8000 fix: repaint the tmux client instead of cycling the alternate screen by msample · Pull Request #40 · gominimal/kittyview · GitHub
[go: up one dir, main page]

Skip to content
Merged
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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# Changelog

## upcoming

- **Slideshow images render fully under tmux, without the flash**: Ghostty through 1.3.1 renders placeholder images delivered as an in-place pane update blank or clipped -- with the data confirmed present in the terminal via the reply channel -- while a full repaint out of tmux's own grid renders them correctly. That is why switching tmux panes completes an image that came out partial. Every draw under a lone tmux layer now ends with `tmux refresh-client`: the same client repaint a pane switch triggers, re-sending nothing (the image is already in the terminal's store, the placeholder cells already in tmux's grid) and costing nothing visible. It replaces the alternate-screen rebuild 0.1.6 used for the same purpose, so slide changes no longer flash the pane underneath, nor pay the 250ms settle that rebuild needed. The repaint is issued after the arrival check, never before, since tmux drops passthrough while a redraw is pending and would eat the question with it; the draws that skip that check -- animations, and the message slide shown for a file that will not load -- wait the same interval before the repaint instead, since they carry passthrough of their own. Where a repaint cannot reach -- screen, which has no equivalent command, and tmux nested in another multiplexer, whose outer layer would receive the repaint as one more in-place update -- slide changes still rebuild by way of the pane's main screen; that rebuild remains the last-attempt fallback when transmissions are being lost, and what the `r` key forces by hand.
- `examples/placeholder_probe` gained a second group of variants (`manual`, `refresh`, `nudge`, `rewrite`, `cycle-refresh`) that bisect the *repair* rather than the draw: given a slide that rendered wrong, which nudge completes it. On Ghostty 1.3.1 + tmux only the two that force a full client repaint do -- unrelated terminal traffic does not, and re-sending the placeholder cells in place does not -- which is what settled the fix above.
- `test/slideshow-test.sh` now checks for the absence of the inter-slide flash it previously told the tester to expect, and asks directly whether any slide came out clipped.

## 0.1.6

- **Slideshow mode**: giving kittyview more than one file now opens an interactive slideshow on the alternate screen -- Right/Space/n and Left/Backspace/p to navigate, Home/End for the ends, q/Esc/Ctrl-C to quit. Images are fitted to the window and centred, with a status line naming the file and position. On the Unicode-placeholder path, navigation never flashes a blank screen: an `a=T,U=1` transmission is invisible until placeholder cells reference it, so the next image's data streams out while the previous slide still shows, and the visible switch is a small delete-erase-grid burst. Every draw uses the same protocol commands as single-image display -- the transmit-then-place split (`a=t`/`a=p`) is deliberately avoided, having been found rendering blank in the wild where `a=T` works. The first draw orders the alternate-screen switch around its transmission per target: directly (and under Zellij) the switch comes first, because kitty-model terminals keep a separate image store per screen buffer and an image transmitted on the main screen does not exist on the alternate one; through tmux/screen passthrough the transmission comes first, because switching the pane's screen schedules a redraw during which tmux silently drops passthrough -- while the outer terminal, whose store holds the image, never switches screens at all. And because tmux drops passthrough around *any* pending redraw -- while `q=2` means nothing ever reports the loss -- every draw under a multiplexer is verified with the protocol's own reply channel (a silent-on-success placement probe chased by a DA barrier) and retried when the data did not arrive; losses are reported in a diagnostics line on exit. Under a multiplexer, slide changes rebuild by way of the pane's main screen (a brief flash), which field testing proved necessary independently of transmission loss: Ghostty through 1.3.1 renders placeholder images delivered as in-place pane updates blank -- with the data confirmed present in the terminal via the reply channel -- while a full repaint renders them correctly. The `r` key forces that rebuild by hand. Placeholder cells are bound to an explicit placement ID through their underline colour, so they resolve against exactly the slideshow's placement -- Ghostty through 1.3.1 otherwise picks among an image's virtual placements in hash order, and any stray placement could capture the cells. Each slide's data is deleted from the terminal's image store when it is replaced, so a long slideshow does not accumulate hundreds of megabytes in the terminal. Restoring the terminal is layered: one cleanup path for quits and errors, a panic hook that restores the screen before the panic message prints, and fatal signals are caught, cleaned up after, and re-raised so the exit status still reports them. Ctrl-Z suspends and resumes properly. Unix only for now: no native Windows terminal displays kitty graphics from a local process, so on Windows the slideshow refuses with an explanation instead.
Expand Down
211 changes: 210 additions & 1 deletion examples/placeholder_probe.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,29 @@
//! Each variant shows the image for four seconds, then cleans up after
//! itself. Note which variants show the cat logo and which stay blank.
//!
//! A second group bisects the *repair* rather than the draw: when a slide
//! renders clipped or blank, which nudge completes it without re-sending
//! image data the terminal already holds?
//!
//! ```sh
//! ./target/debug/examples/placeholder_probe manual # control: switch panes by hand
//! ./target/debug/examples/placeholder_probe refresh # tmux refresh-client
//! ./target/debug/examples/placeholder_probe nudge # one unrelated cell of traffic
//! ./target/debug/examples/placeholder_probe rewrite # re-send the grid cells in place
//! ./target/debug/examples/placeholder_probe cycle-refresh # navigation, repaired by refresh
//! ```
//!
//! Run `manual` first: it establishes that this draw reproduces the problem
//! at all, and that a pane switch repairs it. Without that the other three
//! prove nothing. Each shows the draw, waits, applies exactly one nudge,
//! and waits again, so a single run shows before and after.
//!
//! This is a debugging aid, not part of the kittyview CLI.

