Class: RubyTerm

Inherits:
Object
  • Object
show all
Defined in:
lib/rubyterm/app.rb,
lib/rubyterm/version.rb

Overview

The X11 terminal application: owns the X window, the pty controller, the input-processing thread and the blink/flush timers, and wires the interpreter (Term) + damage tracker (TrackChanges) + buffer to the X11 backend (Window via WindowAdapter). The reusable terminal engine lives in the other lib/ files; this class is the executable front end (bin/rubyterm).

Component classes and the X11/skrift/toml dependencies are loaded by lib/rubyterm.rb, which requires this file last.

Constant Summary collapse

TOPMOST =
0
LEFTMOST =
0
JUMP_BACKLOG =

Everything that touches the buffer/window runs here, on the single input-processing thread. The blink and flush timers enqueue :blink and :flush rather than touching the buffer from their own threads: the buffer/renderer is not thread-safe, and concurrent mutation (e.g. blink’s redraw racing a feed mid-scroll) corrupts cells non-deterministically. Serializing through the queue is the synchronization. When more than this many items are already queued behind the chunk we just processed, we treat it as a flood and jump-scroll: keep interpreting (the buffer + scrollback still update) but stop rendering every frame. The screen catches up at the flush tick or when we drain.

8
VERSION =
"0.1.0"

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(args) ⇒ RubyTerm

Returns a new instance of RubyTerm.



33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
# File 'lib/rubyterm/app.rb', line 33

def initialize(args)
  initconfig

  @queue = Queue.new
  # Coalesce redraw-causing events (resize/expose): a drag fires a
  # flood of ConfigureNotify + Expose, and repainting on each one makes
  # the display lag while it drains the backlog. Keep at most one
  # redraw request queued, always for the latest pending size.
  @redraw_mutex = Mutex.new
  @redraw_pending = false
  @pending_resize = nil

  # DECCOLM (80/132 column switch) mode: :font rescales the glyph cell to
  # fit the new column count in the current window (reliable everywhere);
  # :window asks the WM to resize the window. Default :font.
  @deccolm_mode = (@config[:deccolm] || "font").to_s.to_sym

  @window = Window.new(fonts: @config[:fonts], fontsize: @config[:fontsize])
  @adapter = WindowAdapter.new(@window, self)

  # Yes, this is "bad" and we should define our
  # own, however, I'd prefer to match rxvt or similar
  # sufficiently that we can rely on a TERM setting that
  # "everyone" already has in their termcap. rxvt seems to
  # work better than xterm, but will adjust and consider
  # providing multiple modes
  ENV["TERM"] = "rxvt-256color"
  ENV["COLORTERM"] = "truecolor"

  while args[0].to_s[0] == ?-
    case args[0]
    when "--"
      args.shift
      break
    when "-c"
      args.shift
      @term_instance = args.shift
    else break
    end
  end

  @window.dpy.change_property(:replace,
    @window.wid, "WM_CLASS",
    @window.dpy.atom("STRING"), 8, "rterm\0#{@term_instance||'rterm'}\0".unpack("C*"))

  @window.map_window

  @buffer = TrackChanges.new(TermBuffer.new, @adapter)
  @buffer.defer = true # damage-driven rendering: set() mutates, flush draws
  @term = Term.new(@buffer)
  @buffer.on_resize(@term.width, @term.height)

  # Give window access to the buffer for scrollback
  @window.set_buffer(@buffer)

end

Instance Attribute Details

Returns the value of attribute blink_state.



13
14
15
# File 'lib/rubyterm/app.rb', line 13

def blink_state
  @blink_state
end

Returns the value of attribute rblink_state.



13
14
15
# File 'lib/rubyterm/app.rb', line 13

def rblink_state
  @rblink_state
end

Instance Method Details

#adjust_fontsize(delta) ⇒ Object



273
274
275
276
277
278
# File 'lib/rubyterm/app.rb', line 273

def adjust_fontsize(delta)
  @window.adjust_fontsize(delta)
  resize(@pixelw,@pixelh)
  @window.clear(0,0,@pixelw,@pixelh)
  redraw
end

#bgObject



171
# File 'lib/rubyterm/app.rb', line 171

def bg = @term.bg


358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
# File 'lib/rubyterm/app.rb', line 358

def blink
  t = Time.now
  doblink = false
  #p [@blink_state, @rblink_state, @lastblink, @lastrblink]
  if ((t - @lastblink)*10).to_i > 6
    @lastblink = t
    @blink_state = !@blink_state
    doblink = true
  end
  if ((t - @lastrblink)*10).to_i >= 2
    @lastrblink = t
    @rblink_state = !@rblink_state
    doblink = true
  end
  # FIXME: It bugs out at some point?
  @buffer.redraw_blink if doblink
