Skip to content

feat(terminal): render cropped graphics placements and present decoded frames - #818

Closed
glitch-cmyk658 wants to merge 11 commits into
Kuddev:mainfrom
glitch-cmyk658:feat/graphics-subregion
Closed

glitch-cmyk658 wants to merge 11 commits into
Kuddev:mainfrom
glitch-cmyk658:feat/graphics-subregion

Conversation

@glitch-cmyk658

Copy link
Copy Markdown
Contributor

What

Completes the APC graphics protocol support in the terminal so full-page readers work. A PDF/EPUB reader running inside Pebrel used to leave the page area blank whenever it placed a cropped region of a page, and occasionally showed nothing at all until the window was clicked.

Seven commits, four independent defects:

  1. 3f371845 — carry the placement's sub-region (x/y/w/h) through the grid. It lands in ImageLayout as a SourceRect, is republished when the region or the cell box changes, and a re-place that reuses an already decoded buffer retargets the queued or cached image instead of decoding it again. Also accepts o=z payloads, transfers without an explicit a=t, and placements without c/r (natural size).
  2. 43fe705e — clip each row run to the slice it asked for. The slice rect used to be re-anchored at every row of cells, so a 40 px slice inside an 8-row box painted 160 px.
  3. f3ab6658 — scale the slice offset with the bitmap's paint scale. The terminal reports cell sizes in device pixels while the painter works in logical pixels (1.75× at 175 % scaling), so a slice with y > 0 was pushed above its box and only its last row stayed visible.
  4. e02ee7f2 — keep the crop that an a=T header carries. It was dropped before the body arrived, and the same crop is now also accepted on a=p.
  5. fc44489a — answer the text-area query from the live grid instead of the geometry captured at spawn. A stale answer made the reader believe the pane was 151×40 while it was 90×23, so it laid the page out at column 66, off screen.
  6. faa3edbd — scale the crop to fill the cells the placement owns (c/r) instead of the whole bitmap. A reader sends one band of a page at a time and the band used to occupy only a fraction of the reserved block. Raises the decoded-image cache to 32 images / 256 MiB so the page around the cursor is not evicted while it is on screen.
  7. 769d58c4 — present a decoded image even when its frame is already queued: after cx.notify() the decode path schedules one extra frame 40 ms later, which is the click the user used to need. Also surfaces paint_image failures instead of discarding them, and adds diagnostics gated behind PEBREL_DEBUG_LOG (visible=, y=, bounds=, paint_error=).

Note on the branch

The first three commits (cdd1d648, 06890480, 1fc969d0) are the prerequisites this work builds on: the APC graphics protocol itself (also proposed separately), a chunked-display placement fix, and a startup-card tweak. Only the seven commits above are new here.

Tests

  • The terminal graphics unit tests: 16 passed.
  • The grid image integration tests: 12 passed.
  • The app inline image tests: 19 passed.
  • cargo fmt --all -- --check: clean.

New cases cover: a crop being published and republished when it moves, single-cell placements unioning their slices, o=z inflation, implicit a=t, natural-size placement without c/r, a slice not leaking below its row, device/logical scaling, a crop filling every row of its box, and a replay of the same crop reusing the queued transfer.

Verification

Installed build Pebrel 2.3.1 (769d58c4) on Windows 11, 2560×1600 at 175 % scaling, reading two books (one text, one 226 MB scanned) with a full-page reader inside a maximized Pebrel window:

  • 1447×1872 pages arrive with an x/y/w/h crop and paint every row (painted == runs, empty == 0).
  • Ten rapid page turns leave the page rendered. Before the last commit the same script produced a blank page area about one run in three.

Known follow-up: when the window is restored rather than maximized, the pane keeps stale bounds for a while, so a placement can be drawn outside the visible window until the next relayout. Not addressed here.

The startup card was only ever printed for PowerShell. The shell was
guessed by scanning the shell name for "wsl" or "bash", so cmd.exe got
the PowerShell script (Clear-Host is not a cmd builtin, so it printed
an error and no card) and WSL only got the fastfetch fallback line,
which on a machine without fastfetch is a single line of text.

