Skip to content

Commit 2f8b741

Browse files
committed
small refactor but also possible some breaking, to test..
1 parent a89a180 commit 2f8b741

7 files changed

Lines changed: 70 additions & 50 deletions

File tree

gambaterm/colors.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,9 @@ def cycle(self) -> ColorMode:
2222

2323
def cycle_back(self) -> ColorMode:
2424
"""Cycle to the previous color mode."""
25-
value = self - 1
26-
if value == ColorMode.COULD_NOT_DETECT:
27-
value = ColorMode.HAS_24_BIT_COLOR
25+
value = int(self) - 1
26+
if value <= int(ColorMode.COULD_NOT_DETECT):
27+
value = int(ColorMode.HAS_24_BIT_COLOR)
2828
return ColorMode(value)
2929

3030
def report(self) -> str:

gambaterm/graphics_scaler.py

Lines changed: 23 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,6 @@
2020
if TYPE_CHECKING:
2121
from blessed import Terminal
2222
from .console import Console
23-
from .colors import ColorMode
2423

2524
BASELINE_ID = 1
2625
DELTA_ID = 2
@@ -61,7 +60,7 @@ def parse_autoscale(value: str) -> AutoScaleConfig:
6160
elif token.endswith("kb"):
6261
bandwidth_mbits = float(token[:-2]) / 125.0
6362
elif token.endswith("mb"):
64-
bandwidth_mbits = float(token[:-2])
63+
bandwidth_mbits = float(token[:-2]) * 8.0
6564
elif token.endswith("s"):
6665
seconds = int(token[:-1])
6766
saw_seconds = True
@@ -241,6 +240,12 @@ def __init__(
241240
def position(self) -> tuple[int, int]:
242241
return self.refx_kitty, self.refy_kitty
243242

243+
def close(self) -> None:
244+
"""Close the stats file handle if it is not /dev/null."""
245+
if self._stats_fh is not None and self._stats_fh.name != os.devnull:
246+
self._stats_fh.close()
247+
self._stats_fh = None
248+
244249
@classmethod
245250
def recompute(
246251
cls,
@@ -402,20 +407,27 @@ def blit_sixel(
402407

403408
self._profile.deltas += 1
404409

405-
colors = to_rgb(video)
406-
indices, palette = quantize_colors(colors, 256)
407-
indices = np.asarray(indices)
410+
# Quantize only changed pixels for the overlay delta — unchanged
411+
# pixels are transparent (skip_index=255) and already on screen
412+
# from the prior frame via P2=1 mode.
413+
changed_pixels = video[diff].reshape(-1, 1)
414+
changed_colors = to_rgb(changed_pixels)
415+
max_colors = min(256, changed_colors.shape[0])
416+
indices_delta, palette = quantize_colors(changed_colors, max_colors)
408417
palette = np.asarray(palette)
409-
indices[~diff] = 255
418+
indices = np.full(video.shape, 255, dtype=np.uint8)
419+
indices[diff] = np.asarray(indices_delta).ravel()
410420

411421
result = encode_sixel(
412-
colors,
422+
to_rgb(video),
413423
max_colors=256,
414424
scale=self.scale,
415425
indices=indices,
416426
palette=palette,
417427
skip_index=255,
418428
)
429+
# Baseline updated every overlay: sixel P2=1 transparency requires
430+
# frame-to-frame diffs so unchanged pixels aren't double-rendered.
419431
self._sixel_baseline = video.copy()
420432
self._profile.bytes_out += len(result)
421433
elapsed_us = int((time.perf_counter() - t0) * 1e6)
@@ -590,6 +602,10 @@ def _encode_kitty(self, video, encode_fn):
590602
),
591603
]
592604
self._had_delta = True
605+
# Baseline NOT updated after delta: kitty p=1 rect replacement
606+
# diffs against the last keyframe, not the previous frame. Each
607+
# p=1 replacement removes the prior delta at the target rect, so
608+
# keyframe-relative diffs avoid compounding residual artifacts.
593609
result = b"".join(result_parts)
594610
elapsed_us = int((time.perf_counter() - t0) * 1e6)
595611
self._stats_fh.write(
@@ -607,7 +623,6 @@ def blit_kitty(
607623
last_frame: np.ndarray | None,
608624
width: int,
609625
height: int,
610-
color_mode: ColorMode,
611626
) -> bytes:
612627
"""Encode ``video`` as a kitty RGBA escape sequence.
613628

gambaterm/main.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
from .console import GameboyColor, Console
1515
from .audio import audio_player
1616
from .colors import detect_local_color_mode, ColorMode
17-
from .remote_terminal import GraphicsProtocol, _FORCE_SIXEL_BLITLESS, does_sixel
17+
from .remote_terminal import GraphicsProtocol, _FORCE_SIXEL_BLITLESS, _BAD_TEXT, does_sixel
1818
from .graphics_scaler import parse_autoscale
1919
from .keyboard_input import is_kitty_keyboard_protocol_supported
2020
from .input_getter import BaseInputGetter
@@ -195,6 +195,8 @@ def detect_graphics_local(
195195
if does_sixel(terminal, sv=sv):
196196
available.append(GraphicsProtocol.SIXEL)
197197
# Prefer kitty, then sixel, then text
198+
if GraphicsProtocol.KITTY in available:
199+
return GraphicsProtocol.KITTY, available
198200
return available[-1], available
199201

200202

@@ -249,13 +251,12 @@ def main(
249251

250252
# Prefer text mode when it fits the terminal, unless the terminal has
251253
# poor unicode rendering (Rio, mlterm).
252-
_bad_text = ("rio", "mlterm") # corrupted unicode font rendering
253254
term_height = terminal.height or 24
254255
term_width = terminal.width or 80
255256
if (
256257
term_width >= console.WIDTH
257258
and term_height >= console.HEIGHT // 2
258-
and not terminal_name.startswith(_bad_text)
259+
and not terminal_name.startswith(_BAD_TEXT)
259260
):
260261
args.graphics_protocol = GraphicsProtocol.TEXT
261262

gambaterm/remote_terminal.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,9 @@ def does_sixel(
128128

129129
def _version_in_range(version: str, lo_excl: str, hi_excl: str) -> bool:
130130
"""Return True if *version* is in (lo_excl, hi_excl)."""
131-
v = tuple(int(p) for p in version.split("."))
131+
# Strip pre-release suffixes (e.g. "0.4.0-alpha") before numeric parse.
132+
_nums = __import__("re").split(r"[^\d]", version)
133+
v = tuple(int(p) for p in _nums if p)
132134
lo = tuple(int(p) for p in lo_excl.split("."))
133135
hi = tuple(int(p) for p in hi_excl.split("."))
134136
return lo < v < hi
@@ -174,6 +176,9 @@ def _detect(self, timeout: float = 3.0) -> KeyboardSupport:
174176

175177
_FORCE_SIXEL_BLITLESS = ("contour", "tabby", "konsole", "mlterm", "iterm2")
176178

179+
# Terminals with corrupted unicode font rendering — always prefer graphics.
180+
_BAD_TEXT = ("rio", "mlterm")
181+
177182

178183
def detect_graphics_frontend(
179184
terminal: RemoteTerminal,

gambaterm/run.py

Lines changed: 16 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,10 @@
1717
from .input_getter import BaseInputGetter
1818
from .colors import ColorMode
1919
from .graphics_scaler import GraphicsScaler, AutoScale, AutoScaleConfig, _SCALE_CEILING
20-
from .remote_terminal import GraphicsProtocol
20+
from .remote_terminal import GraphicsProtocol, _BAD_TEXT
21+
22+
# Terminals needing full frames every frame (no dirty-rect / overlay deltas).
23+
_FORCE_KITTY_BLITLESS = ("rio", "ghostty")
2124

2225

2326
@contextlib.contextmanager
@@ -35,21 +38,6 @@ def get_ref(width: int, height: int, console: Console) -> tuple[int, int]:
3538
return refx, refy
3639

3740

38-
# Terminals with corrupted unicode font rendering — always prefer graphics.
39-
_BAD_TEXT = ("rio", "mlterm")
40-
# Terminals needing full frames every frame (no dirty-rect / overlay deltas).
41-
_FORCE_KITTY_BLITLESS = ("rio", "ghostty")
42-
43-
44-
def _is_mlterm(term: Terminal) -> bool:
45-
"""Return True if the terminal is mlterm (loses sixel on focus-out)."""
46-
try:
47-
sv = term.get_software_version(timeout=0.25)
48-
return sv is not None and "mlterm" in sv.name.lower()
49-
except Exception:
50-
return False
51-
52-
5341
def write_frame(term: Terminal, frame_data: bytes) -> None:
5442
# Fix code page issue on windows:
5543
# `sys.stdout.buffer.raw` is a `WindowsConsoleIO` that always support UTF-8
@@ -180,7 +168,7 @@ def run(
180168
cycle = [
181169
p
182170
for p in graphics_cycle
183-
if p is not GraphicsProtocol.TEXT or terminal_name not in _BAD_TEXT
171+
if p is not GraphicsProtocol.TEXT or not terminal_name.startswith(_BAD_TEXT)
184172
]
185173
if cycle:
186174
idx = cycle.index(graphics_protocol)
@@ -192,7 +180,7 @@ def run(
192180
average_over = int(round(fps)) # frames
193181
audio_out.update_speed(console, speed)
194182
elif key.key_name in ("FOCUS_IN", "FOCUS_OUT"):
195-
if graphics_protocol is GraphicsProtocol.SIXEL and _is_mlterm(term):
183+
if graphics_protocol is GraphicsProtocol.SIXEL and terminal_name.startswith("mlterm"):
196184
if scaler is not None:
197185
scaler._sixel_baseline = None
198186
elif key.key_name in ("KEY_GRAVE_ACCENT", "KEY_TILDE") or key in ("`", "~"):
@@ -229,7 +217,7 @@ def run(
229217
if (
230218
new_width >= console.WIDTH
231219
and new_height >= console.HEIGHT // 2
232-
and not terminal_name.startswith(("rio", "mlterm"))
220+
and not terminal_name.startswith(_BAD_TEXT)
233221
):
234222
new_graphics_protocol = GraphicsProtocol.TEXT
235223
changed = True
@@ -242,7 +230,7 @@ def run(
242230
if (
243231
text_fits
244232
and graphics_protocol is not GraphicsProtocol.TEXT
245-
and terminal_name not in _BAD_TEXT
233+
and not terminal_name.startswith(_BAD_TEXT)
246234
):
247235
new_graphics_protocol = GraphicsProtocol.TEXT
248236
elif (
@@ -285,6 +273,8 @@ def run(
285273
graphics_protocol = new_graphics_protocol
286274
term.number_of_colors = new_color_mode.number_of_colors
287275
last_frame.fill(0)
276+
if scaler is not None:
277+
scaler.close()
288278
scaler = None
289279
force_status_update = True
290280

@@ -329,9 +319,9 @@ def run(
329319
frame_data += scaler.blit_sixel_blitless(video, width, height)
330320
else:
331321
frame_data += scaler.blit_kitty(
332-
video, last_frame, width, height, color_mode
322+
video, last_frame, width, height
333323
)
334-
if terminal_name in _FORCE_KITTY_BLITLESS:
324+
if terminal_name.startswith(_FORCE_KITTY_BLITLESS):
335325
scaler._baseline = None
336326
if sync_end:
337327
frame_data += sync_end
@@ -351,6 +341,8 @@ def run(
351341
if data_length and auto_scale is not None and autoscale is not None:
352342
data_rate_kb_s = sum(data_length) / len(data_length) * fps / 1000
353343
if auto_scale.feed_bandwidth(data_rate_kb_s, autoscale.bandwidth_mbits):
344+
if scaler is not None:
345+
scaler.close()
354346
scaler = GraphicsScaler.recompute(
355347
term,
356348
console,
@@ -404,6 +396,8 @@ def run(
404396
and graphics_protocol is GraphicsProtocol.KITTY
405397
):
406398
kitty_pending_delete = True
399+
if scaler is not None:
400+
scaler.close()
407401
scaler = GraphicsScaler.recompute(
408402
term,
409403
console,

termblit_ext/graphicsblit.pyx

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -119,15 +119,20 @@ def encode_sixel(float[:, :, ::1] colors, int max_colors=256, int scale=1,
119119
cdef int h = colors.shape[0]
120120
cdef int w = colors.shape[1]
121121

122+
# Compute scaled dimensions up front — needed even when using
123+
# precomputed indices (the overlay delta path avoids scaling colors).
124+
cdef int use_precomputed = indices is not None and palette is not None
125+
if scale > 1:
126+
h *= scale
127+
w *= scale
128+
122129
# Scale via numpy (already fast C)
123130
cdef float[:, :, ::1] scaled_colors
124-
cdef int use_precomputed = indices is not None and palette is not None
125131
if scale > 1:
126-
arr = np.asarray(colors)
127-
arr = np.repeat(np.repeat(arr, scale, axis=0), scale, axis=1)
128-
scaled_colors = arr
129-
h = scaled_colors.shape[0]
130-
w = scaled_colors.shape[1]
132+
if not use_precomputed:
133+
arr = np.asarray(colors)
134+
arr = np.repeat(np.repeat(arr, scale, axis=0), scale, axis=1)
135+
scaled_colors = arr
131136
if use_precomputed:
132137
indices = np.ascontiguousarray(
133138
np.repeat(np.repeat(np.asarray(indices), scale, axis=0),
@@ -144,7 +149,7 @@ def encode_sixel(float[:, :, ::1] colors, int max_colors=256, int scale=1,
144149
scaled_colors = arr
145150
h = scaled_colors.shape[0]
146151
if use_precomputed:
147-
indices_padded = np.zeros((h, w), dtype=np.uint8)
152+
indices_padded = np.full((h, w), skip_index, dtype=np.uint8)
148153
indices_padded[:indices.shape[0], :] = np.asarray(indices)
149154
indices = indices_padded
150155

tests/test_graphics_renderer.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -84,11 +84,11 @@ def test_kitty_dirty_rect_delta():
8484
)
8585

8686
baseline = np.full((10, 10), 0xFF00FF00, dtype=np.uint32)
87-
scaler.blit_kitty(baseline, None, 10, 10, None)
87+
scaler.blit_kitty(baseline, None, 10, 10)
8888

8989
changed = baseline.copy()
9090
changed[3, 4] = 0xFFFF0000
91-
result = scaler.blit_kitty(changed, baseline, 10, 10, None)
91+
result = scaler.blit_kitty(changed, baseline, 10, 10)
9292

9393
text = result.decode("latin-1")
9494
assert "i=2" in text
@@ -113,14 +113,14 @@ def test_kitty_rebaseline_deletes_delta():
113113
)
114114

115115
baseline = np.full((10, 10), 0xFF00FF00, dtype=np.uint32)
116-
scaler.blit_kitty(baseline, None, 10, 10, None)
116+
scaler.blit_kitty(baseline, None, 10, 10)
117117

118118
changed = baseline.copy()
119119
changed[0, 0] = 0xFFFF0000
120-
scaler.blit_kitty(changed, baseline, 10, 10, None)
120+
scaler.blit_kitty(changed, baseline, 10, 10)
121121

122122
big_change = np.full((10, 10), 0xFF0000FF, dtype=np.uint32)
123-
result = scaler.blit_kitty(big_change, changed, 10, 10, None)
123+
result = scaler.blit_kitty(big_change, changed, 10, 10)
124124
text = result.decode("latin-1")
125125
assert "i=1" in text
126126
assert "a=d,d=i,i=2" in text
@@ -313,7 +313,7 @@ def test_disabled(self, value):
313313

314314
@pytest.mark.parametrize("value,seconds,fps,mbits", [
315315
("30fps", -1, 30.0, 0.0),
316-
("60s,30fps,10mb", 60, 30.0, 10.0),
316+
("60s,30fps,10mb", 60, 30.0, 80.0),
317317
("1500kb", -1, 40.0, 12.0),
318318
("always,25fps", -1, 25.0, 0.0),
319319
])

0 commit comments

Comments
 (0)