Class: Everywhere::Dock::Screen

Inherits:
Object
  • Object
show all
Defined in:
lib/everywhere/dock/screen.rb

Overview

Emits the escape sequences that keep the footer pinned to the bottom.

THE MECHANIC, and why it isn't the obvious one:

The tempting approach is DECSTBM — set a scroll region (ESC[1;Nr) that excludes the last few rows and let the terminal itself keep output off them. It works, and it even confines child processes writing straight to the fd. It was rejected for two reasons:

1. It destroys scrollback. tmux only copies scrolled-out rows into its
 history when the scroll region is the full screen
 (grid_view_scroll_region_up checks rupper == 0 && rlower == sy - 1);
 screen and, as far as we can tell, iTerm2 behave the same way. Losing
 the ability to scroll up and read a Rails backtrace is a far worse
 regression than the one the dock is fixing.
2. Its failure mode is terrible. If the CLI is SIGKILLed before it can
 emit ESC[r, the user's terminal keeps a dead band at the bottom until
 they run `reset`.

So instead we own every byte (the dev server and shells are relayed through us, not inherited) and repaint: erase the footer rows, write the new content — which scrolls normally, preserving scrollback everywhere — then draw the footer again. This is what indicatif, Ink, docker compose and cargo all do. Worst-case failure is one stale line on screen.

Everything is emitted as a SINGLE write per frame. Byte interleaving between processes happens between write(2) calls, not inside one.

Constant Summary collapse

ERASE_ROW =

clear this row, cursor back to column 1

"\r\e[2K"
UP_ROW =

up one row and clear it

"\e[1A\e[2K"
HIDE =
"\e[?25l"
SHOW =
"\e[?25h"
DEFAULT_SIZE =
[24, 80].freeze

Instance Method Summary collapse

Constructor Details

#initialize(io:, size: nil) ⇒ Screen

Returns a new instance of Screen.



40
41
42
43
44
45
# File 'lib/everywhere/dock/screen.rb', line 40

def initialize(io:, size: nil)
  @io = io
  @size = size
  @drawn = 0
  @open = false
end

Instance Method Details

#clearObject

Wipe the footer without redrawing it — for anything that has to write outside our frame discipline (stderr, an interactive child).



71
72
73
74
75
76
# File 'lib/everywhere/dock/screen.rb', line 71

def clear
  return unless @open && @drawn.positive?

  @io.write(erase)
  @drawn = 0
end

#closeObject



78
79
80
81
82
83
84
85
# File 'lib/everywhere/dock/screen.rb', line 78

def close
  return unless @open

  @io.write("#{erase}#{SHOW}")
  @drawn = 0
  @open = false
  @io.sync = @sync
end

#colsObject



101
# File 'lib/everywhere/dock/screen.rb', line 101

def cols = size[1]

#frame(content, footer) ⇒ Object

content: text to land above the footer (must end in a newline, or be empty). footer: the rows to pin.



58
59
60
61
62
63
64
65
66
67
# File 'lib/everywhere/dock/screen.rb', line 58

def frame(content, footer)
  return unless @open

  out = +""
  out << erase
  out << content
  out << footer.join("\n")
  @drawn = footer.length
  @io.write(out)
end

#openObject



47
48
49
50
51
52
53
54
# File 'lib/everywhere/dock/screen.rb', line 47

def open
  return if @open

  @open = true
  @sync = @io.sync
  @io.sync = true
  @io.write(HIDE)
end

#rowsObject



100
# File 'lib/everywhere/dock/screen.rb', line 100

def rows = size[0]

#sizeObject

Rows and columns, re-read on every resize. IO#winsize is a plain TIOCGWINSZ ioctl — it does not touch termios, so it is safe alongside the stty -icanon -echo the key loop sets.



90
91
92
93
94
95
96
97
98
# File 'lib/everywhere/dock/screen.rb', line 90

def size
  return @size if @size

  require "io/console"
  @io.winsize
rescue StandardError
  env = [ENV["LINES"].to_i, ENV["COLUMNS"].to_i]
  env.all?(&:positive?) ? env : DEFAULT_SIZE
end