rubyterm 0.1.2 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/lib/bitmapwindow.rb +78 -34
- data/lib/charwidth.rb +71 -0
- data/lib/controller.rb +14 -1
- data/lib/keymap.rb +1 -1
- data/lib/rubyterm/app.rb +47 -28
- data/lib/rubyterm/version.rb +1 -1
- data/lib/term.rb +16 -3
- data/lib/termbuffer.rb +4 -1
- data/lib/trackchanges.rb +34 -7
- data/lib/utf8decoder.rb +22 -35
- data/lib/window.rb +33 -1
- metadata +21 -6
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b689e43998c1e63f2cd607d3200aeb67d7cf49d6a1ce685ab532ea525132b41c
|
|
4
|
+
data.tar.gz: a6b2201c362d87c3114762e30cc55a2b90af4bd3d7ceec2fe4b14beaabc61d72
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 005e2010106589917df481c5ae217959114a8f80a7ce5787855dab5ef9ed69fa14b9c840781899e609e0d795f39f022fe60ff46448960dd0f2a713dc00c4a2f4
|
|
7
|
+
data.tar.gz: e6182b8a2b492e41777386c0a2cc8626d3e5195a89b14b6e46639cb5e5f7e1baa2abde4b1a135bba08004f78a5077075dcebf089b790cf9fcd27296ec011031a
|
data/lib/bitmapwindow.rb
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
require 'skrift'
|
|
2
2
|
require 'zlib'
|
|
3
|
+
require_relative 'charwidth'
|
|
4
|
+
|
|
5
|
+
# Colour-glyph (emoji) support is optional.
|
|
6
|
+
begin
|
|
7
|
+
require 'skrift/color'
|
|
8
|
+
rescue LoadError
|
|
9
|
+
# skrift-color not installed; emoji render monochrome (or as tofu).
|
|
10
|
+
end
|
|
3
11
|
|
|
4
12
|
# A third implementation of the drawing interface WindowAdapter targets
|
|
5
13
|
# (alongside the X11 Window and the harness's VirtualWindow): it rasterises
|
|
@@ -15,21 +23,27 @@ require 'zlib'
|
|
|
15
23
|
class BitmapWindow
|
|
16
24
|
attr_reader :width, :height, :pixels
|
|
17
25
|
|
|
18
|
-
DEFAULT_FONT
|
|
26
|
+
DEFAULT_FONT = "/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf"
|
|
27
|
+
DEFAULT_EMOJI = "/usr/share/fonts/truetype/noto/NotoColorEmoji.ttf"
|
|
28
|
+
|
|
29
|
+
# Routes emoji codepoints to a colour renderer and leaves everything else to
|
|
30
|
+
# the monochrome path. The emoji? gate (Unicode classification) keeps text
|
|
31
|
+
# that a colour font happens to map — digits, '#', '*' — rendering as text.
|
|
32
|
+
ColourDelegate = Struct.new(:renderer) do
|
|
33
|
+
def render(cp) = CharWidth.emoji?(cp) ? renderer.render(cp) : nil
|
|
34
|
+
end
|
|
19
35
|
|
|
20
36
|
def initialize(cols, rows, font: DEFAULT_FONT, size: 16,
|
|
21
|
-
fg: 0xcccccc, bg: 0x000000)
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
@
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
@char_h =
|
|
28
|
-
@baseline =
|
|
29
|
-
@char_w = @sft.gmetrics(@sft.lookup("M".ord)).advance_width.round
|
|
37
|
+
fg: 0xcccccc, bg: 0x000000, emoji: DEFAULT_EMOJI)
|
|
38
|
+
# The glyph pipeline (rasterise + cache + metrics) lives in skrift's
|
|
39
|
+
# GlyphCache; colour emoji come from an optional colour delegate.
|
|
40
|
+
@cache = Skrift::GlyphCache.new(font, x_scale: size, y_scale: size,
|
|
41
|
+
color: colour_delegate(emoji, size))
|
|
42
|
+
@char_w = @cache.cell_width
|
|
43
|
+
@char_h = @cache.cell_height
|
|
44
|
+
@baseline = @cache.baseline
|
|
30
45
|
@cols, @rows = cols, rows
|
|
31
46
|
@fg, @bg = fg, bg
|
|
32
|
-
@glyphs = {}
|
|
33
47
|
resize(cols * @char_w, rows * @char_h)
|
|
34
48
|
end
|
|
35
49
|
|
|
@@ -70,8 +84,9 @@ class BitmapWindow
|
|
|
70
84
|
def draw(x, y, str, fg, bg, _lineattrs = nil)
|
|
71
85
|
fillrect(x, y, str.length * @char_w, @char_h, bg)
|
|
72
86
|
str.each_char.with_index do |ch, i|
|
|
73
|
-
|
|
74
|
-
|
|
87
|
+
cp = ch.ord
|
|
88
|
+
next if cp == 32 || cp == CharWidth::WIDE_SPACER # space / wide-glyph tail
|
|
89
|
+
blit_glyph(cp, x + i * @char_w, y, fg)
|
|
75
90
|
end
|
|
76
91
|
end
|
|
77
92
|
|
|
@@ -109,6 +124,12 @@ class BitmapWindow
|
|
|
109
124
|
|
|
110
125
|
private
|
|
111
126
|
|
|
127
|
+
def colour_delegate(emoji, size)
|
|
128
|
+
return nil unless emoji && File.exist?(emoji) && defined?(Skrift::Color::Renderer)
|
|
129
|
+
cr = Skrift::Color::Renderer.new(Skrift::Font.load(emoji), x_scale: size, y_scale: size)
|
|
130
|
+
cr.color? ? ColourDelegate.new(cr) : nil
|
|
131
|
+
end
|
|
132
|
+
|
|
112
133
|
def png_chunk(type, data)
|
|
113
134
|
body = type.b + data.b
|
|
114
135
|
[data.bytesize].pack("N") + body + [Zlib.crc32(body)].pack("N")
|
|
@@ -126,10 +147,20 @@ class BitmapWindow
|
|
|
126
147
|
end
|
|
127
148
|
|
|
128
149
|
def blit_glyph(codepoint, cx, cy, fg)
|
|
129
|
-
|
|
130
|
-
return unless
|
|
131
|
-
|
|
132
|
-
|
|
150
|
+
g = @cache.glyph(codepoint)
|
|
151
|
+
return unless g
|
|
152
|
+
if g.color?
|
|
153
|
+
blit_rgba(g, codepoint, cx, cy)
|
|
154
|
+
elsif g.alpha
|
|
155
|
+
blit_alpha(g, cx, cy, fg)
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Monochrome glyph: alpha mask tinted with the foreground colour.
|
|
160
|
+
def blit_alpha(g, cx, cy, fg)
|
|
161
|
+
alpha, gw, gh = g.alpha, g.width, g.height
|
|
162
|
+
gx = cx + g.left_side_bearing
|
|
163
|
+
gy = cy + @baseline - g.y_offset
|
|
133
164
|
gh.times do |row|
|
|
134
165
|
py = gy + row
|
|
135
166
|
next if py < 0 || py >= @height
|
|
@@ -146,6 +177,29 @@ class BitmapWindow
|
|
|
146
177
|
end
|
|
147
178
|
end
|
|
148
179
|
|
|
180
|
+
# Colour glyph (emoji): RGBA bitmap composited over the cells, centred in its
|
|
181
|
+
# cell span (emoji are double-width, so span is two cells).
|
|
182
|
+
def blit_rgba(g, codepoint, cx, cy)
|
|
183
|
+
span = CharWidth.width(codepoint) * @char_w
|
|
184
|
+
gx = cx + (span - g.width) / 2
|
|
185
|
+
gy = cy + (@char_h - g.height) / 2
|
|
186
|
+
g.height.times do |row|
|
|
187
|
+
py = gy + row
|
|
188
|
+
next if py < 0 || py >= @height
|
|
189
|
+
base = py * @width
|
|
190
|
+
grow = row * g.width
|
|
191
|
+
g.width.times do |col|
|
|
192
|
+
px = gx + col
|
|
193
|
+
next if px < 0 || px >= @width
|
|
194
|
+
rgba = g.rgba[grow + col]
|
|
195
|
+
a = rgba & 0xff
|
|
196
|
+
next if a.zero?
|
|
197
|
+
idx = base + px
|
|
198
|
+
@pixels[idx] = a == 255 ? (rgba >> 8) : over_rgb(rgba >> 8, a, @pixels[idx])
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
|
|
149
203
|
# fg over dst, coverage a (0-255).
|
|
150
204
|
def blend(dst, fg, a)
|
|
151
205
|
ia = 255 - a
|
|
@@ -155,22 +209,12 @@ class BitmapWindow
|
|
|
155
209
|
(r << 16) | (g << 8) | b
|
|
156
210
|
end
|
|
157
211
|
|
|
158
|
-
#
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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
|
|
212
|
+
# src (0xRRGGBB) over dst with alpha a (0-255).
|
|
213
|
+
def over_rgb(src, a, dst)
|
|
214
|
+
ia = 255 - a
|
|
215
|
+
r = ((src >> 16 & 0xff) * a + (dst >> 16 & 0xff) * ia) / 255
|
|
216
|
+
g = ((src >> 8 & 0xff) * a + (dst >> 8 & 0xff) * ia) / 255
|
|
217
|
+
b = ((src & 0xff) * a + (dst & 0xff) * ia) / 255
|
|
218
|
+
(r << 16) | (g << 8) | b
|
|
175
219
|
end
|
|
176
220
|
end
|
data/lib/charwidth.rb
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Character-cell width and emoji classification, by Unicode codepoint.
|
|
4
|
+
#
|
|
5
|
+
# Terminal column width must be a property of the codepoint (not the rendered
|
|
6
|
+
# glyph), so that applications running inside the terminal — which compute their
|
|
7
|
+
# own layout from the same Unicode width — stay in sync. These tables are the
|
|
8
|
+
# canonical East Asian Wide/Fullwidth + emoji blocks (UCD 13.0), curated to the
|
|
9
|
+
# substantial ranges; they're a couple of KB and drive two decisions:
|
|
10
|
+
#
|
|
11
|
+
# * width(cp) -> 1 or 2 cells (wide = CJK/Hangul/Kana/fullwidth + emoji)
|
|
12
|
+
# * emoji?(cp) -> whether to render in colour (the non-CJK wide blocks)
|
|
13
|
+
#
|
|
14
|
+
# Combining marks (width 0) are deliberately not handled yet (treated as 1).
|
|
15
|
+
module CharWidth
|
|
16
|
+
# Codepoint stored in the second cell of a double-width glyph: a blank,
|
|
17
|
+
# advancing placeholder. The terminal writes it after a wide char so the
|
|
18
|
+
# next column is reserved; the X11 backend renders codepoint 0 as a blank
|
|
19
|
+
# advancing glyph and the bitmap/virtual backends skip it.
|
|
20
|
+
WIDE_SPACER = 0
|
|
21
|
+
|
|
22
|
+
# Wide (two-cell) blocks: CJK, Hangul, Kana, fullwidth forms, and emoji.
|
|
23
|
+
WIDE = [
|
|
24
|
+
0x1100..0x115F, 0x231A..0x232A, 0x23E9..0x23F3, 0x25FD..0x27BF,
|
|
25
|
+
0x2B1B..0x2B55, 0x2E80..0xA4C6, 0xA960..0xA97C, 0xAC00..0xD7A3,
|
|
26
|
+
0xF900..0xFAD9, 0xFE10..0xFE6B, 0xFF01..0xFF60, 0xFFE0..0xFFE6,
|
|
27
|
+
0x16FE0..0x18D08, 0x1B000..0x1B2FB, 0x1F18E..0x1F19A, 0x1F200..0x1F265,
|
|
28
|
+
0x1F300..0x1F5A4, 0x1F5FB..0x1F6FC, 0x1F7E0..0x1F7EB, 0x1F90C..0x1F9FF,
|
|
29
|
+
0x1FA70..0x1FAD6, 0x20000..0x2EBE0, 0x2F800..0x2FA1D, 0x30000..0x3134A
|
|
30
|
+
].freeze
|
|
31
|
+
|
|
32
|
+
# The emoji subset of WIDE (excludes CJK/Hangul/Kana/fullwidth): these render
|
|
33
|
+
# in colour when a colour font provides them. Digits, '#', '*' etc. are not
|
|
34
|
+
# here, so they stay as ordinary text even though Noto maps colour keycaps.
|
|
35
|
+
EMOJI = [
|
|
36
|
+
0x231A..0x232A, 0x23E9..0x23F3, 0x25FD..0x27BF, 0x2B1B..0x2B55,
|
|
37
|
+
0x1F18E..0x1F19A, 0x1F200..0x1F265, 0x1F300..0x1F5A4, 0x1F5FB..0x1F6FC,
|
|
38
|
+
0x1F7E0..0x1F7EB, 0x1F90C..0x1F9FF, 0x1FA70..0x1FAD6
|
|
39
|
+
].freeze
|
|
40
|
+
|
|
41
|
+
module_function
|
|
42
|
+
|
|
43
|
+
# Number of terminal cells a codepoint occupies (1 or 2).
|
|
44
|
+
def width(cp)
|
|
45
|
+
return 1 if cp < 0x1100 # fast path: ASCII/Latin and most text
|
|
46
|
+
in_ranges?(WIDE, cp) ? 2 : 1
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def wide?(cp) = width(cp) == 2
|
|
50
|
+
|
|
51
|
+
# Whether a codepoint should render as a colour emoji (when the font has it).
|
|
52
|
+
def emoji?(cp)
|
|
53
|
+
return false if cp < 0x231A
|
|
54
|
+
in_ranges?(EMOJI, cp)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Binary search over a sorted array of non-overlapping ranges.
|
|
58
|
+
def in_ranges?(ranges, cp)
|
|
59
|
+
lo = 0
|
|
60
|
+
hi = ranges.length - 1
|
|
61
|
+
while lo <= hi
|
|
62
|
+
mid = (lo + hi) / 2
|
|
63
|
+
r = ranges[mid]
|
|
64
|
+
if cp < r.begin then hi = mid - 1
|
|
65
|
+
elsif cp > r.end then lo = mid + 1
|
|
66
|
+
else return true
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
false
|
|
70
|
+
end
|
|
71
|
+
end
|
data/lib/controller.rb
CHANGED
|
@@ -16,7 +16,7 @@ class Controller
|
|
|
16
16
|
|
|
17
17
|
def run(*args)
|
|
18
18
|
cmd = args.empty? ? @shell : [@shell, '-c', args.join(' ')]
|
|
19
|
-
@master, @wr, @pid = *
|
|
19
|
+
@master, @wr, @pid = *spawn_child(cmd)
|
|
20
20
|
|
|
21
21
|
Thread.new do
|
|
22
22
|
loop do
|
|
@@ -33,6 +33,19 @@ class Controller
|
|
|
33
33
|
end
|
|
34
34
|
end
|
|
35
35
|
|
|
36
|
+
# Spawn the child shell/command with the user's own gem environment, not
|
|
37
|
+
# rubyterm's bundle: when rubyterm runs under `bundle exec`, BUNDLE_GEMFILE /
|
|
38
|
+
# GEM_PATH leak into children, so a Bundler-based shell (or any tool relying
|
|
39
|
+
# on the system gems) fails to load its gems and exits immediately. Stripping
|
|
40
|
+
# the bundle env restores the pre-bundle-exec behaviour.
|
|
41
|
+
def spawn_child(cmd)
|
|
42
|
+
if defined?(Bundler)
|
|
43
|
+
Bundler.with_unbundled_env { PTY.spawn(*cmd) }
|
|
44
|
+
else
|
|
45
|
+
PTY.spawn(*cmd)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
36
49
|
def read
|
|
37
50
|
@master.read_nonblock(128)
|
|
38
51
|
rescue IO::EAGAINWaitReadable
|
data/lib/keymap.rb
CHANGED
|
@@ -24,7 +24,7 @@ def update_keymap(dpy)
|
|
|
24
24
|
(c-0x01000100).
|
|
25
25
|
chr(Encoding::UTF_32) rescue c.to_s(16)
|
|
26
26
|
else
|
|
27
|
-
STDERR.puts "keymap: unknown_#{c.to_s(16)}"
|
|
27
|
+
STDERR.puts "keymap: unknown_#{c.to_s(16)}" rescue nil
|
|
28
28
|
end
|
|
29
29
|
end.each_slice(reply.keysyms_per_keycode).to_a
|
|
30
30
|
#ks = ks.map {|s| s.compact.sort_by{|x| x.to_s}.uniq }.to_a # This is for testing/ease of reading only
|
data/lib/rubyterm/app.rb
CHANGED
|
@@ -382,6 +382,25 @@ class RubyTerm
|
|
|
382
382
|
|
|
383
383
|
def redraw_positions(positions) = positions.each { |pos| @buffer.redraw(*pos) }
|
|
384
384
|
|
|
385
|
+
# The selection's [start, end] boundary pairs, expanded so a double-width
|
|
386
|
+
# glyph is always whole: if the first selected cell is a WIDE_SPACER tail,
|
|
387
|
+
# include its head; if the last selected cell is a wide head, include its
|
|
388
|
+
# tail. So clipping a selection to either half of an emoji still selects the
|
|
389
|
+
# whole emoji. Returns the raw pair unchanged when there is no selection.
|
|
390
|
+
def selection_bounds
|
|
391
|
+
s, e = @select_startpos, @select_endpos
|
|
392
|
+
return [s, e] unless s && e
|
|
393
|
+
lo, hi = [s, e].sort_by { |x, y| [y, x] }
|
|
394
|
+
# Boundaries -> inclusive cell range: a selection from boundary lo to hi
|
|
395
|
+
# covers cells [lo .. hi-1].
|
|
396
|
+
hi = [hi[0] - 1, hi[1]]
|
|
397
|
+
c = @buffer.get(lo[0], lo[1])
|
|
398
|
+
lo = [lo[0] - 1, lo[1]] if lo[0] > 0 && c && c[0] == CharWidth::WIDE_SPACER
|
|
399
|
+
last = @buffer.get(hi[0], hi[1])
|
|
400
|
+
hi = [hi[0] + 1, hi[1]] if last && last[0] && CharWidth.width(last[0]) == 2
|
|
401
|
+
[lo, hi]
|
|
402
|
+
end
|
|
403
|
+
|
|
385
404
|
# Re-stamp the active selection highlight on top of freshly drawn
|
|
386
405
|
# content. The selection is an overlay that is NOT stored in the buffer,
|
|
387
406
|
# so any output - or a full redraw - that repaints those cells erases the
|
|
@@ -390,8 +409,10 @@ class RubyTerm
|
|
|
390
409
|
# app), which a one-shot paint at mouse-time cannot do.
|
|
391
410
|
def reapply_selection
|
|
392
411
|
return unless @select_startpos && @select_endpos
|
|
412
|
+
return if @select_startpos == @select_endpos # zero-width (click): nothing selected
|
|
393
413
|
sb = @window.scrollback_count
|
|
394
|
-
|
|
414
|
+
spos, epos = selection_bounds
|
|
415
|
+
@buffer.each_character_between(spos[0]..spos[1], epos[0]..epos[1]) do |x,y,cell|
|
|
395
416
|
sy = y + sb
|
|
396
417
|
next if sy < 0 || sy >= @term.height
|
|
397
418
|
@buffer.redraw_cell_at(x, sy, cell, fg: 0xffffff, bg: 0xff00ff)
|
|
@@ -408,11 +429,16 @@ class RubyTerm
|
|
|
408
429
|
sb = @window.scrollback_count
|
|
409
430
|
olddamage = @selection_damage || Set.new
|
|
410
431
|
@selection_damage = Set.new
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
@buffer.
|
|
432
|
+
# A zero-width (click) selection highlights nothing, but still falls
|
|
433
|
+
# through to clear any previous highlight below.
|
|
434
|
+
unless @select_startpos == @select_endpos
|
|
435
|
+
spos, epos = selection_bounds
|
|
436
|
+
@buffer.each_character_between(spos[0]..spos[1], epos[0]..epos[1]) do |x,y,cell|
|
|
437
|
+
sy = y + sb
|
|
438
|
+
next if sy < 0 || sy >= @term.height
|
|
439
|
+
@selection_damage << [x,sy]
|
|
440
|
+
@buffer.redraw_cell_at(x, sy, cell, fg: 0xffffff, bg: 0xff00ff)
|
|
441
|
+
end
|
|
416
442
|
end
|
|
417
443
|
# Repaint cells that left the selection with their displayed content.
|
|
418
444
|
(olddamage - @selection_damage).each { |x,sy| @buffer.redraw_display(x, sy, sb) }
|
|
@@ -421,14 +447,15 @@ class RubyTerm
|
|
|
421
447
|
end
|
|
422
448
|
|
|
423
449
|
def get_selection
|
|
424
|
-
startpos =
|
|
425
|
-
endpos = @select_endpos
|
|
450
|
+
startpos, endpos = selection_bounds
|
|
426
451
|
str = ""
|
|
427
452
|
ypos = nil
|
|
428
453
|
@buffer.each_character_between(startpos[0]..startpos[1], endpos[0]..endpos[1]) do |x,y,cell|
|
|
429
454
|
str += "\n" if ypos && y != ypos
|
|
430
455
|
ypos = y
|
|
431
|
-
|
|
456
|
+
cp = cell && cell[0]
|
|
457
|
+
next if cp == CharWidth::WIDE_SPACER # tail of a double-width glyph: its head already emitted the char
|
|
458
|
+
str << (cp.chr(Encoding::UTF_8) rescue "")
|
|
432
459
|
end
|
|
433
460
|
str
|
|
434
461
|
end
|
|
@@ -454,6 +481,11 @@ class RubyTerm
|
|
|
454
481
|
shift = pkt.state.anybits?(0x01) # ShiftMask
|
|
455
482
|
case shift ? nil : @term.mouse_mode
|
|
456
483
|
when nil
|
|
484
|
+
# Selection coordinates are cell *boundaries* (round to the nearest
|
|
485
|
+
# column edge, not the floor cell), so dragging across a single cell
|
|
486
|
+
# selects exactly that cell and a click (no boundary crossed) selects
|
|
487
|
+
# none. Mouse reporting below keeps the floor cell index.
|
|
488
|
+
x = (pkt.event_x + char_w / 2) / char_w
|
|
457
489
|
# Selection works in buffer coordinates: when scrolled back, the
|
|
458
490
|
# row under the pointer is a scrollback line (buffer row
|
|
459
491
|
# screen_y - scrollback_count, negative for scrollback). Without
|
|
@@ -523,7 +555,12 @@ class RubyTerm
|
|
|
523
555
|
Thread.new do
|
|
524
556
|
loop do
|
|
525
557
|
pkt = @window.dpy.next_packet
|
|
526
|
-
|
|
558
|
+
begin
|
|
559
|
+
process(pkt)
|
|
560
|
+
rescue => e
|
|
561
|
+
# One bad event must not kill the terminal (abort_on_exception is on).
|
|
562
|
+
warn("rubyterm: #{e.class}: #{e.message}") rescue nil
|
|
563
|
+
end
|
|
527
564
|
Thread.pass
|
|
528
565
|
end
|
|
529
566
|
end
|
|
@@ -531,24 +568,6 @@ class RubyTerm
|
|
|
531
568
|
|
|
532
569
|
|
|
533
570
|
def run(args)
|
|
534
|
-
# A launcher (e.g. a menu) can hand us stdout/stderr on a pipe that closes
|
|
535
|
-
# once it exits. Our own debug/error output (keymap warnings etc.) would then
|
|
536
|
-
# raise Errno::EPIPE, and with Thread.abort_on_exception on (below) that
|
|
537
|
-
# would kill the terminal — and the user's window — on the next keypress.
|
|
538
|
-
# If either is a pipe, route our output to a log file so a closed pipe can
|
|
539
|
-
# never crash us. A real tty or plain-file launch is already safe, so leave
|
|
540
|
-
# it alone.
|
|
541
|
-
if [$stdout, $stderr].any? { |io| io.stat.pipe? rescue false }
|
|
542
|
-
begin
|
|
543
|
-
log = File.open(File.expand_path(ENV["RUBYTERM_LOG"] || "~/.rubyterm.log"), "a")
|
|
544
|
-
$stdout.reopen(log); $stderr.reopen(log)
|
|
545
|
-
$stdout.sync = $stderr.sync = true
|
|
546
|
-
rescue SystemCallError
|
|
547
|
-
$stdout.reopen(File::NULL, "w") rescue nil
|
|
548
|
-
$stderr.reopen(File::NULL, "w") rescue nil
|
|
549
|
-
end
|
|
550
|
-
end
|
|
551
|
-
|
|
552
571
|
@controller = Controller.new(self, @config)
|
|
553
572
|
@controller.run(*args)
|
|
554
573
|
@term.responder = @controller
|
data/lib/rubyterm/version.rb
CHANGED
data/lib/term.rb
CHANGED
|
@@ -2,6 +2,7 @@ require_relative 'palette' # PALETTE_BASIC, FG, BG
|
|
|
2
2
|
require_relative 'escapeparser'
|
|
3
3
|
require_relative 'utf8decoder'
|
|
4
4
|
require_relative 'charsets'
|
|
5
|
+
require_relative 'charwidth' # CharWidth.width / WIDE_SPACER
|
|
5
6
|
|
|
6
7
|
# The escape/control interpreter: it turns a byte stream into operations on
|
|
7
8
|
# a buffer, and knows *nothing* about X11, windows, or how its buffer is
|
|
@@ -544,17 +545,29 @@ class Term
|
|
|
544
545
|
scroll_if_needed
|
|
545
546
|
return if ch == 127 # DEL is ignored in the data stream
|
|
546
547
|
|
|
548
|
+
cw = CharWidth.width(ch)
|
|
549
|
+
# A double-width glyph can't straddle the right margin: if only one
|
|
550
|
+
# column is left, blank it and wrap so the glyph starts the next line.
|
|
551
|
+
if cw == 2 && @wraparound && @x == line_width - 1
|
|
552
|
+
@buffer.set(@x, @y, ' ', fg, bg, @mode)
|
|
553
|
+
@x = line_width
|
|
554
|
+
wrap_if_needed
|
|
555
|
+
scroll_if_needed
|
|
556
|
+
end
|
|
557
|
+
|
|
547
558
|
# IRM (insert mode): shift the rest of the line right and repaint it,
|
|
548
|
-
# then drop the new glyph into the gap.
|
|
559
|
+
# then drop the new glyph (and its spacer, if wide) into the gap.
|
|
549
560
|
if @irm
|
|
550
|
-
@buffer.insert(@x, @y,
|
|
561
|
+
@buffer.insert(@x, @y, cw, [32,0,0,0])
|
|
551
562
|
@buffer.set(@x, @y, charset[ch], fg, bg, @mode)
|
|
563
|
+
@buffer.set(@x + 1, @y, CharWidth::WIDE_SPACER, fg, bg, @mode) if cw == 2
|
|
552
564
|
redraw_line_from_cursor
|
|
553
565
|
else
|
|
554
566
|
@buffer.set(@x, @y, charset[ch], fg, bg, @mode)
|
|
567
|
+
@buffer.set(@x + 1, @y, CharWidth::WIDE_SPACER, fg, bg, @mode) if cw == 2
|
|
555
568
|
end
|
|
556
569
|
@y = clamph(@y)
|
|
557
|
-
@x +=
|
|
570
|
+
@x += cw
|
|
558
571
|
scroll_if_needed
|
|
559
572
|
end
|
|
560
573
|
end
|
data/lib/termbuffer.rb
CHANGED
|
@@ -228,7 +228,10 @@ class TermBuffer
|
|
|
228
228
|
xend, ymax = epos.first, epos.end
|
|
229
229
|
(spos.end..ymax).each do |y|
|
|
230
230
|
line = line_at(y) || ""
|
|
231
|
-
|
|
231
|
+
# Inclusive of the end column: a range sx..ex on one row covers cells
|
|
232
|
+
# sx through ex. (Callers convert the half-open boundary selection to an
|
|
233
|
+
# inclusive cell range before calling.)
|
|
234
|
+
xmax = y == ymax ? xend : line.length - 1
|
|
232
235
|
xmax = [xmax, line.length - 1].min
|
|
233
236
|
xmax = 0 if xmax < 0
|
|
234
237
|
while x <= xmax
|
data/lib/trackchanges.rb
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
|
|
2
|
+
require_relative 'charwidth' # CharWidth.width / WIDE_SPACER
|
|
3
|
+
|
|
2
4
|
# FIXME: Roll this into the actual buffer.
|
|
3
5
|
class TrackChanges
|
|
4
6
|
# The cursor is rendered as an overlay - a cell repainted with this
|
|
@@ -144,7 +146,28 @@ class TrackChanges
|
|
|
144
146
|
draw_flush
|
|
145
147
|
end
|
|
146
148
|
|
|
147
|
-
|
|
149
|
+
# The screen-column span [x0,x1] of the glyph occupying (x, buffer_y). A
|
|
150
|
+
# double-width glyph is stored as [head, WIDE_SPACER tail]; redrawing either
|
|
151
|
+
# cell on its own leaves the other half stale (cursor move, blink, a
|
|
152
|
+
# single-cell damage update), so every redraw path expands to the whole
|
|
153
|
+
# glyph. cell[0] is an integer codepoint (or nil for an unset cell).
|
|
154
|
+
def glyph_span(x, buffer_y)
|
|
155
|
+
cell = @buffer.get(x, buffer_y)
|
|
156
|
+
cp = cell && cell[0]
|
|
157
|
+
if cp == CharWidth::WIDE_SPACER && x > 0 &&
|
|
158
|
+
(h = @buffer.get(x - 1, buffer_y)) && h[0] && CharWidth.width(h[0]) == 2
|
|
159
|
+
[x - 1, x]
|
|
160
|
+
elsif cp && CharWidth.width(cp) == 2
|
|
161
|
+
[x, x + 1]
|
|
162
|
+
else
|
|
163
|
+
[x, x]
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def redraw(x,y)
|
|
168
|
+
x0, x1 = glyph_span(x, y)
|
|
169
|
+
(x0..x1).each { |xx| draw_buffered(xx, y, @buffer.get(xx, y), true) }
|
|
170
|
+
end
|
|
148
171
|
|
|
149
172
|
# Render the cursor overlay at (x,y) if +visible+, after restoring the
|
|
150
173
|
# cell under its previous position. A no-op while scrolled back, so the
|
|
@@ -174,11 +197,14 @@ class TrackChanges
|
|
|
174
197
|
end
|
|
175
198
|
|
|
176
199
|
def redraw_with(x,y, fg: nil, bg: nil)
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
200
|
+
x0, x1 = glyph_span(x, y)
|
|
201
|
+
(x0..x1).each do |xx|
|
|
202
|
+
cell = Array(@buffer.get(xx, y)).dup
|
|
203
|
+
cell[0] ||= " "
|
|
204
|
+
cell[1] = fg if fg
|
|
205
|
+
cell[2] = bg if bg
|
|
206
|
+
draw_buffered(xx, y, cell, true)
|
|
207
|
+
end
|
|
182
208
|
end
|
|
183
209
|
|
|
184
210
|
# Draw an already-resolved cell at a *screen* position, optionally
|
|
@@ -197,7 +223,8 @@ class TrackChanges
|
|
|
197
223
|
# content rather than the live buffer's).
|
|
198
224
|
def redraw_display(screen_x, screen_y, scrollback_offset = 0)
|
|
199
225
|
buffer_y = screen_y - scrollback_offset
|
|
200
|
-
|
|
226
|
+
x0, x1 = glyph_span(screen_x, buffer_y)
|
|
227
|
+
(x0..x1).each { |xx| draw_buffered(xx, screen_y, @buffer.get(xx, buffer_y), true) }
|
|
201
228
|
end
|
|
202
229
|
|
|
203
230
|
# Public flush point. In the default (eager) mode draws already happened
|
data/lib/utf8decoder.rb
CHANGED
|
@@ -29,48 +29,35 @@
|
|
|
29
29
|
end
|
|
30
30
|
end
|
|
31
31
|
|
|
32
|
-
# Split @buffer into a complete-sequence prefix and a saved leftover,
|
|
33
|
-
#
|
|
34
|
-
#
|
|
32
|
+
# Split @buffer into a complete-sequence prefix and a saved leftover, then
|
|
33
|
+
# yield the prefix (encoding-tagged) to the caller's per-character iterator.
|
|
34
|
+
# If the buffer ends partway through a multibyte sequence (it was split
|
|
35
|
+
# across a pty read), those trailing bytes are held back for next time;
|
|
36
|
+
# everything before them is yielded.
|
|
35
37
|
private def decode
|
|
36
|
-
# We acknowledge that @buffer can contain
|
|
37
|
-
# sequences that are invalid UTF8, and we will
|
|
38
|
-
# do our best with them *unless*:
|
|
39
|
-
# * @buffer[-1] starts any multibyte sequence
|
|
40
|
-
# * @buffer[-2] starts a 3 or 4 byte sequence
|
|
41
|
-
# * @buffer[-3] starts a 4 byte sequence.
|
|
42
|
-
# In those cases, and those cases only, we
|
|
43
|
-
# save those bytes for next time.
|
|
44
|
-
|
|
45
38
|
str = @buffer
|
|
46
39
|
return nil if !str || str.empty?
|
|
47
|
-
|
|
40
|
+
yield_len = str.length
|
|
48
41
|
|
|
49
42
|
if str[-1].ord >= 0x80
|
|
50
|
-
#
|
|
51
|
-
#
|
|
52
|
-
#
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
else # -2 is *part of a 3 or 4 byte sequence
|
|
62
|
-
if str[-3] && str[-3].ord & 0xf0 == 0xe0 # -3 Starts a 3 byte sequence
|
|
63
|
-
elsif str[-3] && str[-3].ord & 0xf8 == 0xf0 # -3 starts a 4 byte sequence
|
|
64
|
-
last = str.length-4
|
|
65
|
-
else # -2 must be the final byte of something, so we only chop the last
|
|
66
|
-
last = str.length-2
|
|
43
|
+
# Find the lead byte of the final sequence by skipping back over up to
|
|
44
|
+
# three continuation bytes (0x80-0xBF). If that sequence isn't yet
|
|
45
|
+
# complete, hold it (and everything after the lead) for the next chunk.
|
|
46
|
+
j = str.length - 1
|
|
47
|
+
j -= 1 while j.positive? && (str[j].ord & 0xc0) == 0x80 && str.length - j < 4
|
|
48
|
+
lead = str[j].ord
|
|
49
|
+
needed =
|
|
50
|
+
if lead & 0xe0 == 0xc0 then 2
|
|
51
|
+
elsif lead & 0xf0 == 0xe0 then 3
|
|
52
|
+
elsif lead & 0xf8 == 0xf0 then 4
|
|
53
|
+
else 0 # orphan continuation byte or not a lead - nothing to hold back
|
|
67
54
|
end
|
|
68
|
-
|
|
55
|
+
yield_len = j if needed.positive? && (str.length - j) < needed
|
|
69
56
|
end
|
|
70
|
-
|
|
71
|
-
complete = str[0
|
|
72
|
-
|
|
73
|
-
|
|
57
|
+
|
|
58
|
+
complete = str[0, yield_len].force_encoding("UTF-8")
|
|
59
|
+
@buffer = str[yield_len..].b
|
|
60
|
+
yield complete unless complete.empty?
|
|
74
61
|
end
|
|
75
62
|
|
|
76
63
|
end
|
data/lib/window.rb
CHANGED
|
@@ -4,11 +4,26 @@
|
|
|
4
4
|
|
|
5
5
|
require 'skrift'
|
|
6
6
|
require 'skrift/x11'
|
|
7
|
+
require_relative 'charwidth'
|
|
7
8
|
#require 'pry'
|
|
8
9
|
|
|
10
|
+
# Colour-glyph (emoji) support is optional.
|
|
11
|
+
begin
|
|
12
|
+
require 'skrift/color'
|
|
13
|
+
rescue LoadError
|
|
14
|
+
end
|
|
15
|
+
|
|
9
16
|
class Window
|
|
10
17
|
attr_reader :dpy, :wid, :scrollback_count # FIXME
|
|
11
18
|
attr_accessor :width, :height
|
|
19
|
+
|
|
20
|
+
DEFAULT_EMOJI = "/usr/share/fonts/truetype/noto/NotoColorEmoji.ttf"
|
|
21
|
+
|
|
22
|
+
# Gates a colour renderer to emoji codepoints, so text a colour font also
|
|
23
|
+
# maps (e.g. Noto's keycap digits) keeps rendering as ordinary text.
|
|
24
|
+
EmojiColour = Struct.new(:renderer) do
|
|
25
|
+
def render(cp) = CharWidth.emoji?(cp) ? renderer.render(cp) : nil
|
|
26
|
+
end
|
|
12
27
|
|
|
13
28
|
# Get scrollback status
|
|
14
29
|
def scrollback_mode
|
|
@@ -212,12 +227,29 @@ class Window
|
|
|
212
227
|
xs = @col_scale || @scale
|
|
213
228
|
# fit: scale oversized glyphs (e.g. wide spinner/symbol chars) down into
|
|
214
229
|
# the fixed cell instead of letting them overflow/clamp at the edge.
|
|
215
|
-
@skr = Skrift::X11::Glyphs.new(@dpy, fontset: @fontset, x_scale: xs, y_scale: @scale, fixed: true, fit: true
|
|
230
|
+
@skr = Skrift::X11::Glyphs.new(@dpy, fontset: @fontset, x_scale: xs, y_scale: @scale, fixed: true, fit: true,
|
|
231
|
+
color: colour_delegate(@scale))
|
|
216
232
|
# FIXME: Maybe instantiate these as needed.
|
|
217
233
|
@skr_dblheight = Skrift::X11::Glyphs.new(@dpy, fontset: @fontset, x_scale: xs*2, y_scale: @scale*2, fixed: true)
|
|
218
234
|
@skr_dblwidth = Skrift::X11::Glyphs.new(@dpy, fontset: @fontset, x_scale: xs*2, y_scale: @scale, fixed: true)
|
|
219
235
|
end
|
|
220
236
|
|
|
237
|
+
# An emoji-gated colour delegate built from the first colour-capable font in
|
|
238
|
+
# the fontset (or a default emoji font), or nil if none / skrift-color is
|
|
239
|
+
# absent. Passed to the main Glyphs renderer so emoji draw in colour.
|
|
240
|
+
def colour_delegate(scale)
|
|
241
|
+
return nil unless defined?(Skrift::Color::Renderer)
|
|
242
|
+
fonts = Skrift::FontSet.new(Array(@fontset)).each.to_a
|
|
243
|
+
fonts << Skrift::Font.load(DEFAULT_EMOJI) if File.exist?(DEFAULT_EMOJI)
|
|
244
|
+
fonts.each do |f|
|
|
245
|
+
cr = Skrift::Color::Renderer.new(f, x_scale: scale, y_scale: scale)
|
|
246
|
+
return EmojiColour.new(cr) if cr.color?
|
|
247
|
+
end
|
|
248
|
+
nil
|
|
249
|
+
rescue StandardError
|
|
250
|
+
nil
|
|
251
|
+
end
|
|
252
|
+
|
|
221
253
|
def adjust_fontsize(adj)
|
|
222
254
|
@scale += adj
|
|
223
255
|
@scale = @scale.clamp(5, 100)
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: rubyterm
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.1
|
|
4
|
+
version: 0.2.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Vidar Hokstad
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-06-
|
|
11
|
+
date: 2026-06-22 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: pure-x11
|
|
@@ -30,28 +30,42 @@ dependencies:
|
|
|
30
30
|
requirements:
|
|
31
31
|
- - ">="
|
|
32
32
|
- !ruby/object:Gem::Version
|
|
33
|
-
version:
|
|
33
|
+
version: 0.4.0
|
|
34
34
|
type: :runtime
|
|
35
35
|
prerelease: false
|
|
36
36
|
version_requirements: !ruby/object:Gem::Requirement
|
|
37
37
|
requirements:
|
|
38
38
|
- - ">="
|
|
39
39
|
- !ruby/object:Gem::Version
|
|
40
|
-
version:
|
|
40
|
+
version: 0.4.0
|
|
41
41
|
- !ruby/object:Gem::Dependency
|
|
42
42
|
name: skrift-x11
|
|
43
43
|
requirement: !ruby/object:Gem::Requirement
|
|
44
44
|
requirements:
|
|
45
45
|
- - ">="
|
|
46
46
|
- !ruby/object:Gem::Version
|
|
47
|
-
version:
|
|
47
|
+
version: 0.3.0
|
|
48
48
|
type: :runtime
|
|
49
49
|
prerelease: false
|
|
50
50
|
version_requirements: !ruby/object:Gem::Requirement
|
|
51
51
|
requirements:
|
|
52
52
|
- - ">="
|
|
53
53
|
- !ruby/object:Gem::Version
|
|
54
|
-
version:
|
|
54
|
+
version: 0.3.0
|
|
55
|
+
- !ruby/object:Gem::Dependency
|
|
56
|
+
name: skrift-color
|
|
57
|
+
requirement: !ruby/object:Gem::Requirement
|
|
58
|
+
requirements:
|
|
59
|
+
- - ">="
|
|
60
|
+
- !ruby/object:Gem::Version
|
|
61
|
+
version: 0.1.0
|
|
62
|
+
type: :runtime
|
|
63
|
+
prerelease: false
|
|
64
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
65
|
+
requirements:
|
|
66
|
+
- - ">="
|
|
67
|
+
- !ruby/object:Gem::Version
|
|
68
|
+
version: 0.1.0
|
|
55
69
|
- !ruby/object:Gem::Dependency
|
|
56
70
|
name: toml-rb
|
|
57
71
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -86,6 +100,7 @@ files:
|
|
|
86
100
|
- lib/ansibackend.rb
|
|
87
101
|
- lib/bitmapwindow.rb
|
|
88
102
|
- lib/charsets.rb
|
|
103
|
+
- lib/charwidth.rb
|
|
89
104
|
- lib/controller.rb
|
|
90
105
|
- lib/escapeparser.rb
|
|
91
106
|
- lib/keymap.rb
|