Skip to content

Phase 3: Pixel tools (brush, eraser, eyedropper, fill) and event routing#9

Merged
kevincarlson merged 17 commits into
mainfrom
claude/fix-dioxus-version-conflict-wE49E
May 20, 2026
Merged

Phase 3: Pixel tools (brush, eraser, eyedropper, fill) and event routing#9
kevincarlson merged 17 commits into
mainfrom
claude/fix-dioxus-version-conflict-wE49E

Conversation

@kevincarlson

Copy link
Copy Markdown
Member

Summary

Implements Phase 3 pixel tool pipeline: brush engine with pressure-sensitive sizing and sub-pixel interpolation, eraser (destination-out mode), eyedropper (color sampling), flood fill, and complete event routing from canvas to tool handlers. Refactors event handling in dioxus-native-dom to modularize by event type. Adds wgpu-context crate for surface and buffer rendering utilities.

Key Changes

New crates:

  • iris-tools: Brush, eraser, eyedropper, and flood-fill implementations with pressure support and tile-based dirty tracking
  • wgpu-context: Extracted wgpu surface/buffer management (SurfaceRenderer, BufferRenderer, DeviceHandle)

Tool implementations:

  • BrushEngine: Hard round brush with pressure-sensitive diameter, sub-pixel spacing interpolation, and f16 tile encoding
  • EraserEngine: Wrapper around BrushEngine with destination-out compositing
  • flood_fill(): BFS fill with Euclidean RGB tolerance and bounded expansion
  • sample_color(): Eyedropper via bottom-to-top Porter-Duff compositing of visible layers

Event routing:

  • tool_dispatch.rs: Routes ToolEvent to active pixel tool (brush, eraser, eyedropper, fill, marquee)
  • ToolEvent enum: Normalised down/move/up/scroll/pinch in document space with pressure
  • Canvas widget integration: Converts mouse/touch events to ToolEvent via viewport transform

Event handling refactor (dioxus-native-dom):

  • Split monolithic events.rs (348 lines) into modular submodules: form.rs, keyboard.rs, mouse.rs, touch.rs, wheel.rs
  • BlitzMountedData: Platform-side mounted element backing with post-layout border-box rect
  • make_pixels_rect(): Helper for PixelsRect construction from blitz layout
  • Deferred onmounted event dispatch until layout is computed

Canvas and compositor updates:

  • IrisCanvas: Added on_tool_event handler and shared size tracking for DPI-aware coordinate transforms
  • Compositor::render(): Accepts physical pixel dimensions and DPI scale; converts to logical for viewport transforms
  • paint_bridge.rs: Tracks rendered_size for event handler coordinate space consistency

UI state and dispatch:

  • AppState: Added PixelToolState (brush, eraser, fill_tolerance), Selection, PixelTool enum, foreground/background colors
  • tool_palette.rs: Refactored button styling; added pixel tool selection UI

Documentation:

  • docs/patches.md: Comprehensive guide to all vendored patches (blitz-dom, blitz-shell, dioxus-native-dom, wgpu-context) with removal conditions

Implementation Details

  • Pressure handling: Brush diameter scales linearly with pressure (0.0–1.0) when pressure_size is enabled; mouse always reports 1.0
  • Tile dirty tracking: All paint operations collect modified (layer_id, tile_coord) pairs for efficient invalidation
  • F16 encoding: Brush tiles store linear RGBA as f16 for 8 bytes/pixel; eyedropper decodes on-the-fly
  • DPI scaling: Viewport transforms use logical (CSS) pixels; pixel placement uses physical; scale factor passed through compositor
  • Touch synthesis: blitz-shell 0.2.3 synthesises touch as mouse events; single-contact forwarding only (multi-touch deferred to Phase 4)
  • Modifiers: All events carry keyboard_types::Modifiers for shift/ctrl/alt/meta state

All files follow CLAUDE.md rules: ≤300 lines, copyright headers, typed errors, no unsafe, mandatory TODO/COMPAT annotations.

https://claude.ai/code/session_012imcXHNWwg8q1TNRo1un9K

claude added 16 commits May 19, 2026 15:39
…dules

- Add HasPointerData impl to NativeClickData in dioxus-native-dom patch,
  synthesising a mouse-type primary pointer from the BlitzMouseButtonEvent.
  Fixes the unimplemented!() panic that would fire on any onpointerdown /
  onpointermove handler in Phase 3 tool input routing.

- Split the monolithic events.rs (386 lines) into events/{mod,mouse,keyboard,
  form,touch}.rs to respect the 300-line / 100-line mod.rs ceilings.

