Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,16 @@ Changelog
In development
--------------

TODO
New:

* Add differential redraw for animated plots. `new - prev` (or, spelled out,
`new.updatestr(prev)`) returns an ANSI sequence that repaints only the cells
that changed since the previous frame, rather than clearing and redrawing the
whole plot. Backed by `CharArray.to_ansi_diff_str`; falls back to a full
clear-and-redraw when the plot size changes. Large byte savings for
mostly-static animations (~13x fewer bytes on a dashboard with a single
moving bar); modest savings for fully turbulent frames where most cells
change every step.

Version 0.3.7
-------------
Expand Down
93 changes: 92 additions & 1 deletion matthewplotlib/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -204,7 +204,98 @@ def to_ansi_str(self: Self) -> str:
if i < self.height - 1:
s.append("\n")
return "".join(s)



def to_ansi_diff_str(self: Self, prev: CharArray) -> str:
"""
Render the minimal ANSI sequence that updates a terminal already
showing `prev` so that it shows `self` instead, repainting only the
cells that differ.

This is the differential counterpart to `to_ansi_str`: where redrawing
a whole frame re-emits every cell, this jumps the cursor to just the
changed cells. For an animation whose frames are mostly stable, that is
dramatically fewer bytes down the wire.

Cursor contract (matching the animated-plot idiom):

* On entry, the cursor is assumed to be at column 0 on the line
immediately below where `prev` was rendered -- exactly where it sits
after `print`ing `prev`.
* On exit, the cursor is left at column 0 on the line immediately below
`self`, so the next frame can diff against this one in the same way.

Assumes `prev` was rendered starting at column 0 and that all glyphs are
single-width (the standard animated-plot layout). `self` and `prev` must
have the same shape. Returns "" when nothing changed.
"""
if (self.height, self.width) != (prev.height, prev.width):
raise ValueError(
"to_ansi_diff_str requires self and prev to have the same shape"
)
H = self.height

# which cells differ in glyph or colour?
fg_changed = (self.fg != prev.fg) | (
self.fg & np.any(self.fg_rgb != prev.fg_rgb, axis=-1)
)
bg_changed = (self.bg != prev.bg) | (
self.bg & np.any(self.bg_rgb != prev.bg_rgb, axis=-1)
)
changed = (self.codes != prev.codes) | fg_changed | bg_changed
if not changed.any():
return ""

s: list[str] = []
# cursor position, in plot coordinates (row H == just below the plot)
cur_row = H
cur_col = 0
# SGR colour state: persists across cursor moves, so we only emit a code
# when the colour actually changes (as in to_ansi_str, but the running
# state now carries across the gaps we skip over).
current_fg: None | NDArray = None
current_bg: None | NDArray = None

for i in np.flatnonzero(changed.any(axis=1)):
for j in np.flatnonzero(changed[i]):
# step the cursor to this cell with relative moves
if i != cur_row:
d = int(i) - cur_row
s.append(f"\x1b[{abs(d)}{'B' if d > 0 else 'A'}")
cur_row = int(i)
if j != cur_col:
d = int(j) - cur_col
s.append(f"\x1b[{abs(d)}{'C' if d > 0 else 'D'}")
cur_col = int(j)
# manage colour
controls = []
fg = self.fg_rgb[i, j] if self.fg[i, j] else None
if fg is None and current_fg is not None:
controls.append(39) # reset fg
elif fg is not None and (
current_fg is None or np.any(fg != current_fg)
):
controls.extend([38, 2, *fg]) # set fg
current_fg = fg
bg = self.bg_rgb[i, j] if self.bg[i, j] else None
if bg is None and current_bg is not None:
controls.append(49) # reset bg
elif bg is not None and (
current_bg is None or np.any(bg != current_bg)
):
controls.extend([48, 2, *bg]) # set bg
current_bg = bg
if controls:
s.append(f"\x1b[{';'.join(map(str, controls))}m")
s.append(chr(self.codes[i, j]))
cur_col += 1

# restore default colour, then drop to column 0 just below the plot
if current_fg is not None or current_bg is not None:
s.append("\x1b[0m")
s.append(f"\x1b[{H - cur_row}E")
return "".join(s)


def to_plain_str(self: Self) -> str:
"""
Expand Down
43 changes: 41 additions & 2 deletions matthewplotlib/plots.py
Original file line number Diff line number Diff line change
Expand Up @@ -183,8 +183,47 @@ def __neg__(self: Self) -> str:
Shortcut for the string for clearing the plot.
"""
return self.clearstr()




