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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: add0fe16033c81c852e4605472ffee6aa840f4639864f04bbbadcbe15dcca024
4
- data.tar.gz: 238ffdf540e08d38630c89034256826975250fb7ed13825d96a73fb7e50e51a9
3
+ metadata.gz: b689e43998c1e63f2cd607d3200aeb67d7cf49d6a1ce685ab532ea525132b41c
4
+ data.tar.gz: a6b2201c362d87c3114762e30cc55a2b90af4bd3d7ceec2fe4b14beaabc61d72
5
5
  SHA512:
6
- metadata.gz: 71ca07c93b9e67c83a1385bb49d5dd5063e1d56e03f257bc2f8d8e93acde384fe23a8e4cb45fc19ce307dffb7373b9c6aa8edcee17d82dc774f9ad0baf4be49a
7
- data.tar.gz: d9d84c93ec626ca6e44d197a257b19bd33769c800452c16c9effed0c1b61e377c4fd28a2073f2dce20bff70374489cca25065ce27c47e96547d42e751ef4a267
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 = "/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf"
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
- @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
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
- next if ch == " "
74
- blit_glyph(ch.ord, x + i * @char_w, y, fg)
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
- alpha, gw, gh, lsb, yoff = glyph(codepoint)
130
- return unless alpha
131
- gx = cx + lsb
132
- gy = cy + @baseline - yoff
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
- # [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
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 = *PTY.spawn(*cmd)
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
- @buffer.each_character_between(@select_startpos[0]..@select_startpos[1], @select_endpos[0]..@select_endpos[1]) do |x,y,cell|
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
- @buffer.each_character_between(@select_startpos[0]..@select_startpos[1], @select_endpos[0]..@select_endpos[1]) do |x,y,cell|
412
- sy = y + sb
413
- next if sy < 0 || sy >= @term.height
414
- @selection_damage << [x,sy]
415
- @buffer.redraw_cell_at(x, sy, cell, fg: 0xffffff, bg: 0xff00ff)
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 = @select_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
- str << (cell[0].chr(Encoding::UTF_8) rescue "")
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
- process(pkt)
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class RubyTerm
4
- VERSION = "0.1.2"
4
+ VERSION = "0.2.1"
5
5
  end
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, 1, [32,0,0,0])
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 += 1
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
- xmax = y == ymax ? xend + 1 : line.length - 1
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
- def redraw(x,y) = draw_buffered(x,y, @buffer.get(x,y), true)
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
- cell = Array(@buffer.get(x,y)).dup
178
- cell[0] ||= " "
179
- cell[1] = fg if fg
180
- cell[2] = bg if bg
181
- draw_buffered(x,y, cell, true)
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
- draw_buffered(screen_x, screen_y, @buffer.get(screen_x, buffer_y), true)
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
- # then yield the prefix (encoding-tagged) to the caller's per-character
34
- # iterator.
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
- last = str.length-1
40
+ yield_len = str.length
48
41
 
49
42
  if str[-1].ord >= 0x80
50
- # -1 is part of a multibyte sequence (a continuation byte 0x80-0xBF
51
- # or a lead byte 0xC0+). NB: this must be >= 0x80, not > 0x80 - a
52
- # trailing 0x80 is the second byte of e.g. an em-dash (E2 80 94)
53
- # split across a pty read; treating it as complete dropped the
54
- # lead bytes and orphaned the final byte into the next chunk.
55
- if str.length == 1
56
- # Single byte that starts a multibyte sequence
57
- last = -2 # Process nothing, save everything
58
- elsif str[-2] && str[-2].ord & 0xe0 == 0xc0 # -2..-1 is a 2 byte sequence; we're good.
59
- elsif str[-2] && str[-2].ord & 0xe0 == 0xe0 # Start of a 3 or 4 byte sequence
60
- last = str.length-3
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
- end
55
+ yield_len = j if needed.positive? && (str.length - j) < needed
69
56
  end
70
- @leftover = str[last+1..-1].b
71
- complete = str[0..last].force_encoding("UTF-8")
72
- yield complete
73
- @buffer = @leftover
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.2
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-20 00:00:00.000000000 Z
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: '0'
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: '0'
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: '0'
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: '0'
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