Drafter.Compositor (drafter v0.3.1)

Copy Markdown View Source

Holds one session's screen as rows of styled cells and writes changed rows to its terminal.

The screen buffer is a list of Drafter.Draw.Strip, one per row, each padded to the screen width. render_strips/3 blits strips into it at a cell position; the frame is written on the next :render_frame message, and only rows whose cache key changed are sent. Frames are wrapped in synchronized-update markers unless DRAFTER_NO_SYNC is set.

Drafter.Compositor.render_strips([Drafter.Draw.Strip.from_text("hello")], 2, 0)

One compositor exists per session and is resolved through Drafter.Session.Context under the :compositor key, so the module-level functions address the caller's own session. Output goes to the session's terminal driver, or, for the local terminal, straight to /dev/tty unless DRAFTER_NO_PACED_WRITE is set.

A resize arrives as {:tui_event, {:resize, {cols, rows}}} from the event manager; the buffer is rebuilt empty at the new size and the whole screen is marked dirty.

Images

Terminal-graphics bytes live outside the cell grid. A widget registers them with put_image/4, positions them with place_image/3 and withdraws them with clear_image/1. Images are drawn after the text of a frame, and are redrawn when their bytes or position changed or when a text row beneath them was rewritten. An image whose rectangle does not fit entirely on screen is not drawn at all.

Stamps order concurrent generations: a put_image/4 whose stamp is not greater than the highest stamp already accepted for that id is discarded. clear_image/1 forgets the id's stamp, so the next put_image/4 for it is accepted whatever its stamp.

Summary

Types

A rectangle of cells recorded as changed since the last frame.

Where an image sits and how big it is, as a widget's image/3 returns it.

The screen as one padded Drafter.Draw.Strip per row, top row first.

Functions

Returns a specification to start this module under a supervisor.

Hide the image region id and re-blank the cells it occupied.

Blank the whole screen buffer and withdraw every image region.

The composited screen buffer of the compositor at pid, one Strip per row.

The size of the screen buffer as {cols, rows}.

Position the image region id with its anchor at cell x, y and mark it visible.

Store the terminal-graphics bytes (kitty, iTerm2, sixel) for an image region.

Redraw every row on the next frame, keeping the buffer contents.

Blit strips into the screen buffer with their top-left corner at cell x, y.

Set the screen buffer to width by height cells.

Start a compositor.

Write bytes straight to this session's terminal, outside the screen buffer.

Types

dirty_region()

@type dirty_region() :: %{
  x: integer(),
  y: integer(),
  width: integer(),
  height: integer()
}

A rectangle of cells recorded as changed since the last frame.

Whether any region is recorded is what decides that a frame is due; which rows are actually written is decided by comparing strip cache keys. width and height are therefore not clipped to the screen and can be zero or negative for a rectangle that starts past an edge.

image_region()

@type image_region() :: %{
  :dx => integer(),
  :dy => integer(),
  :cols => non_neg_integer(),
  :rows => non_neg_integer(),
  optional(:stamp) => integer(),
  optional(:place) => iodata() | nil
}

Where an image sits and how big it is, as a widget's image/3 returns it.

:stamp and :place are optional, defaulting to 0 and nil.

screen_buffer()

@type screen_buffer() :: [Drafter.Draw.Strip.t()]

The screen as one padded Drafter.Draw.Strip per row, top row first.

Functions

child_spec(init_arg)

Returns a specification to start this module under a supervisor.

See Supervisor.

clear_image(id)

@spec clear_image(term()) :: :ok

Hide the image region id and re-blank the cells it occupied.

Queues the region's clear sequence and marks the rows it covered dirty, so the text beneath is written again. Its bytes and position are kept, so a later place_image/3 shows it again.

The id's stamp is forgotten either way, so the next put_image/4 for it is accepted whatever its stamp. A region that is already hidden or has no bytes yet is only marked hidden, and an unknown id is ignored; neither schedules a frame. Asynchronous.

clear_screen()

@spec clear_screen() :: :ok

Blank the whole screen buffer and withdraw every image region.

Each image's clear sequence is queued so the terminal releases it. The regions themselves are forgotten, bytes, positions and stamps alike, so a later place_image/3 for the same id shows nothing until put_image/4 supplies bytes again. Asynchronous.

get_buffer(pid)

@spec get_buffer(pid()) :: screen_buffer()

The composited screen buffer of the compositor at pid, one Strip per row.

Takes an explicit pid rather than resolving the session, so a caller outside the session can read it. The rows returned are what the next frame will write from, which is not necessarily what is on the terminal yet. Synchronous.

get_screen_size()

@spec get_screen_size() :: {pos_integer(), pos_integer()}

The size of the screen buffer as {cols, rows}.

This is the size the buffer was built at, not a fresh measurement of the terminal. Synchronous.

place_image(id, x, y)

@spec place_image(term(), non_neg_integer(), non_neg_integer()) :: :ok

Position the image region id with its anchor at cell x, y and mark it visible.

Carries no image bytes. Calling it for an id that has no bytes yet records the position; nothing is drawn until put_image/4 supplies them. The image is drawn after the text of a frame, and only when its bytes or position changed or a text row under it was redrawn. Asynchronous.

put_image(id, paint, clear, region)

@spec put_image(term(), iodata(), iodata(), image_region()) :: :ok

Store the terminal-graphics bytes (kitty, iTerm2, sixel) for an image region.

Arguments:

  • id — any term identifying the region; a later call with the same id replaces its bytes, keeping the position place_image/3 gave it

  • paint — the sequence that draws the image

  • clear — the sequence that removes it, for protocols that hold an image outside the cell grid; "" for protocols where re-blanking the cells is enough

  • region — where and how big the image is, as the placement map a widget's image/3 returns:

    • :dx, :dy — cell offset of the image from the position given to place_image/3, so the image is drawn at x + dx, y + dy
    • :cols, :rows — size of the image in cells, used to decide whether it fits on screen and which text rows it covers
    • :stamp — generation counter for this id, default 0
    • :place — sequence that redraws the image the terminal is already holding, default nil

A call is discarded outright, changing nothing, when :stamp is less than or equal to the stamp of the last accepted call for the same id. The first call for an id is always accepted, as is the first call after a clear_image/1, which forgets the id's stamp. Callers that generate images concurrently must pass a counter that only ever increases for a given id; passing the default 0 every time means every call after the first is dropped.

place is sent instead of paint when the image is unchanged and is only being drawn again because text was written across it. nil sends paint in that case too.

Registering bytes does not make the image visible; place_image/3 does. Asynchronous.

refresh()

@spec refresh() :: :ok

Redraw every row on the next frame, keeping the buffer contents.

Use after something outside the compositor has written to the terminal, which makes the record of what is on screen untrustworthy. Asynchronous.

render_strips(strips, x \\ 0, y \\ 0)

@spec render_strips([Drafter.Draw.Strip.t()], non_neg_integer(), non_neg_integer()) ::
  :ok

Blit strips into the screen buffer with their top-left corner at cell x, y.

One strip per row, applied downwards from y. x and y are zero-based and both default to 0. Rows that fall outside the screen are dropped. A row is padded with spaces out to the screen width, and an x of 0 or less replaces the whole row. Callers are responsible for supplying strips that fit: a strip reaching past the right edge leaves that row longer than the screen.

The affected rectangle is marked dirty and a frame is scheduled. Asynchronous.

Drafter.Compositor.render_strips([Drafter.Draw.Strip.from_text("hello")], 2, 0)

resize(width, height)

@spec resize(pos_integer(), pos_integer()) :: :ok

Set the screen buffer to width by height cells.

The buffer is rebuilt blank at the new size, every image region is withdrawn as by clear_screen/0 and the whole screen is redrawn on the next frame. Returns before any of that has happened.

This is the same path a {:resize, {cols, rows}} event from the event manager takes, so calling it does not stop the terminal's own size from winning later.

start_link(opts \\ [])

@spec start_link(keyword()) :: GenServer.on_start()

Start a compositor.

Options:

  • :name — registered name, default Drafter.Compositor. Pass nil to start it unregistered, which is what a session that is not the local terminal does.
  • :terminal_driverDrafter.Terminal.Driver (the default) or a {module, pid} pair whose module exports write/2 and get_size/1.
  • :event_manager — manager to subscribe to for resize events, default Drafter.Event.Manager.

The initial screen size is read from the terminal driver. When the driver is Drafter.Terminal.Driver and DRAFTER_NO_PACED_WRITE is unset, /dev/tty is opened once here and every frame is written to it instead of through the driver; it is closed on termination.

write_raw(data)

@spec write_raw(iodata()) :: :ok

Write bytes straight to this session's terminal, outside the screen buffer.

For control sequences addressed to the terminal itself rather than to the screen, such as an OSC 52 clipboard write. The bytes go to the terminal this session is attached to, which for a remote session is the ssh or telnet client rather than the tty the server was started from. Nothing is written to the cell grid and no row is marked dirty.