- Remove the fontique patch entry from workspace Cargo.toml; the patch
  declared version 0.8.0 but blitz-dom 0.2.4 resolves fontique 0.6.0,
  so the patch never applied and generated a spurious cargo warning.
  The build is clean without it; re-evaluate if fontconfig_sys linkage
  errors appear on CI Linux.

Which case applied: CASE B (HtmlEventConverter panics NOT fixed upstream).
The dioxus-native-dom patch IS applying (to 0.7.9 — dioxus 0.7.4 depends
on native-dom 0.7.9 internally). convert_mouse_data already worked;
convert_pointer_data now delegates to the same NativeClickData path.
No unused-patch warnings remain.

https://claude.ai/code/session_012imcXHNWwg8q1TNRo1un9K
… wiring

Implements SPEC.md §7 (tool system) and §11 (canvas interaction):

iris-canvas:
- Add tool_event.rs: ToolEvent enum (Down/Move/Up/Scroll/Pinch) +
  PointerButton; all positions in document space.
- Add on_tool_event: EventHandler<ToolEvent> prop to IrisCanvasProps.
- Wire onmousedown, onmousemove, onmouseup on the canvas element;
  each converts element_coordinates() → screen_to_doc() → ToolEvent.
- Add onwheel handler (correct logic; fires when blitz-shell routes
  MouseWheel to Dioxus — currently blitz 0.2.x consumes at CSS scroll
  level; see TODO comment in canvas_widget.rs §11.2).
- Ctrl+wheel zooms via viewport.zoom_to(); plain wheel pans viewport.pan.

iris-app:
- Add tool_dispatch.rs: dispatch_tool_event routes by ToolMode; pixel
  path logs Down/Move/Up/Scroll/Pinch with doc coordinates.
- Wire CanvasArea → IrisCanvas on_tool_event → dispatch_tool_event.

dioxus-native-dom patch:
- Add events/wheel.rs: NativeWheelData stub implementing HasWheelData;
  prevents convert_wheel_data from panicking when a future blitz version
  routes wheel events through Dioxus.

Scroll/pinch via wheel requires a blitz-shell patch to call
handle_ui_event for MouseWheel; tracked as TODO(iris) SPEC.md §11.2.
Mouse down/move/up events fire and dispatch correctly.

https://claude.ai/code/session_012imcXHNWwg8q1TNRo1un9K
…n panic

SurfaceError::Outdated returned from maybe_blit_and_present() causes an
unrecoverable panic on Windows/DX12 when use_wgpu registers a
CustomPaintSource during window init. The upstream author left a TODO at
surface_renderer.rs:228 indicating this was a known gap.

Fix: match on get_current_texture() result and return early (skip frame)
for Outdated and Lost; unknown errors still panic. The next redraw event
retries successfully.

- Copy wgpu_context-0.1.2 to crates/patches/wgpu-context/
- Add tracing dependency for the warn! log on skipped frames
- Register wgpu_context patch in workspace [patch.crates-io]
- Add docs/patches.md documenting all vendored patches

https://claude.ai/code/session_012imcXHNWwg8q1TNRo1un9K
anyrender_vello's blit pass samples the registered texture in a shader
(via vello::Renderer::register_texture), which requires both flags in
addition to the COPY_SRC already present. COPY_DST is retained for the
initial queue.write_texture upload.

https://claude.ai/code/session_012imcXHNWwg8q1TNRo1un9K
Blitz's custom paint canvas elements don't participate in the hit-test
tree for mouse events — only standard HTML elements receive pointer
events. Moving all event handlers to a wrapper div fixes the routing so
onmousedown/onmousemove/onmouseup reach the tool dispatch system.

COMPAT(blitz) comment documents the workaround. The inner <canvas>
element is now purely visual with no event handlers.

Verified with software Vulkan renderer: Down/Move/Up events fire with
correct doc-space coordinates. Canvas center = doc (0.0, 0.0).

https://claude.ai/code/session_012imcXHNWwg8q1TNRo1un9K
New crate iris-tools owns all pixel tool implementations.

BrushEngine (src/brush.rs):
- Hard round brush with pressure-sensitive size (size_px * pressure)
- Softness falloff: hardness 0.0–1.0 controls the feather zone
- Sub-pixel spacing interpolation between ToolEvent::Move positions
- Porter-Duff "over" compositing into f16 RGBA TileData
- erase_mode flag uses destination-out instead of over

EraserEngine (src/eraser.rs): wraps BrushEngine with erase_mode=true.

Wired into iris-app:
- PixelToolState added to AppState (brush + eraser engines)
- dispatch_pixel_tool calls on_down/on_move/on_up per ToolEvent
- canvas_dirty flag triggers tree_signal sync in canvas_area.rs so
  IrisCanvasPaintSource composites the painted tiles on next frame