def updatestr(self: Self, prev: plot) -> str:
"""
Convert the plot into a string that, if printed when the cursor is just
below `prev` (i.e. immediately after printing `prev`), updates the
terminal to show this plot instead -- repainting only the cells that
differ from `prev`, and leaving the cursor just below this plot.

This is the fast path for animation: redrawing a whole frame re-emits
every cell, while this re-emits only what changed, which can be far
fewer bytes over a slow connection. When the two plots differ in size
(so there is nothing to diff against), it falls back to a full clear and
redraw.

See `CharArray.to_ansi_diff_str` for the precise cursor contract.
"""
if (self.height, self.width) != (prev.height, prev.width):
return prev.clearstr() + self.renderstr() + "\n"
return self.chars.to_ansi_diff_str(prev.chars)


def __sub__(self: Self, other: plot) -> str:
"""
Operator shortcut for a differential redraw: the string that updates the
terminal from `other` to `self` in place.

```
prev = None
for frame in frames:
print("" if prev is None else frame - prev, end="", flush=True)
if prev is None:
print(frame, end="", flush=True)
prev = frame
```

Compare `-plot` (clear) and `str(plot)` (full redraw). See `updatestr`.
"""
return self.updatestr(other)


def __add__(self: Self, other: plot) -> hstack:
"""
Operator shortcut for horizontal stack.
Expand Down
2 changes: 1 addition & 1 deletion pages/roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ Back end improvements:
* [x] Vectorised bitmap rendering.
* [x] Intelligent ANSI rendering (only include necessary control codes and
resets, e.g., if several characters in a row use the same colours).
* [ ] Faster animated plot redraws (e.g., differential rendering with shortcut
* [x] Faster animated plot redraws (e.g., differential rendering with shortcut
`-`).
* [ ] Clean up backend code e.g. using JAX PyTrees and vectorisation.

Expand Down
190 changes: 190 additions & 0 deletions tests/test_core.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import numpy as np
import pytest

import matthewplotlib as mp
from matthewplotlib.core import (
CharArray,
BoxStyle,
Expand Down Expand Up @@ -568,3 +569,192 @@ def test_color_mapping_multirow(self):
assert np.array_equal(ca.bg_rgb[0, 0], [0, 255, 0])
assert np.array_equal(ca.fg_rgb[1, 0], [0, 0, 255])
assert np.array_equal(ca.bg_rgb[1, 0], [255, 255, 0])


# # #
# CharArray.to_ansi_diff_str (differential rendering)


class _Term:
"""A tiny ANSI screen emulator -- just enough to verify diff rendering.

