Repository navigation
Replies: 2 comments
§5's "sort keys / filter predicates" row is done — both halvesBoth are on
FILTERS = {
'images': ['*.jpg', '*.png', '*.gif'],
'today': {'label': 'Modified today', 'match': lambda e: e.mtime >= start_of_today()},
'big': {'label': 'Over 100 MB', 'match': lambda e: e.size > 100 << 20},
}Each becomes a fixed row in the What that settlesOpen question 5 — which registry goes first. Sort keys, and it was the right call for the reason §5 guessed: a key returns data, so the machinery could be shaken down without committing to a PuiKit type crossing the façade. Filters then cost almost nothing — same Open question 4 — may a registry implementation run off the UI thread. Yes, and it did have to be answered with the first registry rather than after. A sort key runs on a worker ( "Build one mechanism, not ten The one question filters had of their ownA filter is stored where a typed pattern is stored. Two smaller decisions, both worth reusing:
Still openEverything in §3 and §4 — Of the rest of §5's table, archive handlers look like the next cheap one, and the suffix table living in two places makes it a tidy-up regardless. Path schemes still go last. |
ShippedPuiKit 1.7.0 (crftwr/puikit#144) and #452. Stages 1–3 are done; stage 4 is out of tree as planned, and now reachable in a few lines of config. The parts worth recording are the ones where building it disagreed with the write-up. The open questions1. Straight-alpha RGBA8? Yes, as leaned. Pillow's One thing this decided that the question did not ask: because the contract is straight alpha, macOS cannot use a 2. Where is the line for "core" formats? The proposal was a fixed set — PNG/JPEG/GIF/BMP/TIFF/WebP/ICO, the ones all three backends reliably read. There is no such set, and asking for one was the mistake. That turned out to matter beyond the fast lane. The claim list — what 3. The LGPL question that motivated the lean never had to be answered, because the two platforms that ship the DMG and the MSIX read HEIC natively. Linux is where the plugin is the answer, and Linux has no installer to attribute anything in. 4 and 5 were settled by the earlier comment; SORT_KEYS and FILTERS proved both. Where §4 was wrong
This did not happen, and would not have. On a terminal The TUI did get faster, by a different route, and only after profiling said where the time was. Per step on a 12-megapixel JPEG:
The resample dominates. The decode is the part that looks expensive. Caching decoded sources by One near-miss worth keeping: folding the crop into A gap the write-up did not see
The general lesson, for the rest of §5's table: exposing a registry is not the same as knowing when to use it. §5's bet held"Build one mechanism, not ten Two decisions that did not carry over from the earlier registries, both confirming §8c's point that these are decided per registry rather than inherited:
And one shape worth reusing: registering also widens what the feature applies to. WindowsBoth COM paths were written on macOS and could not be run there. Verified on Windows 11 build 26200: Two findings from that run:
Left open
Closing — #357 asked for HEIC, AVIF, SVG, RAW and DCM "or some way to extend it", and all five are answerable now: three from the machine's own decoder, and the rest in a few lines of |
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Prompted by #357: "Could the Image Viewer support HEIC, AVIF, SVG? RAW and DCM would be nice too. Or some way to extend it."
The formats fall into clearly separate tiers of difficulty. But whichever ones we add, PuiKit has to be able to draw in-memory raster data before any of it makes sense. With a
draw_imagethat only accepts a file path, an image the app decoded itself has to be written back out as a PNG just to be handed over — a round trip that buys nothing.1. How it works today
The gate is
IMAGE_SUFFIXESinxefm/image_viewer.py:58, but the actual decoding is a different piece of code on every backend:puikit/backends/macos_backend.py:3695puikit/backends/windows_backend.py:3008puikit/backends/_terminal_graphics.py:228Two things follow from that, and both bite.
No size, no picture.
xefm/image_viewer.py:231resolves_sizefrom PuiKit's header parse (PNG/GIF/BMP/JPEG only), falling back to Pillow, and_can_render(xefm/image_viewer.py:356) drops to the metadata card when_size is None. Adding an extension to the list does not by itself make anything render.A backend that cannot decode has nothing to fall back to. macOS's
_render_imagesimply returns when NSImage hands backNone. On Windows, AVIF and HEIC need a Microsoft Store extension (AV1 Video Extension / HEVC Video Extensions) before WIC can read them at all; without it, nothing is drawn.2. What I measured
Verified against Pillow 12.3.0 on macOS.
public.avifpillow-heifgrafts it onpublic.heicorg.nema.dicomSupporting findings:
pillow-12.3.0-cp312-cp312-win_amd64.whland it carriesPIL/_avif.cp312-win_amd64.pyd. Putting a>=11.3floor onpillowinrequirements.txtturns it on everywhere..avif,.jp2,.j2k,.psd,.qoi,.icns,.dds,.cur,.apng,.pcx,.sgi,.xpm, …). Not all of them belong there —.mpg,.ps,.h5,.gribneed Ghostscript or extra plugins to actually decode — so this wants a curated subset rather than the whole registry.pillow-heif1.5.0 is BSD-3-Clause, Python 3.10+, with wheels for macOS, Windows and Linux. It drops in cleanly as an optional dependency. (Its wheels bundle libheif / libde265, which are LGPL, so the DMG / MSIX would need the attribution checked.)3. PuiKit first: draw raster data straight from memory
Writing decoded pixels back out as a PNG so they can be passed as a path is waste. The fix is to let
draw_imagetake the decoded pixels themselves.The groundwork is already there
This design was anticipated in the code. The docstring of
source_key()inpuikit/backends/_terminal_graphics.py:166describes exactly this case:The key shape is already
("raster", identity, revision), and the contract — a raster source names itself through acache_key— is already settled. Both VT caches (vt_backend.py:618and:698) go through it, so the TUI caching layer accepts a raster source with no change at all.Windows already runs on raw pixels.
rt_create_bitmap_from_pixelsreads the pixels itself —wic_copy_pixels_bgra→_premultiply_bgra→CreateBitmap— rather than handing D2D the WIC source. The "bytes → ID2D1Bitmap" half already exists; what is needed is a branch that skips WIC.The change
A
RasterImageinpuikit/image.py—width,height, a packed RGBA8 straight-alpha buffer (stride =w * 4), andcache_key = (identity, revision), bumping the revision when written to. That is precisely the contractsource_key()already documents.Signatures widen to
str | RasterImage—Backend.draw_image/Backend.image_size(puikit/backend.py:692,695),Panel.draw_image,DrawContext.draw_image,LayoutContext.measure_image,ImageView. It is the first argument in every case, so nothing breaks.image_sizereturns(w, h)directly for a raster (thepuikit.image.image_sizeLRU stays path-only and untouched).Per backend:
_render_image(macos_backend.py:3692) building an NSImage fromNSBitmapImageRep.initWithBitmapDataPlanes_.... Re-key the cache from path tosource_key_get_image(windows_backend.py:3000), skip WIC for a raster. Splitrt_create_bitmap_from_pixelsinto "read from WIC" and "build from a BGRA buffer". The RGBA→BGRA swizzle and the premultiply are existing code_terminal_graphics.render(), branchImage.open(path)toImage.frombuffer("RGBA", (w, h), data). The caches need nothing, thanks tosource_key_ensure_image(web_backend.py:778) onsource_keyTwo caveats
The macOS and Windows
_image_cachedicts are path-keyed and unbounded. A 24MP image held as RGBA is ~100MB, so admitting rasters means a byte-budgeted LRU. (The VT side already has a bound.)It is not literally zero-copy. Both ctypes and pyobjc copy the buffer once. That is still an order of magnitude cheaper than a PNG encode/decode round trip.
4. XeFM next: a decoder registration point
Once PuiKit can take pixels, the XeFM side gets thinner:
PIL.Image→RasterImage(im.width, im.height, im.convert("RGBA").tobytes()).BytesIOdirectly, so materializing S3 / SFTP / in-archive files to a temp file stops being necessary (xefm/image_viewer.py:200-236,_resolve). Paths stay as the fast lane for the formats a backend decodes natively.srccrop is normalized, so a re-crop reuses the decoded pixels — no re-decode. That makes the TUI faster than today, where every zoom step re-runsImage.open+load()(_terminal_graphics.py:228).On top of that, the direct answer to the last line of the issue — a way to extend it — is one decoder registration point:
Shaped exactly like the
RichRendererregistration inxefm/viewer_registry.py, and reachable from~/.xefm/config.py(within the API_VERSION 0 frame ofxefm/user_api.py). The defaults register what Pillow can read, and HEIC appears on its own whenpillow-heifis installed. SVG / RAW / DICOM then become "install cairosvg / rawpy / pydicom and write a few lines", and XeFM's own dependency list stays light.5. The same shape elsewhere
Image decoders are not a special case. XeFM already has three shapes of config-side extension —
THEMES(a name → colour-overrides table,xefm/_config.py:183),FILE_ASSOCIATIONS(a pattern → command table), andACTIONS/EVENT_HOOKS(a name → callable table). The one shape missing is pluggable formats, and once that exists it fits a lot more than images:IMAGE_SUFFIXES(xefm/image_viewer.py:58)(bytes|path) -> PIL.Imagexefm/app.py:4520-4525and a construction branch atxefm/archive.py:1270-1280— two placesstartswithchain atxefm/path.py:1077-1110PathImplsubclassxefm/file_list_manager.py:331-360(EntryInfo) -> sortable(EntryInfo) -> strxefm/search_match.py:48,xefm/text_viewer.py:1124)(pattern, text) -> spanscompute_previewis regex-only (xefm/batch_rename_dialog.py:46)(name, index, EntryInfo) -> str(a, b) -> boolxefm/text_encoding.py:63(bytes) -> encoding nameCOLOR_SCHEMES(xefm/colors.py:90)THEMESThree stand out.
Archive formats. 7z and rar are the perennial request in any file manager, and this would let anyone with
py7zr/rarfileinstalled add them in a few lines. The suffix table living in two places today means the registry is a tidy-up in its own right. Unlike an image decoder, the contract is a class (a 4–6 method ABC) rather than a single callable.Path schemes. The biggest lever of the lot — WebDAV, FTP, Google Drive, MTP, rclone. And
PathImplis already an ABC (xefm/path.py:92); only the constructor dispatch is hardcoded. I would still do this last: exposing it turns a large ABC into a public API that has to stay stable.Sort keys / filter predicates.
doc/dev/CUSTOMIZATION_API_DESIGN.mdalready names these as fitting the same pattern "once requested". Smallest possible contract, andEntryInfois a typexefm/user_api.pyalready owns, so the façade stays intact. Nearly free.Two cross-cutting decisions
Build one mechanism, not ten
register_*functions. Step 4 of the customization roadmap ("Exposed registries") should be implemented as machinery rather than as the singleVIEWER_RENDERERSvariable it is written up as:IMAGE_DECODERS,ARCHIVE_HANDLERS,SORT_KEYS,COLUMNS,PATH_SCHEMESall riding the same validation, the same warnings and the same reload path (_process_user_entriesandrun_guardedinxefm/user_api.py). Last-registration-wins and failure isolation both already exist.Settle the threading contract before the first registry ships.
xefm/user_api.pypromises today that actions and hooks run synchronously on the UI thread, with no background helper on purpose. But everything in the table above is the slow work: a RAW decode takes seconds, so does listing an archive or a WebDAV directory.xefm/dir_scan.pyalready runs on a worker; the image viewer's_resolvedoes not. "A registry implementation may be called from a worker thread and must not touch the UI" is a promise that has to be made with the first one, because it cannot be added later.One consequence worth noting: nearly everything in that table returns data, so the façade holds.
VIEWER_RENDERERS— which returns a PuiKit widget — is the sole exception, and the design doc accepts it as such. Which means the item written up as the first of step 4 is arguably the one to do last: an image decoder or a sort key makes a much cleaner first customer for the machinery.6. Stages and effort
RasterImage+ 5 backends + bounding the caches + testspillow-heifas an optional import →register_heif_opener()when presentThe two GUI backends in stage 1 need hands-on verification, and Windows means waiting for the Windows machine as usual.
Why stage 4 stays out of tree:
ID2D1SvgDocumenton Windows._resolveis synchronous on the UI thread today, so it comes bundled with making that async. Pulling the embedded JPEG preview is the practical route.7. What works today
FILE_ASSOCIATIONScan already hand.heicand friends to an external viewer (xefm/_config.py:716) — enough to get by until the above lands:{ 'pattern': ['*.heic', '*.dng'], 'open|view': ['open', '-a', 'Preview'], # on Windows: ['start', ''] },Open questions
RasterImagefix its pixel format as straight-alpha RGBA8? Windows wants premultiplied BGRA, so that costs a swizzle every time — but it lets Pillow'stobytes()pass through untouched, which is the commoner case.pillow-heifoptional or hard? I lean optional, given the DMG / MSIX size and the LGPL attribution it carries.All reactions