- selected_layer initialised to Background layer in AppState::default
- Viewport pan initialised to (doc_w/2, doc_h/2) so doc (0,0) is at
  canvas top-left at zoom=1 (fixes brush landing in negative tile coords)

Fix: read-guard from match state.read().tool_mode was kept alive across
dispatch_pixel_tool causing AlreadyBorrowed panic on state.write();
resolved by binding tool_mode before the match.

Verified: brush strokes appear on canvas; 4 unit tests pass.

https://claude.ai/code/session_012imcXHNWwg8q1TNRo1un9K
- canvas_widget.rs: wrapper div fills parent (flex: 1; 100% w/h) instead
  of being fixed 800×600; reads rendered_size signal for screen_to_doc()
  so coordinate transforms use actual visible dimensions; adds #1e1e1e
  background (Change 2 + 4)
- canvas_area.rs: rename constants to DOC_WIDTH/DOC_HEIGHT to clarify
  these are document dimensions, not the rendered canvas element size;
  TODO(iris): SPEC.md §11.3 for onmounted measurement (Change 1)
- compositor/mod.rs: fill_document_background() draws an opaque white
  rect over the doc boundary before layer compositing so the document
  extent is visible against the dark canvas background (Change 3)

https://claude.ai/code/session_012imcXHNWwg8q1TNRo1un9K
canvas_area.rs: derive canvas_w/canvas_h from window size (1280×800
default) minus shell chrome constants (left=56, right=240, top=176,
bottom=24), pass them as IrisCanvas width/height props. These now
represent the rendered area, not the document size.

canvas_widget.rs: props.width/height IS the rendered size — removed
rendered_size signal, use props directly in screen_to_doc() and as
<canvas> element dimensions so Blitz calls render() with the correct
texture size. Document dimensions come from tree.canvas_width/height
in the compositor (already correct).

TODO(iris): SPEC.md §11.3 — replace hardcoded window size with a real
window-size hook once dioxus-native exposes one. Long-term fix is
implementing get_client_rect() in dioxus-native-dom via blitz_dom
Node::final_layout, tracked in canvas_widget.rs TODO comment.

https://claude.ai/code/session_012imcXHNWwg8q1TNRo1un9K
Adds real rendered-size measurement via Blitz's Taffy layout data,
eliminating hardcoded window dimensions for canvas coordinate transforms.

dioxus-native-dom/events/mod.rs:
  - BlitzMountedData { rect: PixelsRect } implements RenderedElementBacking
    with a get_client_rect() that returns the pre-captured Taffy layout rect
  - convert_mounted_data() downcasts PlatformEventData to BlitzMountedData
  - make_pixels_rect() helper builds a PixelsRect from f32 Taffy size fields

dioxus-native-dom/mutation_writer.rs:
  - DioxusState gains pending_mounted_ids: Vec<(ElementId, NodeId)>
  - create_event_listener() queues elements with onmounted handlers

dioxus-native-dom/dioxus_document.rs:
  - DioxusDocument gains deferred_mounted_ids
  - initial_build() drains pending → deferred (layout not yet run)
  - poll() fires deferred events (node.final_layout.size is now valid)
    before render_immediate, then drains new pending → deferred

iris-canvas/canvas_widget.rs:
  - rendered_size signal initialised from props (window-minus-chrome estimate)
  - onmounted handler spawns async task calling get_client_rect().await,
    updates rendered_size with actual element dimensions from Blitz layout
  - All event handlers (down/move/up/wheel) read rendered_size for
    screen_to_doc() coordinate transforms

https://claude.ai/code/session_012imcXHNWwg8q1TNRo1un9K
initial_build() no longer drains pending_mounted_ids → deferred.
Blitz's layout pass (resolve()) runs AFTER the first poll(), not
between initial_build() and poll(). Moving events to deferred in
initial_build() caused them to fire in poll() #1 before layout,
giving final_layout.size = (0, 0).

Correct sequence now:
  initial_build() → pending_mounted_ids accumulates events
  poll() #1 → fires deferred (empty) → render_immediate
              → drains pending → deferred
  resolve() → layout computed
  poll() #2 → fires deferred (final_layout.size is valid) ✓

Also expand onmounted debug log to show rect.origin alongside size,
confirming that width()/height() return size fields, not position.

https://claude.ai/code/session_012imcXHNWwg8q1TNRo1un9K
canvas_widget: logs client_x/y (page-relative) vs elem_x/y
(element-local after target_origin subtraction) plus rendered_size
and doc coords on onmousedown.