use kittyview::terminal::Mux;
use kittyview::{kitty, logo, placeholder};
use std::io::{self, Write};
use std::process::{Command, Stdio};
use std::time::Duration;

const COLS: u16 = 16;
Expand All @@ -31,6 +49,14 @@ const ROWS: u16 = 8;
/// slideshow's centring does.
const ORIGIN_ROW: u16 = 3;
const ORIGIN_COL: u16 = 5;
/// How long the nudge variants leave the screen alone for one observation.
const OBSERVE: Duration = Duration::from_secs(5);
/// Longer, for the variant whose nudge is a pane switch made by hand.
const MANUAL_OBSERVE: Duration = Duration::from_secs(15);
/// How long to let a draw land before asking tmux to repaint, mirroring the
/// slideshow's `VERIFY_SETTLE`: tmux drops passthrough while a redraw is
/// pending, so the transmission must be clear of the repaint at both ends.
const REPAINT_SETTLE: Duration = Duration::from_millis(200);

fn main() -> io::Result<()> {
let variant = std::env::args().nth(1).unwrap_or_default();
Expand Down Expand Up @@ -149,16 +175,199 @@ fn main() -> io::Result<()> {

cleanup(&mut out, &mux, id_c, true)
}
// Navigation with a tmux repaint standing in for the alternate-
// screen cycle: three slides, each drawn by the slideshow's own
// dance -- transmit the new data, delete the old image, erase,
// write the new grid -- and then repaired with `refresh-client`
// rather than by leaving and re-entering the alternate screen.
//
// This is what `refresh` does not settle. That variant repaints a
// first draw, where nothing has been deleted and no data is in
// flight; a slide change repaints while an image has just been
// dropped from the terminal's store and another has just arrived
// through passthrough. If every slide here renders, the cycle in
// `slideshow::show` -- and its flash, and its LEAVE_SETTLE -- can
// be replaced by a repaint.
"cycle-refresh" => {
if mux.is_empty() {
eprintln!("cycle-refresh needs tmux: $TMUX is not set");
std::process::exit(2);
}
let ids = [
image_id,
kitty::pick_image_id(&mux),
kitty::pick_image_id(&mux),
];
out.write_all(b"\x1b[?1049h\x1b[H\x1b[J")?;
out.flush()?;
std::thread::sleep(Duration::from_millis(1500));

let mut previous: Option<u32> = None;
for (n, &id) in ids.iter().enumerate() {
// New data first: an `a=T,U=1` transmission is invisible
// until cells reference it, so it can stream out while the
// previous slide is still on screen.
kitty::transmit_virtual(&png, &mut out, &mux, id, 1, COLS, ROWS)?;
if let Some(old) = previous {
kitty::delete_image(&mut out, &mux, old, true)?;
}
out.write_all(b"\x1b[H\x1b[J")?;
// Shift each slide right, so which draw is on screen is
// visible at a glance.
position_grid_at(&mut out, id, ORIGIN_ROW, ORIGIN_COL + 20 * n as u16)?;
status(
&mut out,
&format!(
"cycle-refresh: slide {} of {} -- repaint in progress",
n + 1,
ids.len()
),
)?;
out.flush()?;

std::thread::sleep(REPAINT_SETTLE);
let _ = Command::new("tmux")
.arg("refresh-client")
.stdout(Stdio::null())
.stderr(Stdio::null())
.status();
// Let the repaint finish before the next transmission goes
// out through passthrough.
std::thread::sleep(REPAINT_SETTLE);
std::thread::sleep(OBSERVE);
previous = Some(id);
}

cleanup(&mut out, &mux, ids[ids.len() - 1], true)?;
eprintln!(
"cycle-refresh: all three slides rendered => a repaint is enough for slide \
changes too, and the alternate-screen cycle can go. Any slide blank or \
clipped => the cycle is doing something the repaint does not, and \
refresh-client can only be added to it, not swapped for it."
);
Ok(())
}
// The repaint bisection. All four draw the same known-problematic
// shape -- alternate screen, transmit, positioned grid -- hold it
// long enough to see whether it came out wrong, apply exactly one
// nudge, and hold again. Nothing is written after the nudge: any
// further output is itself terminal traffic and would confound the
// result, which is also why the caption goes up front.
"manual" | "refresh" | "nudge" | "rewrite" => {
if variant == "refresh" && mux.is_empty() {
eprintln!("refresh needs tmux: $TMUX is not set");
std::process::exit(2);
}
out.write_all(b"\x1b[?1049h\x1b[H\x1b[J")?;
out.flush()?;
std::thread::sleep(Duration::from_millis(1500));

transmit_and_position(&mut out, &mux, &png, image_id)?;
status(&mut out, &caption(&variant))?;
out.flush()?;
std::thread::sleep(if variant == "manual" {
MANUAL_OBSERVE
} else {
OBSERVE
});

match variant.as_str() {
// What switching panes does: repaint every visible pane
// out of tmux's own grid. No image data is re-sent -- the
// terminal already holds it, and the placeholder cells are
// ordinary text in tmux's grid. They survive the repaint
// because their image ID rides a 256-colour SGR, which a
// multiplexer relays verbatim (see `IdSpace::MuxSafe`).
"refresh" => {
let _ = Command::new("tmux")
.arg("refresh-client")
.stdout(Stdio::null())
.stderr(Stdio::null())
.status();
}
// One cell of traffic, far from the grid, reaching the
// terminal through tmux like any other pane output. It
// changes nothing about the image or the cells drawing
// it, so it can only repair a frame the terminal itself
// failed to paint.
"nudge" => {
write!(out, "\x1b[{};1H\x1b[2m.\x1b[0m", ORIGIN_ROW + ROWS + 4)?;
out.flush()?;
}
// The placeholder cells re-sent exactly as they were, as
// an in-place pane update.
"rewrite" => {
position_grid_at(&mut out, image_id, ORIGIN_ROW, ORIGIN_COL)?;
out.flush()?;
}
// "manual": the nudge is the pane switch, done by hand
// during the wait that just passed.
_ => {}
}

std::thread::sleep(OBSERVE);
cleanup(&mut out, &mux, image_id, true)?;
eprintln!("{}", verdict(&variant));
Ok(())
}
_ => {
eprintln!(
"usage: placeholder_probe \
<flowed|positioned|alt-flowed|alt-wait|alt-positioned|cycle>"
<flowed|positioned|alt-flowed|alt-wait|alt-positioned|cycle\
|manual|refresh|nudge|rewrite|cycle-refresh>"
);
std::process::exit(2);
}
}
}