Pick the intro command from the program actually spawned instead:
PowerShell keeps its cached script, cmd gets a UTF-8 switch plus a
plain-text copy of the card, and POSIX shells (wsl, bash, zsh, fish,
nu, ...) clear the screen and print the same text, translating the temp
path with wslpath or cygpath when either is available. fastfetch still
wins when it is installed.
Terminals serving that protocol see two dialects: one client sends a placement
with an explicit cell count, another asks for a 1x1 placement per cell and lets
the cursor describe the rectangle. The grid now owns the image either way: a
placement becomes an ImagePlacement whose tiles live in the cells the cursor
visited, so scrolling, reflow, alt-screen swaps and erasure reuse the existing
grid-owned image lifetime.

- osc_cwd: parse APC commands (ESC _ G ... ESC \) into control and payload.
  vte drops APC, so this sniffer is the only parser for them.
- kitty_graphics: reassemble chunked base64 transfers, decode raw RGB/RGBA
  pixels, keep one bitmap per id, union the visited cells into a single
  placement, publish one Event::KittyImage per transfer instead of one per
  placement, and reset transfer state when a reused id starts over.
- Answer the CSI 16 t cell-size query from the byte stream with the pane's
  device-pixel cell size; the parser has no handler for it, and a client that
  never learns it stretches its preview across the whole pane.
- Msg::CellMetrics supplies that cell size to panes whose born geometry equals
  their target geometry and which therefore never send a resize.
- The app decodes transferred pixels on the background executor through the
  existing inline-image store, repacking RGB/RGBA into the BGRA the renderer
  wants.
A transfer that asks the terminal to display the image as it arrives (�=T)
only carries the target cell box on its first chunk; the closing chunk has no
action at all. The state machine treated that as a plain transfer, so the
bitmap was stored and never placed, which left clients that stream a page in
one go with a blank screen. The box now travels with the transfer and is
placed when the last chunk lands.

Also drop the stored geometry on delete so a reused image id cannot inherit
the previous picture's size.
A placement may name a rectangle of the transferred bitmap (`x`/`y`/`w`/`h`).
The terminal kept only the destination cells, so a pager that scrolls a page by
re-placing the same image with a different `y` kept the previous band on screen
forever, and after an erase the area stayed blank because the cell box had not
grown.

- carry the slice in `ImageLayout` and move the bitmap under its cells, so no
  pixels are re-uploaded to show a different part of the same picture
- republish a placement when its slice changes, not only when its box grows
- retarget a queued decode when a placement only moved its slice, instead of
  decoding the same buffer again on every scroll step
- accept `o=z` payloads (inflated to the declared size), transfer headers that
  omit `a=t`, and placements without `c`/`r` (natural size, one image pixel per
  screen pixel)
The painter shifted the bitmap by the slice offset but clipped only to the
cells of the run, so a slice shorter than its cell box revealed the rows that
follow it. Rows are painted one run each, which made the leak worse: a 40-pixel
slice in an eight-row box painted 160 pixels, four copies of the page stacked
inside the box.

- intersect the clip with the slice, anchored to the placement origin, so the
  rows below a slice stay empty
- keep the slice in bitmap coordinates so a cell box that disagrees with the
  reported cell size cannot move the boundary
A high-DPI pane paints the bitmap smaller than its pixel size, so a
placement's slice has to shrink with it. Shifting the bitmap by the raw
pixel offset put the slice above the tile and left only its last row
visible: an 80 pixel slice in an eight row box painted 20 pixels.

image_geometry now derives the scale before placing the bitmap and uses
it for both the origin and the slice rectangle.
A reader that streams a page sends one �=T header with the crop and the
destination cells and then continuation chunks without any control keys.
Two things broke that shape:

- inish_transfer placed the image with an empty control string, so the
  crop in the transfer header was dropped and the whole bitmap went into the
  cell box. The header's cells and crop are now kept in a Display entry
  and handed to the placement.
- Nothing recorded why a page stayed invisible, so the app-side enqueue and
  paint paths now log under PEBREL_DEBUG_LOG, which is how the render bug
  below was found.

Tests cover the reader-shaped transfer, a mixed-depth batch and the crop
that reaches the app.
CSI 14 t answered with the view's window size, which only commit_viewport
updates; a grid-only reflow (resize without reflowing the child, the debounced
path) left it at the spawn geometry. A reader that derives its page size from
the text area then laid out for 151 columns on a 90 column pane and placed the
page past its right edge.

Also scale a placement into the cells it declared instead of its pixel size:
the cells are what the grid reserved and what the clip uses, so a sender that
multiplies differently no longer paints a page several cells wider than the
block it asked for.

