Class: RubyTUI::AnsiBackend

Inherits:
Object
  • Object
show all
Includes:
Backend
Defined in:
lib/rubytui/backends/ansi_backend.rb

Overview

Backend that drives a real terminal with ANSI escape sequences.

Output accumulates in an internal string buffer and is written to the output IO only by #flush. #enter_alternate_screen, #leave_alternate_screen, #enable_mouse, #disable_mouse and #clear_all_images! flush immediately.

See Also:

Constant Summary collapse

CSI =

Control Sequence Introducer (ESC [) that prefixes escape sequences.

"\e["

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(output: $stdout, input: $stdin) ⇒ AnsiBackend

Returns a new instance of AnsiBackend.

Parameters:

  • output (IO) (defaults to: $stdout) —

    stream escape sequences are written to (default: $stdout)

  • input (IO) (defaults to: $stdin) —

    terminal input used for raw mode and cell-size queries (default: $stdin)



23
24
25
26
27
28
29
30
31
32
# File 'lib/rubytui/backends/ansi_backend.rb', line 23

def initialize(output: $stdout, input: $stdin)
  @output = output
  @input = input
  @buffer = String.new(capacity: 4096)
  @prev_style = nil
  @original_state = nil
  @next_image_id = 1
  @image_id_map = {}
  @cell_pixel_size = nil
end

Instance Attribute Details

#cell_pixel_size ⇒ Array(Integer, Integer)? (readonly)

Returns cached cell dimensions [width_px, height_px], or nil until #query_cell_pixel_size has run.

Returns:

  • (Array(Integer, Integer), nil) —

    cached cell dimensions [width_px, height_px], or nil until #query_cell_pixel_size has run



240
241
242
# File 'lib/rubytui/backends/ansi_backend.rb', line 240

def cell_pixel_size
  @cell_pixel_size
end

Instance Method Details

#clear ⇒ void

This method returns an undefined value.

Append the clear-screen sequence (CSI 2J) to the output buffer.



103
104
105
# File 'lib/rubytui/backends/ansi_backend.rb', line 103

def clear
  @buffer << "#{CSI}2J"
end

#clear_all_images! ⇒ void

This method returns an undefined value.

Delete all Kitty graphics protocol images.

Nuclear option for clearing all image overlays. Useful during transitions or when precise tracking isn't possible. Forgets all tracked image IDs and flushes immediately.



316
317
318
319
320
# File 'lib/rubytui/backends/ansi_backend.rb', line 316

def clear_all_images!
  @buffer << "\e_Ga=d,d=A\e\\"
  @image_id_map.clear
  flush
end

#delete_kitty_image(placement) ⇒ void

This method returns an undefined value.

Delete a Kitty graphics protocol image by ID.

Sends a delete command targeting the specific image ID, ensuring precise removal without affecting other images. Appends to the output buffer; does not flush.

Parameters:

  • placement (Hash{Symbol => Object}) —

    the image placement to delete



296
297
298
299
300
301
302
303
304
305
306
307
# File 'lib/rubytui/backends/ansi_backend.rb', line 296

def delete_kitty_image(placement)
  placement_key = [placement[:x], placement[:y], placement[:content_hash]]
  image_id = @image_id_map.delete(placement_key)

  if image_id
    # Delete by image ID — precise and reliable
    @buffer << "\e_Ga=d,d=i,i=#{image_id}\e\\"
  else
    # Fallback: delete images whose cells were overwritten
    @buffer << "\e_Ga=d,d=C\e\\"
  end
end

#disable_mouse ⇒ void

This method returns an undefined value.

Disable mouse event reporting

Turns off modes 1006, 1002 and 1000, then flushes.



140
141
142
143
144
145
# File 'lib/rubytui/backends/ansi_backend.rb', line 140

def disable_mouse
  @buffer << "#{CSI}?1006l"
  @buffer << "#{CSI}?1002l"
  @buffer << "#{CSI}?1000l"
  flush
end

#disable_raw_mode ⇒ void

This method returns an undefined value.

Restore the input's terminal state saved by #enable_raw_mode.

Runs stty with the saved state on the input. Falls back to cooked! (when the input supports it) if no state was saved or stty raises.



174
175
176
177
178
179
180
181
182
183
184
185
# File 'lib/rubytui/backends/ansi_backend.rb', line 174

def disable_raw_mode
  if @original_state
    begin
      system("stty", @original_state, in: @input, err: File::NULL)
    rescue StandardError
      @input.cooked! if @input.respond_to?(:cooked!)
    end
    @original_state = nil
  elsif @input.respond_to?(:cooked!)
    @input.cooked!
  end
end

#draw(changes) ⇒ void

This method returns an undefined value.

Append escape sequences for the changed cells to the output buffer.

Emits a cursor move only where a cell does not directly follow the previous one, skips wide-character spacer cells (empty symbol), and appends an SGR reset at the end. Does nothing if changes is empty. Does not flush.

Parameters:

  • changes (Array<Array(Integer, Integer, Cell)>) —

    changed cells as [x, y, cell]



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
# File 'lib/rubytui/backends/ansi_backend.rb', line 43

def draw(changes)
  return if changes.empty?

  last_x = -1
  last_y = -1

  changes.each do |x, y, cell|
    # Skip spacer cells from wide characters
    if cell.symbol.empty?
      next
    end

    # Move cursor if not at expected position
    unless x == last_x + 1 && y == last_y
      @buffer << "#{CSI}#{y + 1};#{x + 1}H"
    end

    # Apply style
    apply_style(cell.style)

    # Write character
    @buffer << cell.symbol

    # Wide characters advance terminal cursor by 2
    w = cell.symbol.length == 1 ? Buffer.char_width(cell.symbol) : 1
    last_x = x + w - 1
    last_y = y
  end

  # Reset style at end
  @buffer << "#{CSI}0m"
  @prev_style = nil
end

#draw_kitty_image(placement) ⇒ void

This method returns an undefined value.

Emit a Kitty graphics protocol image at the given placement.

Transmits the image data as chunked base64 in APC escape sequences. The terminal decodes and renders the image at the cursor position. Each image gets a unique ID for precise deletion. Appends to the output buffer; does not flush.

Parameters:



251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
# File 'lib/rubytui/backends/ansi_backend.rb', line 251

def draw_kitty_image(placement)
  # Generate a unique image ID for this placement
  placement_key = [placement[:x], placement[:y], placement[:content_hash]]
  image_id = @image_id_map[placement_key] ||= (@next_image_id += 1)

  # Position cursor at image origin
  @buffer << "#{CSI}#{placement[:y] + 1};#{placement[:x] + 1}H"

  encoded = [placement[:data]].pack("m0") # strict base64, no newlines
  format_code = placement[:format] == :png ? 100 : 32

  # Chunk into 4096-byte pieces (safe for all Kitty-compatible terminals)
  chunks = encoded.scan(/.{1,4096}/m)

  chunks.each_with_index do |chunk, i|
    more = i < chunks.length - 1 ? 1 : 0

    if i == 0
      # C=1: do not move the cursor after displaying — keeps the
      # terminal's cursor position in sync with the backend's model
      params = +"a=T,f=#{format_code},i=#{image_id},C=1"
      params << ",s=#{placement[:pixel_width]},v=#{placement[:pixel_height]}" if format_code == 32

      # Source rectangle for cropping/scrolling (pixel coordinates within image)
      if placement[:src_x] && placement[:src_y]
        params << ",x=#{placement[:src_x]},y=#{placement[:src_y]}"
        params << ",w=#{placement[:src_w]},h=#{placement[:src_h]}" if placement[:src_w] && placement[:src_h]
      end

      params << ",c=#{placement[:cols]},r=#{placement[:rows]},m=#{more}"
      @buffer << "\e_G#{params};#{chunk}\e\\"
    else
      @buffer << "\e_Gm=#{more};#{chunk}\e\\"
    end
  end
end

#enable_mouse ⇒ void

This method returns an undefined value.

Enable SGR extended mouse mode (button events + drag + scroll)

Turns on modes 1000, 1002 and 1006, then flushes.



128
129
130
131
132
133
# File 'lib/rubytui/backends/ansi_backend.rb', line 128

def enable_mouse
  @buffer << "#{CSI}?1000h"  # Enable button event tracking
  @buffer << "#{CSI}?1002h"  # Enable button-event tracking with drag
  @buffer << "#{CSI}?1006h"  # Enable SGR extended mode (for coords > 223)
  flush
end

#enable_raw_mode ⇒ void

This method returns an undefined value.

Put the input into raw mode, saving its terminal state for #disable_raw_mode.

Save the termios state of the SAME fd we are about to raw! — a backtick stty -g reads the process's stdin, which is the wrong fd (and often not a tty at all) when a custom input: IO such as File.open("/dev/tty") is used, e.g. a TUI launched from fzf execute() with output captured. A failed stty yields "" which is truthy, so it must collapse to nil or restore would run stty "" and leave the terminal stuck in raw mode.



157
158
159
160
161
162
163
164
165
# File 'lib/rubytui/backends/ansi_backend.rb', line 157

def enable_raw_mode
  @original_state = begin
    state = IO.popen(["stty", "-g"], in: @input, err: File::NULL, &:read).chomp
    state.empty? ? nil : state
  rescue StandardError
    nil
  end
  @input.raw! if @input.respond_to?(:raw!)
end

#enter_alternate_screen ⇒ void

This method returns an undefined value.

Switch to the alternate screen buffer (CSI ?1049h) and flush.



110
111
112
113
# File 'lib/rubytui/backends/ansi_backend.rb', line 110

def enter_alternate_screen
  @buffer << "#{CSI}?1049h"
  flush
end

#flush ⇒ void

This method returns an undefined value.

Write the buffered output to the output IO and flush it.

Does nothing if the buffer is empty.



327
328
329
330
331
332
333
# File 'lib/rubytui/backends/ansi_backend.rb', line 327

def flush
  return if @buffer.empty?

  @output.write(@buffer)
  @output.flush
  @buffer.clear
end

#hide_cursor ⇒ void

This method returns an undefined value.

Append the hide-cursor sequence (CSI ?25l) to the output buffer.



80
81
82
# File 'lib/rubytui/backends/ansi_backend.rb', line 80

def hide_cursor
  @buffer << "#{CSI}?25l"
end

#leave_alternate_screen ⇒ void

This method returns an undefined value.

Switch back from the alternate screen buffer (CSI ?1049l) and flush.



118
119
120
121
# File 'lib/rubytui/backends/ansi_backend.rb', line 118

def leave_alternate_screen
  @buffer << "#{CSI}?1049l"
  flush
end

#move_cursor(x, y) ⇒ void

This method returns an undefined value.

Append a cursor-position sequence to the output buffer.

Parameters:

  • x (Integer) —

    zero-based column

  • y (Integer) —

    zero-based row



96
97
98
# File 'lib/rubytui/backends/ansi_backend.rb', line 96

def move_cursor(x, y)
  @buffer << "#{CSI}#{y + 1};#{x + 1}H"
end

#query_cell_pixel_size ⇒ Array(Integer, Integer)

Query the terminal for cell pixel dimensions.

Sends CSI 16 t and parses the response. Must be called before entering raw mode or while in raw mode with careful stdin handling. Falls back to [9, 18] when the terminal does not answer within 100 ms or the query raises IOError, Errno::EIO or Errno::ENOTTY; the result, including the fallback, is cached.

Returns:

  • (Array(Integer, Integer)) —

    cell dimensions [width_px, height_px]



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
234
235
236
# File 'lib/rubytui/backends/ansi_backend.rb', line 207

def query_cell_pixel_size
  return @cell_pixel_size if @cell_pixel_size

  begin
    @input.raw do
      @output.write("\e[16t")
      @output.flush

      response = +""
      deadline = Time.now + 0.1

      while Time.now < deadline
        if IO.select([@input], nil, nil, 0.01)
          char = @input.read_nonblock(1, exception: false)
          break if char == :wait_readable || char.nil?
          response << char
          break if char == "t"
        end
      end

      if response =~ /\e\[6;(\d+);(\d+)t/
        @cell_pixel_size = [$2.to_i, $1.to_i]
      end
    end
  rescue IOError, Errno::EIO, Errno::ENOTTY
    # Terminal doesn't support query
  end

  @cell_pixel_size ||= [9, 18]
end

#show_cursor ⇒ void

This method returns an undefined value.

Append the show-cursor sequence (CSI ?25h) to the output buffer.



87
88
89
# File 'lib/rubytui/backends/ansi_backend.rb', line 87

def show_cursor
  @buffer << "#{CSI}?25h"
end

#size ⇒ Array(Integer, Integer)

Get the output terminal size.

Returns:

  • (Array(Integer, Integer)) —

    [width, height] in cells, or [80, 24] if the size cannot be read from the output



191
192
193
194
195
196
# File 'lib/rubytui/backends/ansi_backend.rb', line 191

def size
  rows, cols = @output.winsize
  [cols, rows]
rescue Errno::ENOTTY, Errno::ENODEV, NoMethodError
  [80, 24]
end