Applies the escape sequences our renderer emits (cursor moves, SGR colour,
printable glyphs, erase-to-end) to a grid of (char, fg, bg) cells, so a test
can check that a diff transforms the screen exactly like a full redraw.
"""

def __init__(self, rows, cols):
self.rows, self.cols = rows, cols
self.grid = [[(" ", None, None) for _ in range(cols)] for _ in range(rows)]
self.r = self.c = 0
self.fg = None
self.bg = None

def feed(self, s):
i = 0
while i < len(s):
ch = s[i]
if ch == "\x1b":
assert s[i + 1] == "["
j = i + 2
while s[j] not in "ABCDEFGHJKmf":
j += 1
self._csi(s[i + 2:j], s[j])
i = j + 1
elif ch == "\n":
self.r += 1
self.c = 0
i += 1
elif ch == "\r":
self.c = 0
i += 1
else:
self.grid[self.r][self.c] = (ch, self.fg, self.bg)
self.c += 1
i += 1
return self

def _csi(self, params, letter):
if letter == "m":
codes = [int(x) for x in params.split(";")] if params else [0]
k = 0
while k < len(codes):
x = codes[k]
if x == 0:
self.fg = self.bg = None
k += 1
elif x == 39:
self.fg = None
k += 1
elif x == 49:
self.bg = None
k += 1
elif x == 38 and codes[k + 1] == 2:
self.fg = tuple(codes[k + 2:k + 5])
k += 5
elif x == 48 and codes[k + 1] == 2:
self.bg = tuple(codes[k + 2:k + 5])
k += 5
else:
k += 1
return
n = int(params) if params else 1
if letter == "A":
self.r -= n
elif letter == "B":
self.r += n
elif letter == "C":
self.c += n
elif letter == "D":
self.c -= n
elif letter == "E":
self.r += n
self.c = 0
elif letter == "J": # erase from cursor to end of screen
for cc in range(self.c, self.cols):
self.grid[self.r][cc] = (" ", None, None)
for rr in range(self.r + 1, self.rows):
for cc in range(self.cols):
self.grid[rr][cc] = (" ", None, None)

def region(self, h, w):
return [row[:w] for row in self.grid[:h]]


def _rand_chars(rng, h, w):
"""A realistic CharArray: a random half-block image (every cell coloured)."""
img = rng.integers(0, 256, size=(2 * h, w, 3), dtype=np.uint8)
return unicode_image(img)


def _screen_after_diff(prev, new):
"""Emulate printing `prev`, then applying new.to_ansi_diff_str(prev)."""
H, W = prev.height, prev.width
term = _Term(H + 3, W + 2)
term.feed(prev.to_ansi_str()).feed("\n") # as if print(prev) had run
term.feed(new.to_ansi_diff_str(prev))
return term


class TestCharArrayDiffStr:
def test_no_change_is_empty(self):
rng = np.random.default_rng(0)
ca = _rand_chars(rng, 4, 6)
assert ca.to_ansi_diff_str(ca) == ""

def test_shape_mismatch_raises(self):
rng = np.random.default_rng(1)
a = _rand_chars(rng, 3, 4)
b = _rand_chars(rng, 3, 5)
with pytest.raises(ValueError):
b.to_ansi_diff_str(a)

def test_diff_repaints_only_changed_cell(self):
rng = np.random.default_rng(2)
prev = _rand_chars(rng, 4, 8)
new = unicode_image(rng.integers(0, 256, (8, 8, 3), dtype=np.uint8))
# force exactly one differing cell
new.codes = prev.codes.copy()
new.fg = prev.fg.copy()
new.fg_rgb = prev.fg_rgb.copy()
new.bg = prev.bg.copy()
new.bg_rgb = prev.bg_rgb.copy()
new.fg_rgb[1, 5] = (prev.fg_rgb[1, 5].astype(int) + 40) % 256
diff = new.to_ansi_diff_str(prev)
# the diff carries a single glyph, far smaller than a full redraw
assert diff.count("▀") == 1
assert len(diff) < len(new.to_ansi_str())

def test_cursor_returns_below_plot(self):
rng = np.random.default_rng(3)
prev = _rand_chars(rng, 5, 7)
new = _rand_chars(rng, 5, 7)
term = _screen_after_diff(prev, new)
assert (term.r, term.c) == (prev.height, 0)

def test_color_only_change(self):
rng = np.random.default_rng(4)
prev = _rand_chars(rng, 3, 5)
new = unicode_image(rng.integers(0, 256, (6, 5, 3), dtype=np.uint8))
new.codes = prev.codes.copy() # same glyphs, different colours
term = _screen_after_diff(prev, new)
ref = _Term(new.height + 3, new.width + 2).feed(new.to_ansi_str())
assert term.region(new.height, new.width) == ref.region(new.height, new.width)

def test_diff_matches_full_redraw_random(self):
"""The strong one: a diff must leave the screen identical to a fresh
redraw of `new`, across many random partial changes."""
rng = np.random.default_rng(2024)
for _ in range(40):
h = int(rng.integers(1, 9))
w = int(rng.integers(1, 14))
base = rng.integers(0, 256, (2 * h, w, 3), dtype=np.uint8)
after = base.copy()
mask = rng.random((2 * h, w)) < rng.uniform(0.0, 0.6)
after[mask] = rng.integers(0, 256, (int(mask.sum()), 3), dtype=np.uint8)
prev = unicode_image(base)
new = unicode_image(after)
term = _screen_after_diff(prev, new)
ref = _Term(new.height + 3, new.width + 2).feed(new.to_ansi_str())
assert term.region(new.height, new.width) == ref.region(new.height, new.width)
assert (term.r, term.c) == (new.height, 0)


class TestPlotUpdateStr:
def test_sub_operator_matches_updatestr(self):
a = mp.image(np.random.default_rng(5).random((6, 8)))
b = mp.image(np.random.default_rng(6).random((6, 8)))
assert (b - a) == b.updatestr(a)

def test_updatestr_falls_back_on_size_change(self):
# different sizes -> clear + full redraw; emulate and check the screen
prev = mp.image(np.random.default_rng(7).random((8, 10))) # 4 rows
new = mp.image(np.random.default_rng(8).random((4, 6))) # 2 rows
H = max(prev.height, new.height)
term = _Term(H + 3, max(prev.width, new.width) + 2)
term.feed(prev.renderstr()).feed("\n") # as if print(prev) had run
term.feed(new.updatestr(prev))
ref = _Term(H + 3, new.width + 2).feed(new.renderstr())
assert term.region(new.height, new.width) == ref.region(new.height, new.width)
# the old plot's extra rows must have been cleared
assert term.grid[prev.height - 1][0] == (" ", None, None)