feat: extract guiltty-sprite crate, region-scoped footprint staleness - #38
Conversation
Implements PR 1/2 of docs/design/sprite-crate-extraction.md: move Sprite/Bitmap out of guiltty-core into a new guiltty-sprite crate, replacing the inherent Canvas::draw_sprite with sprite.draw_on(&mut canvas) -- a breaking public-API change, no compatibility shim (pre-1.0). guiltty-core gains Canvas::id() and Canvas::region_version(Rect), backed by a per-tile version grid bumped by every pixel-mutating call (set_pixel, draw_shape, draw_text). guiltty-sprite's Sprite exposes draw_on/clear_footprint/place/discard_footprint: clear_footprint returns Err(StaleFootprint) (canvas left untouched) if anything wrote into its footprint's own region since capture, rather than silently restoring stale pixels -- the design's "Footprint staleness" fix. discard_footprint recovers a footprint that's gone permanently stale (the version counter only increases, so a bare retry can never succeed) without attempting a restore. Sprite/Bitmap/draw_sprite's existing tests moved to guiltty-sprite as draw_on tests; added coverage for the two bugs the design doc's first pass got wrong (stamp-after-blit self-invalidation, canvas-wide invalidation breaking disjoint sprites) plus the wrong-canvas no-op and discard_footprint recovery paths. Co-Authored-By: WOZCODE <contact@withwoz.com>
|
Caution The consumer version of Gemini Code Assist on GitHub has been sunset. All code review activity has officially ceased. |
There was a problem hiding this comment.
rsenna has reached the 50-credit limit for trial accounts. To continue receiving code reviews, upgrade your plan.
Reviewer's GuideExtracts Bitmap/Sprite logic from guiltty-core into a new guiltty-sprite crate, introduces region-scoped canvas version tracking to detect stale sprite footprints, and updates the public API and demo to use Sprite::draw_on with staleness handling. File-Level Changes
Tips and commandsInteracting with Sourcery
Customizing Your ExperienceAccess your dashboard to:
Getting Help
|
📝 WalkthroughWalkthroughThe change extracts bitmap and sprite APIs into ChangesSprite extraction and rendering
Estimated code review effort: 4 (Complex) | ~45 minutes Possibly related PRs
Suggested reviewers: Sequence Diagram(s)sequenceDiagram
participant Sprite
participant Bitmap
participant Canvas
Sprite->>Bitmap: Read pixel data
Sprite->>Canvas: Save footprint and draw opaque pixels
Canvas-->>Sprite: Return region version
Sprite->>Canvas: Validate footprint before clearing
Canvas-->>Sprite: Restore pixels or return StaleFootprint
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches 💡 1📝 Generate docstrings 💡
🧪 Generate unit tests (beta)
Comment |
There was a problem hiding this comment.
Hey - I've found 1 issue, and left some high level feedback:
- In
Canvas::draw_text,text.chars().count()is used solely for computing the bounding box; consider caching the iterator length or using a more efficient metric to avoid iterating the string twice (once for the bbox and again in the drawing loop). - In
Sprite::place, you always iterate over coordinates that are already clipped to the canvas bounds, socanvas.pixel(...).unwrap_or_default()andBitmap::pixelchecks add unnecessary overhead; you could use direct indexing into the underlying buffers (or a dedicated fast path onCanvas) to reduce per-pixel cost for sprite blitting.
Prompt for AI Agents
Please address the comments from this code review:
## Overall Comments
- In `Canvas::draw_text`, `text.chars().count()` is used solely for computing the bounding box; consider caching the iterator length or using a more efficient metric to avoid iterating the string twice (once for the bbox and again in the drawing loop).
- In `Sprite::place`, you always iterate over coordinates that are already clipped to the canvas bounds, so `canvas.pixel(...).unwrap_or_default()` and `Bitmap::pixel` checks add unnecessary overhead; you could use direct indexing into the underlying buffers (or a dedicated fast path on `Canvas`) to reduce per-pixel cost for sprite blitting.
## Individual Comments
### Comment 1
<location path="crates/guiltty-sprite/src/lib.rs" line_range="271-272" />
<code_context>
+ for y in y_lo..y_hi {
+ for x in x_lo..x_hi {
+ let (cx, cy) = (x as u32, y as u32);
+ saved.push(canvas.pixel(cx, cy).unwrap_or_default());
+ let (bx, by) = ((x - px) as u32, (y - py) as u32);
+ if let Some(color) = self.bitmap.pixel(bx, by) {
</code_context>
<issue_to_address>
**suggestion (bug_risk):** Capturing footprint pixels via `unwrap_or_default` may silently hide canvas bugs.
In `Sprite::place`, `(cx, cy)` is already clipped to canvas bounds, so `canvas.pixel(cx, cy)` should never be `None` if `Canvas` is correct. Using `unwrap_or_default` hides violations of this invariant by recording a default color instead of failing fast, making footprint issues harder to debug. Prefer `unwrap()` or `expect("canvas pixel out of bounds")` to catch such bugs early while keeping the happy-path behavior unchanged.
```suggestion
let (cx, cy) = (x as u32, y as u32);
saved.push(
canvas
.pixel(cx, cy)
.expect("canvas pixel out of bounds in Sprite::place"),
);
```
</issue_to_address>Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.
Up to standards ✅🟢 Issues
|
| Metric | Results |
|---|---|
| Complexity | 41 |
| Duplication | 0 |
AI Reviewer: first review requested successfully. AI can make mistakes. Always validate suggestions.
TIP This summary will be updated as you push new changes.
PR Summary by QodoExtract guiltty-sprite crate and add region-scoped footprint staleness checks
AI Description
Diagram
High-Level Assessment
Files changed (13)
|
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 72eb040185
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
There was a problem hiding this comment.
Pull Request Overview
The extraction of sprite and bitmap logic into the guiltty-sprite crate is architecturally sound and fulfills the defined acceptance criteria. The implementation of Canvas::region_version successfully enables detection of stale sprite footprints through a per-tile versioning grid. Codacy analysis indicates the project remains up to standards, and all required test scenarios were addressed.
However, a critical performance regression exists in the set_pixel implementation. Currently, every individual pixel write triggers a touch_region call, leading to redundant tile index calculations and version increments. This affects both internal shape drawing in guiltty-core and sprite blitting in guiltty-sprite. These issues should be addressed by introducing bulk-write or internal-write methods that bypass the versioning logic for individual pixels while updating the version once per operation.
Test suggestions
- Verify bitmap creation, loading, and pixel retrieval (ported tests)
- Sprite movement correctly restores old background and blits at new position
- Sprite::clear_footprint returns StaleFootprint error after an intervening pixel write
- Disjoint writes (in separate grid tiles) do not cause spurious footprint invalidation
- Sprite avoids self-invalidation by capturing version after its own blit
- Discarding a footprint allows a sprite to be redrawn after becoming stale
- Clearing a footprint on a different canvas instance is a safe no-op
- Pixel-mutating shape drawing correctly triggers footprint staleness
TIP Improve review quality by adding custom instructions
TIP How was this review? Give us feedback
There was a problem hiding this comment.
🧹 Nitpick comments (2)
crates/guiltty-sprite/src/lib.rs (1)
225-242: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueOptional: remove the
expectby taking the footprint up front.
clear_footprintborrowslast_draw, runs the checks, then re-takes the same value and asserts it isSome. You can take it once and put it back on the stale path. That removes theexpectand the second lookup.♻️ Proposed refactor
pub fn clear_footprint(&mut self, canvas: &mut Canvas) -> Result<(), StaleFootprint> { - let Some(footprint) = self.last_draw.as_ref() else { + let Some(footprint) = self.last_draw.take() else { return Ok(()); }; if footprint.canvas_id != canvas.id() { - self.last_draw = None; return Ok(()); } if canvas.region_version(footprint.rect) != footprint.version { + self.last_draw = Some(footprint); // keep it for `discard_footprint` return Err(StaleFootprint); } - let footprint = self - .last_draw - .take() - .expect("checked Some above, and canvas_id/region_version both matched"); Self::restore_footprint(canvas, &footprint); Ok(()) }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@crates/guiltty-sprite/src/lib.rs` around lines 225 - 242, Refactor clear_footprint to take last_draw at the start instead of borrowing it and calling take later. Restore the footprint to last_draw before returning Err(StaleFootprint), while preserving the existing canvas-id mismatch behavior and successful restoration flow, and remove the expect assertion.crates/guiltty-core/src/lib.rs (1)
214-214: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick winAvoid per-pixel version stamping inside the internal fill loops.
set_pixelnow callstouch_regionfor every single pixel. The internal rasterizers (fill_clipped_rect,fill_ellipse,fill_triangle,fill_polygon_even_odd,stroke_line,draw_glyph) all write throughset_pixel, anddraw_shape/draw_textalready stamp the whole bounding region before rendering. Each inner-loop pixel therefore pays atile_rangecomputation, anext_versionbump, and a tile-grid write that the bounding-region stamp already covers.Correctness is unaffected, because versions only increase. The cost is a constant-factor overhead on every rasterizer inner loop.
Consider a private untracked writer used by the internal fills, keeping the public
set_pixeltracked.♻️ Sketch of an untracked internal writer
pub fn set_pixel(&mut self, x: u32, y: u32, color: Color) { if let Some(i) = self.index(x, y) { self.pixels[i] = color; self.touch_region(Rect::new(x as i32, y as i32, 1, 1)); } } + + /// Writes one pixel without stamping the version grid. Only for callers that + /// already stamped their whole bounding region (`draw_shape`, `draw_text`). + fn set_pixel_untracked(&mut self, x: u32, y: u32, color: Color) { + if let Some(i) = self.index(x, y) { + self.pixels[i] = color; + } + }Then switch the internal fill/stroke/glyph loops to
set_pixel_untracked.Also applies to: 254-265
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@crates/guiltty-core/src/lib.rs` at line 214, Introduce a private untracked pixel writer alongside set_pixel that performs the same pixel update without calling touch_region, while preserving set_pixel’s public version-tracking behavior. Update fill_clipped_rect, fill_ellipse, fill_triangle, fill_polygon_even_odd, stroke_line, and draw_glyph to use the untracked writer in their inner loops; keep draw_shape and draw_text bounding-region stamping unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Nitpick comments:
In `@crates/guiltty-core/src/lib.rs`:
- Line 214: Introduce a private untracked pixel writer alongside set_pixel that
performs the same pixel update without calling touch_region, while preserving
set_pixel’s public version-tracking behavior. Update fill_clipped_rect,
fill_ellipse, fill_triangle, fill_polygon_even_odd, stroke_line, and draw_glyph
to use the untracked writer in their inner loops; keep draw_shape and draw_text
bounding-region stamping unchanged.
In `@crates/guiltty-sprite/src/lib.rs`:
- Around line 225-242: Refactor clear_footprint to take last_draw at the start
instead of borrowing it and calling take later. Restore the footprint to
last_draw before returning Err(StaleFootprint), while preserving the existing
canvas-id mismatch behavior and successful restoration flow, and remove the
expect assertion.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: 99678332-7910-4e51-b739-f37f001135ba
⛔ Files ignored due to path filters (5)
Cargo.lockis excluded by!**/*.lockcrates/guiltty-sprite/tests/fixtures/grayscale_2x2.pngis excluded by!**/*.pngcrates/guiltty-sprite/tests/fixtures/malformed.pngis excluded by!**/*.pngcrates/guiltty-sprite/tests/fixtures/rgb_2x2.pngis excluded by!**/*.pngcrates/guiltty-sprite/tests/fixtures/rgba_2x2.pngis excluded by!**/*.png
📒 Files selected for processing (8)
Cargo.tomlcrates/guiltty-core/Cargo.tomlcrates/guiltty-core/src/lib.rscrates/guiltty-sprite/Cargo.tomlcrates/guiltty-sprite/src/lib.rscrates/guiltty/Cargo.tomlcrates/guiltty/src/lib.rsexamples/src/bin/demo.rs
Code Review by Qodo
1.
|
There was a problem hiding this comment.
4 issues found across 13 files
Confidence score: 2/5
- In
crates/guiltty-core/src/lib.rs, version tracking drops valid writes when coordinates exceedi32::MAX, so sprite footprints can look up-to-date and later restore stale pixels over newer drawing — keep tracking inu32space for canvas coordinates. - In
crates/guiltty-core/src/lib.rs, zero-width/zero-height canvases can trigger hugetile_versionsallocation because a zero dimension is coerced to one tile, creating an OOM risk from valid input — preserve zero tile counts for zero canvas dimensions. - In
crates/guiltty-core/src/lib.rs, large text plus maximal scale can overflow region tracking before clipping, causing debug panics and release-bound wrapping that can corrupt dirty-region behavior — switch these calculations to checked/saturating arithmetic. - In
crates/guiltty-core/src/lib.rs, no-op draw calls (blank text, zero-radius shapes, unsupported text) still invalidate sprite footprints due to unconditional pre-stamp version changes, which can cause unnecessary or incorrect restores — only bump versions when pixels are actually written.
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="crates/guiltty-core/src/lib.rs">
<violation number="1" location="crates/guiltty-core/src/lib.rs:163">
P2: Constructing a valid zero-height or zero-width canvas can allocate an enormous `tile_versions` buffer and OOM because the zero dimension is forced to one tile. Keep each tile count at zero when its corresponding canvas dimension is zero.</violation>
<violation number="2" location="crates/guiltty-core/src/lib.rs:214">
P1: Version tracking silently misses valid canvas writes above `i32::MAX`, allowing a sprite footprint there to remain apparently fresh and restore stale pixels over intervening drawing. Track the `u32` coordinates directly (or add a u32-coordinate touch helper) instead of converting them to `Rect`'s signed coordinates.</violation>
<violation number="3" location="crates/guiltty-core/src/lib.rs:383">
P2: A sufficiently long string with a maximal scale can overflow this tracking calculation in debug builds (and wrap its region bounds in release) before clipping stops at the canvas edge. Use checked or saturating conversion/multiplication and bounds addition for the tracking rectangle.</violation>
<violation number="4" location="crates/guiltty-core/src/lib.rs:385">
P2: Blank/unsupported text, zero-radius shapes, and other no-op drawing calls can spuriously invalidate a sprite footprint because the unconditional pre-stamp runs without any pixel write. Base version changes on actual writes or guard no-op primitives so `clear_footprint` fails only after a canvas mutation.</violation>
</file>
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
Four real bugs found by review bots in the initial version: - Canvas::new forced tile counts to >=1 per axis, so a canvas with one zero dimension (e.g. Canvas::new(0, u32::MAX)) still allocated a huge tile_versions buffer despite having zero actual pixels -- potential OOM. Tile count is now 0 when the corresponding dimension is 0. - set_pixel touched the region-version grid via a Rect built from u32 coordinates cast to i32 -- silently wraps negative for a canvas wide enough to have valid coordinates past i32::MAX, touching the wrong tile (or none) and letting a footprint appear falsely fresh. Added touch_pixel(x: u32, y: u32), computed directly in u32/i64, no Rect. - draw_shape/draw_text already touch their whole bounding region once up front, but their internal per-pixel helpers all called the public (touching) set_pixel too -- redundant tile-index recomputation and version bumps once per pixel instead of once per call. Split set_pixel into the public touching version and a private set_pixel_raw (write-only), used by stroke_line/fill_*. - draw_text's bbox arithmetic (advance * char count, origin + width) used unchecked i64 math that could overflow for extreme scale/length combinations; switched to saturating_mul/saturating_add since this bbox only feeds touch_region, never pixel addressing. Also: Sprite::place now expects (not unwrap_or_default) its background capture, since an out-of-bounds read there would mean a real Canvas bug, not a state to paper over silently. clear_footprint refactored to take last_draw up front and restore it only on the error path, instead of borrowing then taking late with an unreachable expect(). Path bbox combined from four O(n) passes into one. Added a regression test for the zero-dimension allocation fix. Independently re-verified via pr-review-toolkit:review-pr before pushing -- no further issues found. Co-Authored-By: WOZCODE <contact@withwoz.com>
Not changing either of these -- both are the same accepted tradeoff, just in two places:
(The |
Fixed in 6dae95a, matching this shape closely: |
Summary
Implements task 1/2 of
docs/design/sprite-crate-extraction.md:Sprite/Bitmapout ofguiltty-coreinto a newguiltty-spritecrate.Canvas::draw_spriteis gone; the replacement issprite.draw_on(&mut canvas). This is a breaking public-API change (pre-1.0, no compatibility shim, matching the project's existing precedent for this kind of change) —guiltty's facade re-exportsBitmap/Spritefrom the new location soguiltty::Spriteetc. keep working, and the one in-repo call site (examples/src/bin/demo.rs) is updated.guiltty-coregainsCanvas::id()andCanvas::region_version(Rect), backed by a per-tile version grid bumped by every pixel-mutating call (set_pixel,draw_shape,draw_text).Spriteexposesdraw_on/clear_footprint/place/discard_footprint.clear_footprintnow returnsErr(StaleFootprint)(canvas left untouched) if anything wrote into its footprint's own region since it was captured, instead of silently restoring stale pixels over newer drawing — the bug the design doc's "Footprint staleness" section exists to fix.discard_footprintrecovers a footprint that's gone permanently stale (the version counter only increases, so a bare retry can never succeed) by dropping it without attempting a restore.Test plan
Canvas::draw_sprite's existing tests (transparency, clipping, same-canvas move, cross-canvas) ported toguiltty-spriteasdraw_on_*testsErrdiscard_footprintrecovery path after a permanently-stale footprintcargo fmt --all -- --check,cargo clippy --workspace --all-targets -- -D warnings,cargo test --workspaceall passcargo llvm-cov --workspace --fail-under-lines 90: 92.92% overall (guiltty-sprite: 97.32% lines)🧙 Built with WOZCODE
Summary by Sourcery
Extract sprite and bitmap functionality from guiltty-core into a dedicated guiltty-sprite crate, introduce region-scoped canvas version tracking to detect and avoid restoring stale sprite footprints, and update the public facade and example to use the new Sprite::draw_on-based API.
New Features:
Enhancements:
Tests:
Summary by cubic
Extracted
guiltty-spritewithBitmap/Sprite, and added region-scoped versioning inguiltty-coreto detect and prevent restoring stale pixels. Also fixed tile-grid correctness and performance issues found in review.New Features
guiltty-spritecrate withSprite::draw_on/clear_footprint/place/discard_footprintandStaleFootprint.guiltty-core:Canvas::id()andCanvas::region_version(Rect)using per-tile counters; shape/text drawing participate in versioning.guilttyfacade re-exportsBitmap,Sprite,StaleFootprint; tests moved and expanded.canvas.draw_sprite(&mut sprite)withsprite.draw_on(&mut canvas)and handleResult<(), StaleFootprint>.Bug Fixes
touch_pixel(noi32wrap) andset_pixel_rawso draws bump the grid once per call, not per pixel.draw_textuses saturating math; path bbox computed in one pass.clear_footprintleaves state untouched on staleness;placevalidates background capture.Written for commit 6dae95a. Summary will update on new commits.