tui: #132 inpaint canvas (keyboard mask editor) #152
No reviewers
Labels
No labels
area:agents
area:ai
area:config
area:dashboard
area:design
area:design-review
area:devtools
area:entities
area:gallery
area:generate
area:image
area:infra
area:meta
area:model-browser
area:navigation
area:presets
area:security
area:sessions
area:settings
area:sharing
area:test
area:ux
area:webhook
area:workdir
type:bug
type:chore
type:meta
type:user-story
No milestone
No project
No assignees
2 participants
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
charles/loom!152
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "tui/inpaint-canvas"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Closes charles/loom#132
Replaces the ~150-line
components/inpaint.rsstub with a full-screenkeyboard-driven mask editor. Opens from the Generate screen via
ewhenin Inpaint mode with an init image already selected.
Keybindings
e(on Generate)h/j/k/l+ arrowsmax(1, radius/2)pxSpacexc[/]Entercache_dir/masksand closeEscImplementation
InpaintCanvasOverlaycomposes a ratatui-image base layer (via theshared picker, same as
ModelDetailOverlay) with a half-block redmask layer overlaid on top.
Maskowns a rawVec<u8>sized to the init image; stamps mutate inplace (no per-frame alloc).
Enter, the mask is exported as an 8-bit grayscale PNG and aAppAction::SetMaskPathdispatches the path intoGenerateScreen.params.mask_image_path.OverlayKind::InpaintCanvas { init_path }andAppAction::SetMaskPathare the two new variants in
app.rs.Test plan
cargo test -p loom-tui— 175 tests pass (8 new unit tests undercomponents::inpaint::testscovering stamp math, cursor clamping,stride, brush clamping, PNG round-trip, and file export).
cargo fmt --allclean.cargo clippy -p loom-tui --libclean (pre-existing errors inscreens/settings.rstests unchanged).just run-tui, switch to Generate → mode 3 (Inpaint),set an init image via the preview
rshortcut on a completed job,press
e, paint with Space, confirm with Enter, then submit aninpaint generation and verify the mask is respected.
🤖 Generated with Claude Code
Nicely structured —
Maskis test-first and the PNG export path is covered end-to-end. Some perf and hygiene notes:Perf —
render_mask_layersample_cellcallsregion_hitwhich scans every mask pixel inside the cell's bounding box. For a 1024×1024 init image rendered into an ~80×40 terminal, each cell covers ~12×50 mask pixels, and the mask overlay runs this for every cell every frame the mask is dirty. For a 2048×2048 init image it's ~25×100 = 2,500 reads × 3,200 cells = 8M reads per frame. Plusdraw_cursor_ringwith 64 samples is fine.Short-term: early-out by tracking a dirty bounding box (min/max stamp extents) and only re-scanning cells that intersect it. Medium-term: pre-compute a downsampled "cell-is-masked" bitmap when the mask changes, not on every render.
Correctness
write_to_dirgenerates filenames by nanosecond-since-epoch. Two confirmations within the same nanosecond collide — not plausible on desktop but deserves auuidor a counter for safety.cache_dir/masks/mask-{ts}.pngand never cleaned up. Add a cleanup pass on startup, or write totempfile::NamedTempFileand let the OS reclaim.InpaintCanvasOverlay::handlereturnstrueon any modifier-less key even when unmatched (_ => true). That swallows Ctrl+C / Ctrl+L etc. Restrict the catch-all to the keys you actually consume.ensure_image_loadedsilently drops all errors (picker missing, lock poisoned). At minimumwarnwith the reason so a user looking at the placeholder has something to go on.adjust_brushrange on[: ifradius == 1pressing[should stay at 1, not wrap — confirm via the existingadjust_brush_clamps_to_rangetest that the floor is 1.Nits
draw_cursor_ringusesstep_by((r/32).ceil() as usize)— forr ≤ 32that's 1, forr = 64it's 2; withsamples = 64andstep = 2you only draw 32 points around the ring. Fine, but thestep_by(step.max(1))inside afor i in (0..samples).step_by(...)already hasstep.max(1)applied above. Redundant.last_paint_atthrottles paint on Space but a user holding Space will still skip cells if the stride is large; consider interpolating a line of stamps between last paint and current cursor for smoother painting.Review feedback addressed in
1cfda25:Perf (
render_mask_layerO(N²))DirtyRectonMask;paint()unions stamp extent into it,clear()marks whole mask dirty.InpaintCanvasOverlaynow owns aCellCacheof per-cell(upper, lower)bits sized to the render area.render_mask_layerre-samples only cells intersecting the dirty bbox (via newdirty_to_cells), reuses the cache for the rest, and fast-paths to pure cache-paint when nothing is dirty. Resize invalidates the cache.dirty_bbox_scan_is_small_for_one_stamp_on_2k_mask: 2048×2048 mask, radius-16 stamp on a 120×40 terminal — worst-case mask-pixel reads stay under 10k (vs ~8M before).Real
handlecatch-all now returnsfalse; unrecognised modifier combos also returnfalseso Ctrl+C / Ctrl+L / global shortcuts propagate to the app.write_to_dirswitched touuid::Uuid::new_v4()— collision-safe.cleanup_stale_masks()run at startup frommain::run, prunes mask PNGs older than 24h incache_dir/masks/.ensure_image_loadednowtracing::warn!s when the picker is missing or its lock is poisoned.Nit
step.max(1)indraw_cursor_ring(step is already ≥1 fromceil(r/32).max(1)).cargo fmt --all,cargo build,cargo test -p loom-tui,cargo clippy -p loom-tui -- -D warningsall pass.