dioxus_document: logs raw/origin/element coords under the
tracing feature flag for each mouse event.

https://claude.ai/code/session_012imcXHNWwg8q1TNRo1un9K
…nted

The previous approach initialized rendered_size from a hardcoded
window-minus-chrome estimate (984×600) and updated it via onmounted.
Because onmounted fires async in poll #2, the first click (which can
arrive in poll #1) used the wrong size, causing a systematic offset
in screen_to_doc (wrong canvas center anchor).

IrisCanvasPaintSource::render() now writes the Blitz-reported
width/height into a shared Arc<Mutex<(u32,u32)>> before compositing.
Event handlers read from this shared size, which is guaranteed to be
correct (1144×633 or whatever the actual window is) by the time any
click can reach them, since Blitz paints before the window becomes
interactive.

onmounted is retained for diagnostic logging only.

https://claude.ai/code/session_012imcXHNWwg8q1TNRo1un9K
Root cause: blitz-paint passes PHYSICAL pixel dimensions to
CustomPaintSource::render() — content_box.width() = layout.size.width
* scale (confirmed in blitz-paint-0.2.1/src/render.rs:780). On a 2x
Retina display, width=2288, height=1266 while the correct logical CSS
size is 1144×633. Storing physical dimensions in shared_size caused
screen_to_doc() to use a wrong canvas center (1144,633) instead of
(572,316), creating a fixed 572px horizontal offset. On maximize the
physical dimensions grew further, making the offset proportionally
worse — exactly the "maximizing makes it more pronounced" symptom.

Fix: divide width/height by scale at the very top of render() before
any early returns, storing the logical CSS size. Also restored the
onmounted path as a belt-and-suspenders fallback (fires with logical
Taffy layout size in CSS pixels, same value).

https://claude.ai/code/session_012imcXHNWwg8q1TNRo1un9K
… in compositor

On HiDPI displays, blitz-paint passes physical pixel dimensions to
CustomPaintSource::render() (physical = logical × scale). The compositor
was calling doc_to_screen() with physical dims while the event handler
called screen_to_doc() with logical dims — producing mismatched center
anchors that caused the painted stroke to drift from the cursor by an
amount proportional to the distance from canvas center.

Fix: composite_to_texture() now takes a scale parameter, computes
logical_w/h from physical/scale, and passes logical dims to
doc_to_screen() / visible_doc_rect() for all viewport transforms.
Pixel placement then scales the resulting screen coordinates back to
physical pixel coordinates before writing to the accumulation buffer.

https://claude.ai/code/session_012imcXHNWwg8q1TNRo1un9K
Eyedropper (iris-tools/src/eyedropper.rs):
  sample_color() composites visible pixel layers bottom-to-top with
  Porter-Duff over to sample the colour at a document-space point.
  Wired to ToolEvent::Down with Eyedropper active; updates
  AppState.foreground_color and brush.settings.color.

Flood fill (iris-tools/src/fill.rs):
  flood_fill() BFS-fills a contiguous region of similar pixels.
  Tolerance is Euclidean RGB distance; fill is bounded by doc_rect
  to prevent runaway expansion on blank canvases.

Rectangular marquee (iris-app/src/state.rs + tool_dispatch.rs):
  Selection { rect: Option<kurbo::Rect> } added to AppState.
  Down starts drag, Move updates rect, Up finalises.
  BrushEngine.settings.selection gates stamp() per-pixel so brush
  and eraser only paint inside the active selection.

Foreground/background colour (AppState.foreground_color/background_color):
  Defaults: black / white. Eyedropper writes foreground_color.
  ToolPalette shows two colour swatches; click either to swap fg/bg.

Brush split: brush.rs → brush.rs + brush/paint.rs (was 405 lines, now
  214 + 153) to comply with the 300-line ceiling. PixelTool sub-enum
  added to state.rs for per-tool dispatch in tool_dispatch.rs.

All 9 iris-tools tests pass. cargo check --workspace clean.

https://claude.ai/code/session_012imcXHNWwg8q1TNRo1un9K
@kevincarlson kevincarlson self-assigned this May 20, 2026
The Ubuntu runner lacks libwayland-dev (and related libs) which are
required by wayland-sys at build time. Add an apt-get step covering
the full set of system libraries needed by blitz/winit on Linux CI.

https://claude.ai/code/session_012imcXHNWwg8q1TNRo1un9K
@kevincarlson kevincarlson merged commit df656c2 into main May 20, 2026
1 check passed
@kevincarlson kevincarlson deleted the claude/fix-dioxus-version-conflict-wE49E branch May 20, 2026 12:44
@AppThere AppThere locked as resolved and limited conversation to collaborators May 20, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants