Class: RubyTerm
- Inherits:
-
Object
- Object
- RubyTerm
- 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
-
#blink_state ⇒ Object
readonly
Returns the value of attribute blink_state.
-
#rblink_state ⇒ Object
readonly
Returns the value of attribute rblink_state.
Instance Method Summary collapse
- #adjust_fontsize(delta) ⇒ Object
- #bg ⇒ Object
- #blink ⇒ Object
- #char_h ⇒ Object
- #char_w ⇒ Object
- #clear_selection_if_set ⇒ Object
-
#coalesced_redraw ⇒ Object
Process a coalesced redraw on the processing thread: resize to the latest pending size if it changed, otherwise just repaint.
- #each_character(&block) ⇒ Object
- #event_thread ⇒ Object
-
#exit_scrollback ⇒ Object
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.
- #fg ⇒ Object
- #get_selection ⇒ Object
- #get_x ⇒ Object
- #get_y ⇒ Object
- #handle_mouse(pkt) ⇒ Object
- #initconfig ⇒ Object
-
#initialize(args) ⇒ RubyTerm
constructor
A new instance of RubyTerm.
- #inspect ⇒ Object
-
#jump_redraw ⇒ Object
Resume rendering and repaint the whole current screen at once - the jump-scroll “catch up”, skipping every frame that scrolled past while suspended.
- #key(event) ⇒ Object
- #process(pkt) ⇒ Object
- #process_chunk(str) ⇒ Object
- #process_queue ⇒ Object
-
#reapply_selection ⇒ Object
Re-stamp the active selection highlight on top of freshly drawn content.
- #redraw ⇒ Object
- #redraw_positions(positions) ⇒ Object
-
#render_selection ⇒ Object
FIXME: Cursor, selection etc.
- #render_text_buffer ⇒ Object
-
#request_redraw(size = nil) ⇒ Object
Request a redraw (from the event thread).
- #resize(w, h) ⇒ Object
- #run(args) ⇒ Object
-
#set_columns(cols) ⇒ Object
DECCOLM: realise an 80/132 column switch (called via the adapter from Term#set_width_and_clear).
- #term_height ⇒ Object
- #term_width ⇒ Object
-
#write(str) ⇒ Object
Escape/control/character interpretation lives in Term (lib/term.rb).
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
#blink_state ⇒ Object (readonly)
Returns the value of attribute blink_state.
13 14 15 |
# File 'lib/rubyterm/app.rb', line 13 def blink_state @blink_state end |
#rblink_state ⇒ Object (readonly)
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 |
#bg ⇒ Object
171 |
# File 'lib/rubyterm/app.rb', line 171 def bg = @term.bg |
#blink ⇒ Object
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_h ⇒ Object
16 |
# File 'lib/rubyterm/app.rb', line 16 def char_h = @adapter.char_h |
#char_w ⇒ Object
15 |
# File 'lib/rubyterm/app.rb', line 15 def char_w = @adapter.char_w |
#clear_selection_if_set ⇒ Object
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_redraw ⇒ Object
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_thread ⇒ Object
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_scrollback ⇒ Object
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 |
#fg ⇒ Object
170 |
# File 'lib/rubyterm/app.rb', line 170 def fg = @term.fg |
#get_selection ⇒ Object
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_x ⇒ Object
30 |
# File 'lib/rubyterm/app.rb', line 30 def get_x = @term.x |
#get_y ⇒ Object
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. = = pkt.detail > 0 ? pkt.detail : @term. 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 && >= 4 event = [0,1,2,64,65][-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 |
#initconfig ⇒ Object
20 21 22 23 24 25 26 |
# File 'lib/rubyterm/app.rb', line 20 def initconfig cname = File.("~/.config/rterm/config.toml") if File.exist?(cname) @config = TomlRB.load_file(cname, symbolize_keys: true) end @config ||= {} end |
#inspect ⇒ Object
28 |
# File 'lib/rubyterm/app.rb', line 28 def inspect = "<RubyTerm #{self.object_id}>" |
#jump_redraw ⇒ Object
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_queue ⇒ Object
182 |
# File 'lib/rubyterm/app.rb', line 182 def process_queue = process_chunk(@queue.shift) |
#reapply_selection ⇒ Object
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 |
#redraw ⇒ Object
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_selection ⇒ Object
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_buffer ⇒ Object
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_height ⇒ Object
18 |
# File 'lib/rubyterm/app.rb', line 18 def term_height = @term.height |
#term_width ⇒ Object
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 |