end

#char_hObject



16
# File 'lib/rubyterm/app.rb', line 16

def char_h  = @adapter.char_h

#char_wObject



15
# File 'lib/rubyterm/app.rb', line 15

def char_w  = @adapter.char_w

#clear_selection_if_setObject



435
436
437
438
439
440
441
442
# File 'lib/rubyterm/app.rb', line 435

def clear_selection_if_set
  return if !@select_startpos
  sb = @window.scrollback_count
  (@selection_damage || []).each { |x,sy| @buffer.redraw_display(x, sy, sb) }
  @select_startpos = nil
  # FIXME
  redraw
end

#coalesced_redrawObject

Process a coalesced redraw on the processing thread: resize to the latest pending size if it changed, otherwise just repaint.



260
261
262
263
264
265
266
267
268
269
270
271
# File 'lib/rubyterm/app.rb', line 260

def coalesced_redraw
  size = @redraw_mutex.synchronize do
    @redraw_pending = false
    @pending_resize
  end
  if size && size != @last_resize
    @last_resize = size
    resize(size[0], size[1]) # resize repaints as needed
  else
    redraw
  end
end

#each_character(&block) ⇒ Object



115
# File 'lib/rubyterm/app.rb', line 115

def each_character(&block) = @buffer.each_character(&block)

#event_threadObject



521
522
523
524
525
526
527
528
529
# File 'lib/rubyterm/app.rb', line 521

def event_thread
  Thread.new do
    loop do
      pkt = @window.dpy.next_packet
      process(pkt)
      Thread.pass
    end
  end
end

#exit_scrollbackObject

Sending input to the pty must snap the view back to the live screen; otherwise typed/echoed output is drawn over the scrolled-back display.



378
379
380
# File 'lib/rubyterm/app.rb', line 378

def exit_scrollback
  redraw if @window.scrollback_reset
end

#fgObject



170
# File 'lib/rubyterm/app.rb', line 170

def fg = @term.fg

#get_selectionObject



422
423
424
425
426
427
428
429
430
431
432
433
# File 'lib/rubyterm/app.rb', line 422

def get_selection
  startpos = @select_startpos
  endpos   = @select_endpos
  str = ""
  ypos = nil
  @buffer.each_character_between(startpos[0]..startpos[1], endpos[0]..endpos[1]) do |x,y,cell|
    str += "\n" if ypos && y != ypos
    ypos = y
    str << (cell[0].chr(Encoding::UTF_8) rescue "")
  end
  str
end

#get_xObject



30
# File 'lib/rubyterm/app.rb', line 30

def get_x = @term.x

#get_yObject



31
# File 'lib/rubyterm/app.rb', line 31

def get_y = @term.y

#handle_mouse(pkt) ⇒ Object



444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
# File 'lib/rubyterm/app.rb', line 444

def handle_mouse(pkt)
  @term.mouse_buttons = button = pkt.detail > 0 ? pkt.detail : @term.mouse_buttons
  release = pkt.is_a?(X11::Form::ButtonRelease)
  x = pkt.event_x / char_w
  y = pkt.event_y / char_h
  # Holding Shift forces a local text selection even when the
  # application has grabbed the mouse (mouse reporting on - e.g. Claude's
  # agent picker, or any full-screen app with clickable UI). This is the
  # standard xterm override so you can always select/copy.
  shift = pkt.state.anybits?(0x01) # ShiftMask
  case shift ? nil : @term.mouse_mode
  when nil
    # Selection works in buffer coordinates: when scrolled back, the
    # row under the pointer is a scrollback line (buffer row
    # screen_y - scrollback_count, negative for scrollback). Without
    # this, selection/copy reads the live screen instead of what is
    # actually displayed.
    y -= @window.scrollback_count
    # New selection, but the old has not been cleared yet
    if @released
      clear_selection_if_set
      @released = false
    end

    @select_startpos ||= [x,y]
    if [x,y] != @select_endpos
      @select_endpos = [x,y]
      # FIXME: Optimize rendering of selection further
      render_selection
    end

    if release
      @released = true
      if @select_startpos != @select_endpos
        sel = get_selection
        io = IO.popen("xsel -i", "a+")
        io.write(sel)
        io.close
      else
        clear_selection_if_set
      end
    end
  when :vt200, :btn_event
    # FIXME: This is only right for  @mouse_reporting == :digits
    # FIXME: Report modifiers.
    # Not reporting release for scroll wheel
    return if release && button >= 4
    event = [0,1,2,64,65][button-1]
    event += 32 if pkt.is_a?(X11::Form::MotionNotify)
    #button = [0,1,2,4,5][button-1]
    @controller.mouse_report(@term.mouse_reporting, event, x,y, release)
  end
end

#initconfigObject



20
21
22
23
24
25
26
# File 'lib/rubyterm/app.rb', line 20

def initconfig
  cname = File.expand_path("~/.config/rterm/config.toml")
  if File.exist?(cname)
    @config = TomlRB.load_file(cname, symbolize_keys: true)
  end
  @config ||= {}
end

#inspectObject



28
# File 'lib/rubyterm/app.rb', line 28

def inspect = "<RubyTerm #{self.object_id}>"

#jump_redrawObject

Resume rendering and repaint the whole current screen at once - the jump-scroll “catch up”, skipping every frame that scrolled past while suspended.



238
239
240
241
# File 'lib/rubyterm/app.rb', line 238

def jump_redraw
  @buffer.suspend = false
  redraw
end

#key(event) ⇒ Object



280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
# File 'lib/rubyterm/app.rb', line 280

def key(event)
  #p event
  ks, str = lookup_string(@window.dpy, event)
  case ks
    
  when :"ctrl_+" then adjust_fontsize(1.0)
  when :"ctrl_-" then adjust_fontsize(-1.0)
  when :shift_page_up
    @window.scrollback_page_up
    # Full redraw to show scrollback buffer
    redraw
    return
  when :shift_page_down
    # Don't do anything if not in scrollback mode
    return if !@window.scrollback_mode
    
    # If we're exiting scrollback mode or still in it
    changed = @window.scrollback_page_down
    
    # Redraw everything from scratch
    redraw
    
    # Explicitly force cursor to be redrawn if exiting scrollback mode
    if changed
      # Clear cursor if it exists
      @term.clear_cursor
      # Force draw cursor again
      @term.draw_cursor
      # Ensure changes are flushed
      @buffer.draw_flush
      @window.flush
    end
    return
  when :XK_Insert  # Paste primary selection
    # FIXME: Giant hack
    primary = `xsel -p`
    if primary.chomp.empty?
      primary = @primary
    else
      @primary = primary
    end
    exit_scrollback
    @controller.paste(primary)
    return
  when "C"
    # FIXME. Cstrl + shift + c
    if str == "\x03"  # Copy primary selection into clipboard
      system("xsel -o -p | xsel -i -b")
      return
    end
  when "V"
    # FIXME. Cstrl + shift + v
    if str == "\x16"  # Paste clipboard
      clipboard = `xsel -b`
      if clipboard.chomp.empty?
        clipboard = @clipboard
      else
        @clipboard = clipboard
      end
      exit_scrollback
      @controller.paste(clipboard)
      return
    end
  when :XK_Menu;
    # FIXME: Want deskmenu here, but as long as we're not running the shell, we don't know pwd.
    puts "FIXME: deskmenu"
    # FIXME: In the meantime we use it as a debugging tool to force redraw.
    redraw
    render_text_buffer
  end
  payload = keysym_to_vt102(ks) || str
  # Only snap to the live screen when we're actually sending input;
  # bare modifiers (Ctrl/Shift) produce no payload and must not disturb
  # scrollback (e.g. while setting up a Ctrl+Shift+C copy from history).
  exit_scrollback unless payload.nil? || payload.empty?
  @controller.keypress(payload)
end

#process(pkt) ⇒ Object



498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
# File 'lib/rubyterm/app.rb', line 498

def process(pkt)
#  p pkt
  case pkt
  when X11::Form::ButtonPress, X11::Form::MotionNotify, X11::Form::ButtonRelease
    handle_mouse(pkt)
  when X11::Form::KeyPress
    key(pkt)
  when X11::Form::KeyRelease,
       X11::Form::NoExposure
    # Intentionally ignored
  when X11::Form::ConfigureNotify
    # Real size change: pkt.width/height are the new window size.
    request_redraw([pkt.width, pkt.height])
  when X11::Form::Expose
    # Damage, NOT a size change: pkt.width/height are the exposed
    # rectangle, not the window. Repaint only; never resize here (that
    # would shrink the terminal to the strip size).
    request_redraw
  else
    # Other X events (MapNotify, etc.) are not acted on.
  end
end

#process_chunk(str) ⇒ Object



197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
# File 'lib/rubyterm/app.rb', line 197

def process_chunk(str)
  case str
  when :blink      then return blink
  when :flush
    # Mid-flood: jump the display to the current state at the flush rate
    # instead of painting every scrolled-off frame.
    jump_redraw if @buffer.suspend
    return @window.flush
  when :do_redraw  then return coalesced_redraw
  end

  # FIXME: Could be smarter about this; it's only needed if the
  # first character being written won't clear the same square.
  @term.clear_cursor

  @term.feed(str)

  if @queue.size > JUMP_BACKLOG
    # A flood is backing up. Render this frame, then suspend per-chunk
    # rendering: subsequent chunks only mutate the buffer until the flush
    # tick jumps the display forward or we catch up.
    @buffer.draw_flush
    @buffer.suspend = true
  elsif @buffer.suspend
    jump_redraw          # drained after a flood: redraw the final state
  else
    # Draw the chunk's damaged content (damage-driven flush), then the
    # cursor overlay on top.
    @buffer.draw_flush
    @term.draw_cursor
    @buffer.draw_flush # Ensure everything has been rendered
    # Output just repainted cells without the selection overlay; re-stamp
    # it so a streaming program (top, full-screen apps) doesn't erase the
    # highlight out from under an in-progress copy.
    reapply_selection
  end
end

#process_queueObject



182
# File 'lib/rubyterm/app.rb', line 182

def process_queue = process_chunk(@queue.shift)

#reapply_selectionObject

Re-stamp the active selection highlight on top of freshly drawn content. The selection is an overlay that is NOT stored in the buffer, so any output - or a full redraw - that repaints those cells erases the highlight. Re-applying it after each draw keeps the selection visible while a program streams output (e.g. top repainting, or a full-screen app), which a one-shot paint at mouse-time cannot do.



390
391
392
393
394
395
396
397
398
399
# File 'lib/rubyterm/app.rb', line 390

def reapply_selection
  return unless @select_startpos && @select_endpos
  sb = @window.scrollback_count
  @buffer.each_character_between(@select_startpos[0]..@select_startpos[1], @select_endpos[0]..@select_endpos[1]) do |x,y,cell|
    sy = y + sb
    next if sy < 0 || sy >= @term.height
    @buffer.redraw_cell_at(x, sy, cell, fg: 0xffffff, bg: 0xff00ff)
  end
  @buffer.draw_flush
end

#redrawObject



90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
# File 'lib/rubyterm/app.rb', line 90

def redraw
  # Always clear the entire screen before redrawing to ensure all areas are cleaned up
  # This ensures no artifacts remain at the ends of lines
  @window.clear(0, 0, @window.width, @window.height)
  
  if @window.scrollback_mode
    @buffer.redraw_all(@window.scrollback_count)
  else
    @buffer.redraw_all(0)
  end
  
  # Make sure changes are rendered and cursor is shown
  @buffer.draw_flush
  @term.draw_cursor
  # A full repaint draws only buffer content; restore the selection
  # overlay on top so it survives resizes, exposes and scrollback redraws.
  reapply_selection
end

#redraw_positions(positions) ⇒ Object



382
# File 'lib/rubyterm/app.rb', line 382

def redraw_positions(positions) = positions.each { |pos| @buffer.redraw(*pos) }

#render_selectionObject

FIXME: Cursor, selection etc. are “special” overlays on top of attributes. Allow the terminal to set a set of positions + fg/bg, and a set of ranges.



403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
# File 'lib/rubyterm/app.rb', line 403

def render_selection
  # Selection positions are in *buffer* coordinates (negative rows are
  # scrollback); the screen row is buffer_row + scrollback_count. Damage
  # is tracked in screen coordinates so it can be repainted later.
  sb = @window.scrollback_count
  olddamage = @selection_damage || Set.new
  @selection_damage = Set.new
  @buffer.each_character_between(@select_startpos[0]..@select_startpos[1], @select_endpos[0]..@select_endpos[1]) do |x,y,cell|
    sy = y + sb
    next if sy < 0 || sy >= @term.height
    @selection_damage << [x,sy]
    @buffer.redraw_cell_at(x, sy, cell, fg: 0xffffff, bg: 0xff00ff)
  end
  # Repaint cells that left the selection with their displayed content.
  (olddamage - @selection_damage).each { |x,sy| @buffer.redraw_display(x, sy, sb) }
  @buffer.draw_flush
  #@window.flush
end

#render_text_bufferObject



109
110
111
112
113
# File 'lib/rubyterm/app.rb', line 109