/// What the nudge variants put on screen before the wait, since nothing may
/// be written afterwards.
fn caption(variant: &str) -> String {
match variant {
"manual" => format!(
"manual: switch to another pane and back within {}s -- does the image complete?",
MANUAL_OBSERVE.as_secs()
),
"refresh" => format!(
"refresh: `tmux refresh-client` fires in {}s",
OBSERVE.as_secs()
),
"nudge" => format!(
"nudge: one unrelated cell is written in {}s",
OBSERVE.as_secs()
),
_ => format!(
"rewrite: the grid cells are re-sent in {}s",
OBSERVE.as_secs()
),
}
}

/// How to read what just happened, printed once the screen is restored.
fn verdict(variant: &str) -> &'static str {
match variant {
"manual" => {
"manual: if the image was wrong and the pane switch completed it, this draw \
reproduces the bug and the other three variants mean something. If it looked \
right all along, the probe is not reproducing what the slideshow hits."
}
"refresh" => {
"refresh: completed => a full tmux client redraw is the fix, and the slideshow \
can call refresh-client after each draw instead of cycling the alternate \
screen -- no flash, no LEAVE_SETTLE."
}
"nudge" => {
"nudge: completed => the terminal painted a stale frame; any trivial traffic \
after a draw is enough, and no tmux involvement is needed."
}
_ => {
"rewrite: completed => re-sending the placeholder cells in place is enough, \
which is the cheapest fix of the three."
}
}
}

/// Write a dim status line below the grid area.
fn status(out: &mut impl Write, text: &str) -> io::Result<()> {
write!(
Expand Down
Loading
Loading
0