Tests: the terminal graphics unit tests (16 passed), the app inline image tests
(19 passed) and the grid image integration tests (12 passed).
A sender that crops to the part of a page it wants visible (a pager
sending one y/h per scroll step) expects that crop to cover the block it
declared. Scaling the slice by the whole bitmap instead left it a
fraction of the box tall, which showed up as a page that never appeared.

Keep more decoded pages cached as well: a reader uploads the pages around
the cursor up front, and the previous budget of ten pages evicted the one
on screen while it was still being paged to.
A page uploaded by a reader decodes off the UI thread. When the bitmap
finished decoding while the window already had a frame queued, the frame
drawn for it was built before the texture reached the store, so the cells
painted as empty and the pane kept showing an untouched page area until
the next input event — a click brought the page back.

Nudge one more frame once the decode has settled, so the freshly decoded
image is presented without any input.

The graphics paint summary also reports how many runs survive the pane
bounds, the vertical span they cover, the pane bounds, and the error from
a dropped image paint; the atlas failing to insert a texture used to be
silently discarded.

Tests: the app inline image tests (19 passed), the terminal graphics unit
tests (16 passed) and the grid image integration tests (12 passed).
`nebula_terminal/src/event_loop.rs` had crossed the 2000-line source budget
after the graphics tests moved in. Keep the module focused on the stream
processor and put the tests next to it as `event_loop_tests.rs`, declared
through the same `#[path = "..."]` pattern the unix-only tests already use.

No test bodies changed: the file only drops one level of indentation.

Tests: cargo test -p nebula_terminal --offline --lib (364 passed, 2 failed -
both pre-existing proxy tests that need pwsh.exe), the terminal graphics unit
tests (16 passed), the grid image integration tests (12 passed), and
scripts/check_architecture.py exits 0.
@glitch-cmyk658

Copy link
Copy Markdown
Contributor Author

Status update: upstream main merged the protocol work as cf6bafb2 (#767) while this branch was open, so the branch now contains a parallel copy of that protocol and reports as conflicting.

Before deciding how to split this, I built current main (cf6bafb2) and re-ran the same reproduction (a full-page reader inside the terminal, six to ten page turns two seconds apart): the page area is still blank after a turn, and the first page of a book can come up blank too, so the fixes below are still needed on top of the merged protocol.

The app-side parts port cleanly onto main and are small:

  • answer the text-area query from the live grid instead of the geometry captured at spawn (a stale answer makes a reader lay pages out for a pane that no longer exists — 151×40 while the grid was 90×23, so the page lands off screen);
  • keep 32 images / 256 MiB of decoded pages instead of 16 / 128 MiB, so the page around the cursor is not evicted while it is visible;
  • request one more frame ~40 ms after a decode finishes, because a texture inserted between frames can stay unpresented until the next input event (that is the "click the window and the page appears" symptom).

With those three applied to main the page still did not render, so at least one terminal-side change from this branch is also required — most likely the crop that an a=T header carries has to survive until the body arrives, and a placement has to republish when its crop or cell box changes. Porting that against the new kitty_graphics/control.rs layout is the next step; I will open it as a separate, focused PR and close this one rather than ship a second copy of the protocol.

Two CI notes from this branch, for whoever runs into them: architecture-contracts failed because nebula_terminal/src/event_loop.rs crossed 2 000 lines once the graphics tests moved in (fixed by moving them to event_loop_tests.rs), and pr-size counts the three prerequisite commits, which is most of why this PR is over the default limit.

@glitch-cmyk658

Copy link
Copy Markdown
Contributor Author

Superseded by #819.

The protocol implementation in main after #767 covers most of this branch:
sub-region placements, the crop carried by the a=T header, and the DPI-scaled
slice offset are all handled there, so this branch duplicates them.

What was actually still broken on main, and what #819 carries instead:

  • a reader header with z=-1 was rejected with ENOTSUP before the pixels were
    stored, so pages stayed blank;
  • CSI 14 t answered from the size committed at spawn time, so pages were laid
    out for a grid that no longer existed;
  • a page decoded between frames could stay unpresented until the next input
    event (the "click the window and the page appears" symptom);
  • the decoded-page cache was small enough that a reader uploading pages around
    the cursor could evict the one on screen, permanently.

#819 is 4 files, +50/-4. Closing this one to keep the queue single-threaded.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant