Module: Unmagic::Browser::Console::Graphics

Defined in:
lib/unmagic/browser/console/graphics.rb

Overview

Drawing an image in the terminal, over the kitty graphics protocol.

The terminal is handed the PNG bytes themselves, base64'd, wrapped in an APC escape sequence. That's the transmission mode every implementing terminal supports — the alternative, pointing at a file on disk, is faster but only works when the terminal is on the same machine and allowed to read it.

Constant Summary collapse

CHUNK =

The protocol caps a single escape sequence's payload, so anything bigger goes out as a run of chunks flagged m=1 until the last.

4096
MARGIN =

Leave a little room so an image drawn at the full width doesn't wrap.

2
DEFAULT_SCALE =

How much of the terminal's width a drawn image may take.

A screenshot in a console is for glancing at — "did the page load, did the button move" — and a 1280px capture drawn edge to edge pushes the command that produced it off the screen. Half leaves the conversation readable, and the full-size file on disk is there when you want to look properly.

0.5
ID_BASE =

Where this process starts numbering its images.

Every transmission needs an id the terminal hasn't seen, or it may treat the new bytes as a redraw of an image it already has and leave the old one on screen — a screenshot that never seems to change. Untagged transmissions all land on the same implicit id, which is exactly that trap, so ids are seeded from the pid to keep two consoles sharing a terminal out of each other's way.

(Process.pid % 10_000) * 100_000

Class Method Summary collapse

Class Method Details

.draw(image) ⇒ void

This method returns an undefined value.

Parameters:

  • image (Image)

    the PNG bytes to draw



91
92
93
94
95
96
97
98
99
100
101
102
103
104
# File 'lib/unmagic/browser/console/graphics.rb', line 91

def draw(image)
  payload = Base64.strict_encode64(image).scan(/.{1,#{CHUNK}}/)

  payload.each_with_index do |chunk, index|
    more = index == payload.size - 1 ? 0 : 1
    # Only the first chunk carries the full control data; the rest just
    # say whether more is coming.
    control = index.zero? ? "#{settings(image)},m=#{more}" : "m=#{more}"
    $stdout.write("\e_G#{control};#{chunk}\e\\")
  end

  $stdout.write("\n")
  $stdout.flush
end

.next_idInteger

Returns an id no image drawn from this process has used.

Returns:

  • (Integer)

    an id no image drawn from this process has used



48
49
50
51
# File 'lib/unmagic/browser/console/graphics.rb', line 48

def next_id
  @drawn = (@drawn || 0) + 1
  ID_BASE + @drawn
end

.save(image) ⇒ String

Returns the path written to.

Parameters:

  • image (Tagged)

    the bytes to write

Returns:

  • (String)

    the path written to



81
82
83
84
85
86
87
# File 'lib/unmagic/browser/console/graphics.rb', line 81

def save(image)
  directory = File.join(Dir.tmpdir, "unmagic-browser")
  FileUtils.mkdir_p(directory)
  path = File.join(directory, "#{image.name}-#{timestamp}.#{image.extension}")
  File.binwrite(path, image)
  path
end

.scaleFloat

Returns the fraction of the terminal's width an image may take.

Returns:

  • (Float)

    the fraction of the terminal's width an image may take



54
55
56
# File 'lib/unmagic/browser/console/graphics.rb', line 54

def scale
  @scale ||= DEFAULT_SCALE
end

.scale=(fraction) ⇒ void

This method returns an undefined value.

Draw images larger or smaller — Graphics.scale = 1.0 for full width.

Parameters:

  • fraction (Float)

    a fraction of the terminal's width



62
63
64
# File 'lib/unmagic/browser/console/graphics.rb', line 62

def scale=(fraction)
  @scale = fraction
end

.scaled_columns(image) ⇒ Integer?

The width to draw at: #scale of the terminal, but never wider than the image really is. Scaling up would turn a screenshot of one button into a blurry banner — the cap is a ceiling, not a target.

Parameters:

Returns:

  • (Integer, nil)

    the width to scale to, or nil to draw at native size



129
130
131
132
133
134
135
136
# File 'lib/unmagic/browser/console/graphics.rb', line 129

def scaled_columns(image)
  width, = image.dimensions
  return nil unless width

  ceiling = [((Terminal.columns - MARGIN) * scale).round, 1].max
  natural = (width.to_f / Terminal.cell.first).ceil
  natural > ceiling ? ceiling : nil
end

.settings(image) ⇒ String

a=T transmits and displays in one go; f=100 says the payload is a PNG, so the terminal does the decoding; i= gives this image an id of its own; and q=2 tells the terminal to keep its acknowledgement to itself — it would otherwise write OK back down the tty, where the console is reading keystrokes and would take it for typing.

Parameters:

Returns:

  • (String)

    the protocol's control data



114
115
116
117
118
119
120
121
# File 'lib/unmagic/browser/console/graphics.rb', line 114

def settings(image)
  control = "a=T,f=100,i=#{next_id},q=2"
  columns = scaled_columns(image)
  # Given a width and no height, the terminal keeps the aspect ratio.
  # Left off entirely, the image draws at its true pixel size.
  control += ",c=#{columns}" if columns
  control
end

.show(image) ⇒ String

Write the image somewhere durable and draw it if the terminal can.

It's written either way: half the point of a screenshot in a console is being able to open it in something that isn't a terminal.

Parameters:

  • image (Image)

    the PNG bytes

Returns:

  • (String)

    the path it was written to



73
74
75
76
77
# File 'lib/unmagic/browser/console/graphics.rb', line 73

def show(image)
  path = save(image)
  draw(image) if Terminal.graphics?
  path
end

.timestampString

Returns a sortable, collision-resistant filename fragment.

Returns:

  • (String)

    a sortable, collision-resistant filename fragment



139
140
141
# File 'lib/unmagic/browser/console/graphics.rb', line 139

def timestamp
  Time.now.strftime("%Y%m%d-%H%M%S-%L")
end