Taking Rich Screenshots with Wezterm¶
Taking the screenshot¶
I’ll admit it, this whole idea started out as a solution in search of a problem!
I’ve been using WezTerm as my terminal emulator for a while now, but I wasn’t taking advantage of the fact that it is configured using Lua and so I was looking for an excuse to try something non-trivial.
Before long I found it - pane:get_lines_as_escapes()
.
The above function returns the current contents of the terminal pane as a string, including all the escape codes that control the formatting! By mashing together a couple of examples from the documentation I soon had a custom command I could invoke through the command palette which would dump the contents of the active pane to a text file.
wezterm.on('augment-command-palette', function(window, pane)
return {
{
brief = 'Take screenshot',
icon = 'cod_device_camera',
action = act.PromptInputLine {
description = 'Screenshot name',
action = wezterm.action_callback(function(window, pane, line)
if line then
local text = pane:get_lines_as_escapes()
local f = io.open(line .. '.txt', 'w+')
f:write(text)
f:flush()
f:close()
end
end),
},
},
}
end)
After running the command and giving it a filename I was left with a plain-ish text file that looked something like the following
\x1b[38:2::255:255:255m\x1b[48:2::49:49:49m|\x1b(B\x1b[0;1m\x1b[38:2::255:255:255m\x1b[40m*gud-test* x\x1b(B\x1b[0m\x1b['z...
Rendering the Screenshot¶
Now that we have the raw data, there are lots of ways we could approach rendering it, for example you could dump it into xterm.js.
However, while I have nothing against JavaScript it’s always nice not to rely on it unnecessarily. Since this is a static screenshot anyway it makes sense to render it offline and embed the result in the page.
Using the rich Python library, it only takes a few lines of code to convert the screenshot from plain-ish text to an SVG.
import io
import pathlib
from rich.console import Console
from rich.text import Text
content = pathlib.Path('<filepath>').read_text()
text = Text.from_ansi(content)
console = Console(
file=io.StringIO(),
force_terminal=True,
record=True,
width=None,
height=None,
)
console.print(text, end="")
svg = console.export_svg(title="Example screenshot")
There is one small issue… passing None
for the width
and height
of the Console
object means that it will use the dimensions of the current terminal executing the code rather than the dimensions of the terminal the screenshot was captured from!
Width and Height¶
In order for the captured data to be rendered correctly, rich’s Console
object needs to use the same width
and height
as the original terminal.
This means that when we take the screenshot, we also need to capture the dimensions of the terminal pane. Easy enough with WezTerm’s Lua API, but where to store the values?
Rather than try and invent a file format, why not reuse the asciicast format from asciinema? After all a screenshot is just a single frame screencast!
We don’t need to worry too much about the details of the format, the following snippet should be enough for this use case.
{"version": 3, "term": {"cols": "<width>", "rows": "<height>"}}
[0, "o", "<captured_output>"]
Which we can modify our custom WezTerm command to produce.
wezterm.on('augment-command-palette', function(window, pane)
return {
{
brief = 'Take screenshot',
icon = 'cod_device_camera',
action = act.PromptInputLine {
description = 'Screenshot name',
action = wezterm.action_callback(function(window, pane, line)
if line then
local dimensions = pane:get_dimensions()
local body = {0, "o", pane:get_lines_as_escapes()}
local header = {
version = 3,
term = {
cols = dimensions.cols,
rows = dimensions.viewport_rows,
},
}
local f = io.open(line .. '.cast', 'w+')
f:write(wezterm.json_encode(header) .. '\n')
f:write(wezterm.json_encode(body))
f:flush()
f:close()
end
end),
},
},
}
end)
And update our Python code accordingly
fpath = pathlib.Path('<filepath>')
with fpath.open() as f:
header = json.loads(f.readline())
body = json.loads(f.readline())
text = Text.from_ansi(body[-1])
console = Console(
file=io.StringIO(),
force_terminal=True,
record=True,
width=header["term"]["cols"],
height=header["term"]["rows"],
)
console.print(text, end="")
svg = console.export_svg(title=self.options.get("title", ""))
At this point you’d think it would be job done, but there is one more detail to consider…
My terminal colours are not being preserved!
SGR Codes¶
I’m not the right person to try and explain how terminal rendering works. I only know enough to say that the story is long and built up using many layers of escape codes…
The codes that matter for this tale are something called SGR codes and they control various formatting properties including colour. Most documentation I’ve found will say that to set the text colour to… red for example, you need to use the following escape sequence:
\x1b[38;2;255;0;0m
However, there are some places that specify an alternative representation:
\x1b[38:2::255:0:0m
It turns out that WezTerm uses this second style, which rich
does not recognise, resulting in the colourless image you saw above.
To fix this, I tweaked the way rich parsed escape codes to allow for both styles, resulting in the image you see below.
Which is better, but it still isn’t an accurate representation of what I see in my terminal.
Colour Palette¶
Aside from providing specific RGB values, SGR codes also allow for applications to choose from a palette of 16 colours - 8 “normal” and 8 “bright” colours.
The specific colours in this palette are of course dependent on whatever theme you have configured in your terminal emulator, so the final step is to ensure that rich
uses the same colour palette as my terminal.
Thankfully the asciicast format supports recording this information alongside the width and height, all we need to do is extend the screenshot command in WezTerm to populate it.
local dimensions = pane:get_dimensions()
local theme = wezterm.get_builtin_color_schemes()[window:effective_config().color_scheme]
local body = {0, "o", pane:get_lines_as_escapes()}
local header = {
version = 3,
term = {
cols = dimensions.cols,
rows = dimensions.viewport_rows,
theme = {
fg = theme.foreground,
bg = theme.background,
palette = table.concat(append_tables(theme.ansi, theme.brights), ':'),
},
},
}
Expand to see the complete command implementation
wezterm/commands/screenshot.lua
local wezterm = require 'wezterm'
local io = require 'io'
local os = require 'os'
local act = wezterm.action
local M = {}
local function append_tables(...)
local result = {}
for _, tbl in ipairs({...}) do
for i = 1, #tbl do
result[#result + 1] = tbl[i]
end
end
return result
end
function M.setup()
wezterm.on('augment-command-palette', function(window, pane)
return {
{
brief = 'Take screenshot',
icon = 'cod_device_camera',
action = act.PromptInputLine {
description = 'Screenshot name',
-- initial_value = os.date('%Y%m%dT%H%M%S--terminal-screenshot'), -- apparently this requires a nightly build
action = wezterm.action_callback(function(window, pane, line)
if line then
local dimensions = pane:get_dimensions()
local theme = wezterm.get_builtin_color_schemes()[window:effective_config().color_scheme]
local body = {0, "o", pane:get_lines_as_escapes()}
local header = {
version = 3,
term = {
cols = dimensions.cols,
rows = dimensions.viewport_rows,
theme = {
fg = theme.foreground,
bg = theme.background,
palette = table.concat(append_tables(theme.ansi, theme.brights), ':'),
},
},
}
local f = io.open(line .. '.cast', 'w+')
f:write(wezterm.json_encode(header) .. '\n')
f:write(wezterm.json_encode(body))
f:flush()
f:close()
end
end),
},
},
}
end)
end
return M
Which of course requires a similar update to the rendering code
term = header["term"]
ansi_colors = term["theme"]["palette"].split(":")
theme = TerminalTheme(
background=Color.parse(term["theme"]["bg"]).triplet,
foreground=Color.parse(term["theme"]["fg"]).triplet,
normal=[Color.parse(col).triplet for col in ansi_colors[:8]],
bright=[Color.parse(col).triplet for col in ansi_colors[8:]],
)
...
svg = console.export_svg(
title=self.options.get("title", ""),
theme=theme,
)
Which, finally, results in the SVG you saw at the start of this blog post!
Sphinx Integration¶
When working on a post for this site, I want to be able to just include the *.cast
file produced by WezTerm as I would with any other image
.. termshot:: /images/example.cast
:title: Example
and have Sphinx perform the conversion to SVG automatically during the build. This can be done by creating a custom directive.
The following class defintion defines the interface for the directive, we want it accept a single argument (/images/example.cast
)
and have the option to specify a :title:
.
class Termshot(SphinxDirective):
required_arguments = 1
optional_arguments = 0
final_argument_whitespace = True
option_spec = {
"title": directives.unchanged,
}
Each directive has a run()
method, which should return a list of nodes which are inserted into the document at the location where the directive was used.
Other Sphinx directives like .. figure::
or .. include::
interpret absolute paths as paths relative to the project root.
I want the .. termshot::
directive to behave in the same way, so I pass the given filepath through the self.env.relfn2path
function which handles that for me
def run(self):
_, fpath = self.env.relfn2path(self.arguments[0])
Once the svg has been generated, it needs to be included in the final HTML somehow, there are many way to approach this but for simplicity I’ve opted to inline the SVG code using a raw
docutils node.
svg = console.export_svg(
title=self.options.get("title", ""),
theme=theme,
)
node = nodes.raw("", svg, format="html")
return [node]
Expand to see the complete implementation
from __future__ import annotations
import io
import json
import pathlib
import typing
from os.path import abspath, relpath
from docutils import nodes
from docutils.parsers.rst import directives
from rich.color import Color
from rich.console import Console
from rich.terminal_theme import TerminalTheme
from rich.text import Text
from sphinx.util.docutils import SphinxDirective
if typing.TYPE_CHECKING:
from sphinx.application import Sphinx
class Termshot(SphinxDirective):
required_arguments = 1
optional_arguments = 0
final_argument_whitespace = True
option_spec = {
"title": directives.unchanged,
}
def run(self):
_, fpath = self.env.relfn2path(self.arguments[0])
with pathlib.Path(fpath).open() as f:
header = json.loads(f.readline())
body = json.loads(f.readline())
term = header["term"]
ansi_colors = term["theme"]["palette"].split(":")
text = Text.from_ansi(body[-1])
theme = TerminalTheme(
background=Color.parse(term["theme"]["bg"]).triplet,
foreground=Color.parse(term["theme"]["fg"]).triplet,
normal=[Color.parse(col).triplet for col in ansi_colors[:8]],
bright=[Color.parse(col).triplet for col in ansi_colors[8:]],
)
console = Console(
file=io.StringIO(),
force_terminal=True,
record=True,
width=term["cols"],
height=term["rows"],
)
console.print(text, end="")
svg = console.export_svg(
title=self.options.get("title", ""),
theme=theme,
)
node = nodes.raw("", svg, format="html")
return [node]
def setup(app: Sphinx):
app.add_directive("termshot", Termshot)
return {"version": "1.0", "parallel_read_safe": True}