def render_text_buffer
  (0...@term.height).each {|y|
    puts @buffer.buffer.getline(y).map {|a| a ? a[0].chr(Encoding::UTF_8):" " }.join
  }
end

#request_redraw(size = nil) ⇒ Object

Request a redraw (from the event thread). Records the latest pending size and enqueues a single :do_redraw marker; while one is already queued, further requests just update the target. This collapses a drag’s flood of ConfigureNotify+Expose into one repaint per processing slot at the most recent size, instead of one per event.



248
249
250
251
252
253
254
255
256
# File 'lib/rubyterm/app.rb', line 248

def request_redraw(size = nil)
  enqueue = false
  @redraw_mutex.synchronize do
    @pending_resize = size if size
    enqueue = true unless @redraw_pending
    @redraw_pending = true
  end
  @queue << :do_redraw if enqueue
end

#resize(w, h) ⇒ Object



117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
# File 'lib/rubyterm/app.rb', line 117

def resize(w,h)
  @pixelw||=0
  @pixelh||=0
  should_redraw = w >= @pixelw || h >= @pixelh
  @pixelw=w
  @pixelh=h
  @window.on_resize(w,h)

  w = w/char_w
  h = h/char_h
  return if w <= 0 || h <= 0 # FIXME: WTF?!?
  #if w != @term.width && h == @term.height
  @buffer.on_resize(w,h)
  ow, oh = @term.width, @term.height

  if ow != w || oh != h
    @term.resize(w,h)
    @controller.report_size(w,h)
  end

  if should_redraw
    redraw
  end
end

#run(args) ⇒ Object



532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
# File 'lib/rubyterm/app.rb', line 532

def run(args)
  @controller = Controller.new(self, @config)
  @controller.run(*args)
  @term.responder = @controller

  @lastblink  ||= Time.now
  @lastrblink ||= Time.now

  Thread.abort_on_exception = true
  threads =[]
  
  threads << Thread.new do
    loop do
      process_queue
      Thread.pass
    end
  end

  threads << event_thread
  
  threads << Thread.new do
    loop do
      sleep(0.1)
      # Enqueue rather than calling blink directly: blink redraws and
      # so touches the buffer, which must only be mutated on the
      # processing thread. blink self-gates on elapsed time, so a
      # fixed tick is fine.
      @queue << :blink
    end
  end

  # Flush on the processing thread too, so the @buf->window copy never
  # races a concurrent buffer mutation.
  threads << Thread.new do
    loop do
      @queue << :flush
      sleep(1/30.0)
    end
  end

  if ENV["DEBUG"].to_s.strip != ""
    while cmd = STDIN.gets&.strip
      binding.pry if cmd == "pry"
    end
  end

  threads.each(&:join)
end

#set_columns(cols) ⇒ Object

DECCOLM: realise an 80/132 column switch (called via the adapter from Term#set_width_and_clear). font mode rescales the glyph cell so ‘cols` columns fit the current window, keeping the row count; window mode asks the WM to resize. Either way the pty is told the new size.



146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
# File 'lib/rubyterm/app.rb', line 146

def set_columns(cols)
  return if @deccolm_mode == :off   # ignore DECCOLM entirely
  cols = cols.to_i
  return if cols <= 0 || @pixelw.to_i <= 0

  if @deccolm_mode == :window
    # WM-driven: the resulting ConfigureNotify completes the change via resize().
    @window.request_pixel_size(cols * char_w, @pixelh.to_i)
    return
  end

  # font mode. Rescale the glyph cell (up or down) so `cols` columns fit
  # the current window, keeping the row count. The window is NOT resized;
  # integer cell widths mean the rightmost columns may not reach the window
  # edge exactly (an accepted artefact - we don't do sub-pixel placement).
  rows = @term.height
  @window.fit_columns(cols, @pixelw.to_i)
  @buffer.on_resize(cols, rows)
  @term.resize(cols, rows)
  @controller.report_size(cols, rows)
  redraw
end

#term_heightObject



18
# File 'lib/rubyterm/app.rb', line 18

def term_height = @term.height

#term_widthObject



17
# File 'lib/rubyterm/app.rb', line 17

def term_width = @term.width

#write(str) ⇒ Object

Escape/control/character interpretation lives in Term (lib/term.rb). RubyTerm only owns the X11 window, the pty controller and the threading; this keeps the terminal core testable headlessly (see harness/).



178
179
180
# File 'lib/rubyterm/app.rb', line 178

def write(str)
  @queue << str
end