Skip to content

ci: add the release pipeline (CI, git-cliff changelog, GitHub release, crates.io) - #5

Closed
p6laris wants to merge 115 commits into
masterfrom
ci/release-pipeline
Closed

p6laris wants to merge 115 commits into
masterfrom
ci/release-pipeline

Conversation

@p6laris

@p6laris p6laris commented Sep 29, 2026 •

Copy link
Copy Markdown
Member

Summary

Mirrors the void project's release pipeline, adapted for a Windows-only crate on master.

  • ci.yml: cargo fmt --check, clippy -D warnings, tests and a release build on windows-latest, on push and PR to master.

  • publish.yml (manual): bumps the version (exact, or major/minor/patch), generates CHANGELOG.md with git-cliff, commits, tags and pushes with a PAT so the tag triggers the binary build, creates the GitHub release, then runs cargo publish on a Windows runner.

  • build-binaries.yml / reusable-build.yml: on a v* tag, builds zoomify.exe, zips it with a SHA-256 and attaches both to the release.

  • cliff.toml, plus what publishing needs: crates.io metadata in Cargo.toml, an MIT LICENSE, README.md and CONTRIBUTING.md (release steps and secrets).

  • A separate commit makes the code clippy-clean so CI starts green (collapsed if-lets, an index loop, const chunk sizes, an eprintln! in a Display impl, items moved ahead of a test module). No behaviour change.

  • README showcase: assets/showcase.png and assets/features.png (drawn recreations of the UI, not screenshots) and a README rewritten around them. assets/ is excluded from the crates.io package.

Not carried over from void: the Homebrew and winget packaging jobs.

Before the first release

Add two repository secrets (see CONTRIBUTING.md):

  • CARGO_REGISTRY_TOKEN: a crates.io token scoped to publishing zoomify.
  • RELEASE_GITHUB_TOKEN: a personal access token with write access to this repo (the default token's pushes do not trigger the binary build).

Test plan

  • cargo fmt --check, cargo clippy --all-targets -- -D warnings, cargo test (160 pass)
  • cargo publish --dry-run: 32 files, builds from the packaged copy, nothing uploaded
  • All four workflow files parse as YAML; cargo update --workspace only changes the version line in Cargo.lock
  • Not yet run: the workflows themselves. CI will run on this PR; the release workflows need the secrets above and a first manual run.

🤖 Generated with Claude Code

p6laris and others added 30 commits August 30, 2026 11:33
…Live Zoom with virtual screen metrics & global hotkeys
… stroke styles, SRCCOPY blits, and synchronous UpdateWindow
…reen magnification coordinates, and fix static zoom instant pan
…adge attributes, canvas mouse tracking, and timer redesign
…de transitions

- Add Direct2D hardware-accelerated floating magnifier loupe lens (Ctrl+6, tray, toolbar)
- Support circle & rounded rect shapes, reticle toggle (R), magnification pill, dynamic resizing, and cursor pinning (Space / Left-Click)
- Isolate canvas zoom, drawing shapes, text inputs, laser trails, and slate backgrounds strictly to Draw and StaticZoom modes
- Cleanly reset overlay state on exit and enable direct right-click exit in Draw mode at 1:1 scale
- Ensure seamless mode switching across global hotkeys, single-key shortcuts, tray items, and Fluent toolbar buttons
- Introduce hardware-accelerated Direct2D/DirectWrite Settings & Hotkeys Configurator window (Ctrl+, or tray)
- Provide interactive key combination recording for all 6 tool modes with dynamic runtime re-registration
- Add Windows startup auto-launch management via HKCU Run registry
- Support custom canvas tuning (magnification scale, default pen thickness, color palette presets) and countdown timer preferences
- Guarantee precise client geometry with AdjustWindowRectEx and dynamically anchored Fluent action footer
- Integrate Settings & Help entry in system tray menu
… text

Correctness and soundness
- Recover from D2DERR_RECREATE_TARGET in the overlay and settings window
  instead of freezing on the last good frame.
- Rebuild the background bitmap after EndPaint and wait for a compose
  barrier so magnified content is not captured stale.
- Replace `static mut` live-zoom state with atomics (AtomicPtr/AtomicBool/
  AtomicI32) used as a hook -> owner channel; add drain_pending_zoom().
- Re-add the tray icon on the TaskbarCreated broadcast (Explorer restart
  previously left the app with no icon).
- Use TPM_RETURNCMD | TPM_NONOTIFY + PostMessage for the tray menu.
- Keep timer total and remaining consistent via adjust_timer_values(); a
  stale config could pin timer_duration_mins to 1.
- Write screenshots to the real Pictures folder via SHGetKnownFolderPath
  rather than a hardcoded %USERPROFILE%\Pictures, which misses OneDrive
  redirection.
- Strip a UTF-8 BOM before parsing config.json and back the file up as
  .json.bad on parse failure; a BOM previously reset every setting.
- Report failed RegisterHotKey bindings to the user instead of silently
  dropping them.

DPI
- Track per-window DPI and convert through dpi_scale()/logical_w()/
  logical_h()/px_to_dip(); SetDpi() on the capture render target.

Performance
- Cache solid colour brushes instead of creating one per draw call.
- Rewrite the blur shape as downsample -> upsample (2 draws, was ~30k).
- Gate the animation timer so an idle overlay stops repainting.
- Cap the laser trail at MAX_LASER_TRAIL_POINTS.

Custom colours
- ColorPreset::Custom(r,g,b) with hsv_to_rgb/rgb_to_hsv, an in-overlay
  RGB/hue picker opened from a "+" toolbar swatch (src/renderer/ui_picker.rs),
  and recently-used customs persisted in config.

Multi-line text
- TextEditorState gains insert_newline, line_bounds, byte_at_col,
  current_col, move_up/move_down, move_line_start/move_line_end and
  line_count; columns are counted in chars so the caret stays on a UTF-8
  boundary.
- Enter inserts a newline, Ctrl+Enter commits, Esc commits (right-click
  still discards), arrows move the caret with Ctrl+arrows resizing the
  font, and Home/End are line-scoped.
- Size text cards from exact IDWriteTextLayout metrics (new
  Renderer::measure_text_block) instead of `text.len() * font_size * 0.7`,
  which made multi-line blocks far too wide and clipped all but the first
  line. The eraser hit-test uses the same measurement.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WLVFf1328PFs5UzYLuwTgv
Adds a Select tool (V, first in the toolbar's tool group) that operates on
already-committed shapes.

- Click picks the topmost shape under the cursor; clicking empty canvas or
  pressing Esc clears the selection.
- Dragging the body moves it; dragging one of eight grips resizes it with
  the opposite edges anchored. Arrow keys nudge by 1 canvas unit, Shift+
  arrows by 10.
- Delete or Backspace removes the selection. Every edit is one undo step
  via the new HistoryAction::TransformShape, and a drag that ends where it
  started records nothing.
- Double-clicking a text annotation re-opens it in the editor with the
  caret at the end, so wording can be fixed instead of retyped. This needs
  WM_LBUTTONDBLCLK, so the overlay class now sets CS_DBLCLKS; the message
  shares WM_LBUTTONDOWN's arm so no other tool loses the second click.

Geometry (src/shapes.rs):
- shape_bounds, selection_handle_points, handle_at, resized_bounds,
  translate_shape and resize_shape, all unit-tested.
- resize_shape maps the geometry *inside* the stroke padding, so the box a
  shape reports afterwards lands exactly on the box the drag asked for and
  the grips stay under the cursor. Stroke weight is deliberately preserved:
  repeated resizes would otherwise compound into a blob. StepBadge stays
  circular (inscribed in the new box) and Text reflows at a scaled point
  size, since both are content-shaped rather than box-shaped.
- Text bounds come from exact DirectWrite metrics via the renderer, so the
  selection box hugs the rendered card.

Selection chrome renders in screen space, so the grips keep a constant size
however far the canvas is zoomed. Cursor becomes an arrow for the tool and a
move cursor over a picked shape. Adding, erasing, clearing, undoing or
redoing a shape drops the selection, since those shift the indices it holds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WLVFf1328PFs5UzYLuwTgv
A session is the shape list plus the slate and step-badge counter, written
as versioned JSON so it stays diffable and a future format change is
detected rather than guessed at.

- Ctrl+Shift+S saves; Ctrl+O opens a shell file dialog and loads. Loading
  replaces the canvas in one undo step, and restores the slate and badge
  counter alongside the shapes.
- The overlay drops WS_EX_TOPMOST around the dialog: a topmost fullscreen
  window would otherwise paint straight over it and leave it unreachable.
- New Sessions tab in Settings: autosave-on-exit, PNG-alongside, keep-last-N
  (0 = keep all), a folder picker with a Default reset, and Open Folder.
- Autosave writes on overlay exit when enabled. It runs during teardown
  where a toast can no longer be drawn, so failures route through a
  process-global slot that the message loop drains into a tray balloon.
- Retention prunes after every save and takes each pruned session's
  exported PNG with it.

Autosave defaults to OFF. It puts files on the user's disk on every Esc,
which should be asked for rather than assumed; the switch is one click away
in Settings. Sessions default to Pictures\Zoomify Sessions, which honours
OneDrive redirection via SHGetKnownFolderPath.

Shape and its field enums now derive Serialize/Deserialize. pictures_dir()
moved from overlay.rs into the new session module, its only other caller
being save_snapshot.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WLVFf1328PFs5UzYLuwTgv
Pen and touch
- WM_POINTERDOWN/UPDATE/UP are translated into their mouse equivalents in
  one place before the message match, so every tool works from a single
  code path with the pen's pressure recorded alongside. Handling them here
  rather than letting DefWindowProc promote them also stops each contact
  arriving twice.
- Shape::Stroke gains a per-point `pressures` track (serde-defaulted, so
  older sessions still load). The renderer draws a pressure stroke segment
  by segment with a width that follows the press, averaging endpoints so
  the line tapers instead of stepping; mouse and touch strokes keep the
  cheaper uniform-width bezier path.
- pressure_width_factor floors light contact at 0.35x so a feather touch
  stays visible, and caps hard contact at 1.45x so it cannot balloon past
  the chosen nib.
- Mouse messages are ignored while a pen or finger is in contact. Windows
  moves and restores the system cursor around a stroke, and a promoted
  stray move was splicing the cursor's position into the middle of the
  pen's stroke — visible as a hook running off to wherever the mouse sat.

Windows.Graphics.Capture
- New capture_wgc module: cached D3D11 device, a free-threaded frame pool
  waited on via FrameArrived (with a 600ms cap so a refusing capture cannot
  hang the overlay), then a staging-texture copy to BGRA.
- ScreenCapture::capture_rect tries it first and falls back to BitBlt on
  any failure, on a machine without WGC, or for a rectangle spanning
  monitors — WGC works a whole display at a time.
- Cursor capture and the capture border are both switched off; both setters
  are best-effort since older builds lack them.
- Settings > General gains a Hardware Screen Capture toggle, applied at
  startup and on Save & Apply.

GDI reads the desktop without the compositor, so hardware-overlay video and
protected surfaces come back black. WGC asks DWM for the composed frame,
which is what the user actually sees.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WLVFf1328PFs5UzYLuwTgv
Shapes now pull onto other shapes' corners, edge midpoints and centres, with
dashed guides showing what lined up.

- Two kinds of snap: a point snap lands directly on an anchor, and failing
  that each axis lines up independently, so a shape can align vertically
  with one neighbour and horizontally with another at once.
- Applies to the first and last corner of a new shape, to dragging a
  selection, and to dragging a resize grip.
- Tolerance is measured in screen DIPs and divided by the zoom, so the pull
  feels the same however far in the canvas is scaled.
- Hold Alt to suppress it for one gesture. A held Shift/Ctrl constraint also
  stands aside rather than fighting the angle/square snap already there.
- Freehand strokes neither snap nor offer anchors: pulling onto a scribble is
  noise, not alignment. Lines and arrows offer their own endpoints and
  midpoint rather than their bounding box, whose corners are not on the line.

Guides run past both ends they connect. An alignment guide lies exactly along
the edge it is aligning, so without that overhang it vanishes underneath that
edge and shows nothing — which is what the first version did.

Config key `snap_to_shapes` (default on) toggles it; there is no Settings card
because the Canvas tab is full at 468px against a 504px footer, and Alt is the
better ergonomic answer anyway.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`Vec<Shape>` becomes `Vec<Annotation>`, where an Annotation is a shape plus a
`ShapeId` and the relationships that hang off it. No user-visible change; this
is the groundwork for text bound inside a shape and arrows anchored to one.

Relationships between annotations have to survive deletion, undo and
reordering, and a position in a Vec survives none of those — delete one shape
and every later index shifts, silently re-pointing anything that referred to
them. So:

- Selection is addressed by id rather than index.
- HistoryAction::TransformShape is keyed by id. DeleteShape keeps an index,
  because it needs somewhere to put the shape back, but carries the whole
  Annotation so an undone delete restores the *same* identity and anything
  anchored to it survives the round trip.
- ShapeId::reserve_above pushes the counter past everything in a loaded
  session, so newly drawn annotations cannot collide with ids off disk.

Session format goes to v2. v1 files still load: `shapes` deserialises through
an untagged either-or, and bare v1 shapes are each handed a fresh id.

Contained text is already excluded from hit-testing and from offering snap
anchors, so the next commit only has to add the layout.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Double-click a box or disc with the Select tool, or click inside one with the
Text tool, and you type a label into it rather than dropping loose text on
top. Double-clicking again edits what is there.

The label's position is derived from its container's bounds every frame
rather than stored, which is what makes it follow moves and resizes with no
bookkeeping at all — there is no position to keep in sync.

- Centred on both axes, wrapped to the container's inner width.
  measure_text_block gained a max_width, so wrapping was a parameter rather
  than new machinery.
- A labelled shape grows in height until the label fits, and will not shrink
  below it on resize. Growing rather than clipping is the less surprising
  trade: the box is what the user sized, but losing words is worse than a
  taller box.
- The label wears no card of its own — the container is the card.
- A label is not independently selectable and offers no snap anchors: the
  container owns it, and clicking the words grabs the box.
- Deleting or erasing a container takes its label with it, as one undo step
  via the new HistoryAction::DeleteShapes. A label whose container is gone
  would render nowhere, so the two have to travel together.
- If the container disappears mid-edit the text commits as free-floating
  rather than losing what was typed.

Only rectangles, ellipses and step badges can hold a label. A line, a
scribble or a blur patch does not read as a container.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Draw a line or arrow starting or ending on a box, disc or text and it anchors
there. Move or resize that shape and the arrow follows, its tip landing on the
shape's outline with a small gap rather than buried in the middle of it.

Endpoints are derived, not authored. Rather than teaching hit-testing, bounds,
snapping and export about bindings, a settle pass rewrites the stored geometry
after any mutation and everything downstream carries on reading plain shapes.
It runs after a drag, a nudge, undo, redo and a session load.

- boundary_point casts a ray from the shape's centre toward what the arrow is
  aiming at and returns where it leaves the outline — rectangles by nearest
  axis crossing, ellipses analytically, badges by radius. It never reaches
  past the point being aimed at, or a short arrow between two touching boxes
  would turn itself inside out.
- Each end aims at the *centre* of whatever the other end is attached to, so
  the two resolutions stay independent instead of chasing each other.
- An arrow with both ends on the same shape binds neither: that is an arrow
  to nowhere.
- A binding whose target is deleted is kept, not cleared, and resolution is
  skipped while the target is missing — so undoing the deletion re-attaches
  rather than leaving the arrow orphaned.
- Only shapes with a real boundary can be anchored to. A scribble, a line or
  a blur patch has no edge worth aiming at.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Windows.Graphics.Capture was costing ~100ms on every Ctrl+2, because each
capture built the whole pipeline from scratch. Measured breakdown of a single
grab: D3D device 18ms, activating GraphicsCaptureItem and CreateForMonitor
110ms, frame pool 17ms, StartCapture 30ms, waiting for the frame 25ms.

- The item and frame pool are now cached per monitor and rebuilt only if that
  monitor's resolution changes. The FrameArrived handler and its event are
  registered once, with the pool.
- Only the session stays per-capture. Leaving one running would remove the
  last ~55ms, but it keeps the GPU compositing frames for a capture nobody
  asked for, and can raise the system's screen-capture indicator.
- A reused pool can still be holding the previous grab, so it is drained and
  the event reset before each capture. Without that a capture can hand back a
  stale desktop, which a test for exactly that now covers.
- The pipeline is warmed once the tray icon is up, via a message posted to
  ourselves, rather than making the first overlay wait for it.

Ctrl+2 to first frame on screen, release build, median of three:

  before this commit   201 ms
  after                119 ms
  with WGC disabled    101 ms

Cold start is unaffected at ~40ms; the warm-up is off that path. The residue
over the GDI path is one session start plus one compositor frame, which is the
price of capturing what the compositor actually shows.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The shaft was drawn full length to the tip and a head filled over it, with the
head sized by `(width * 5 + 12).clamp(16, 48)`. Past about 8px the clamp held
the head still while the shaft kept fattening: at 12px the shaft's round cap
poked out beyond the point like a pimple, and by 36px the head was gone
entirely — a rounded bar with two small fins.

- The shaft now stops just inside where the head begins, so the cap can never
  reach past the tip and a dashed shaft cannot run through the head.
- Head length and width both scale with the stroke, with the head kept at
  least about twice the shaft's width so it stays recognisable.
- A head is never wider than it is long. On a short, thick arrow the length
  cap and the width floor cannot both be satisfied, and a fin is not an arrow,
  so the head wins and the shaft simply disappears — which is the honest
  rendering of a 36px stroke on a 14px arrow. Two tests spell that tension out
  rather than leaving it implicit.
- Proportions live in `arrow_head_size` / `arrow_head_points` / `arrow_shaft`
  in shapes.rs, so they are testable away from a render target.
  `calculate_arrow_head` is gone, superseded.

Double and Dimension styles pull the shaft back at both ends, and the
dimension ticks now scale from the head rather than a separate constant.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- Ctrl+] brings the selection to the front, Ctrl+[ sends it to the back.
  Later in the list means drawn later, so this is a move within the vector,
  recorded as HistoryAction::Reorder so undo walks it back.
- Ctrl+D copies the selection 16px down-right and selects the copy. A
  container brings its label, with the copy's label pointed at the copy —
  otherwise both boxes would share one label and moving either would drag the
  same words around.

A label needs no special handling when restacking: labels are drawn in a pass
of their own after every other shape, so they always sit above their container
whatever the container's depth.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Multi-select
- Sweep a marquee on empty canvas to select everything it touches; Shift+click
  toggles one in or out, so a mis-click can be taken back without starting the
  selection over.
- Move, resize, nudge and delete act on the whole selection. A group resize
  maps every member through the same box change, so the arrangement holds.
- One drag is one undo step via HistoryAction::TransformShapes, not one entry
  per shape. Deleting a group is likewise one DeleteShapes.
- Selection is now a set of ids; Selection::only() gates the operations that
  only make sense on exactly one (label editing, duplicate, restack).
- Touching the marquee counts as selected rather than requiring full
  containment, which makes sweeping up a diagram far less fiddly.

ROADMAP.md records what is built, the design decisions worth not
re-litigating, the constraints already hit (crowded toolbar, full Canvas
settings tab, ~119ms overlay open), and the queued work by area. Agreed order
is diagramming, then recording, then the rest.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A selected line or arrow now shows a round grip on each end instead of an
eight-grip bounding box. A line has no interior to scale, so the box mostly
offered handles that did the wrong thing — dragging one used to stretch the
whole arrow rather than move the end you grabbed.

- Dragging an end moves just that end, snapping like anything else.
- Dropping it on a shape anchors it there; dropping it on empty canvas frees
  it, which was previously impossible: once an arrow latched on there was no
  way to detach it short of deleting it.
- Both ends on one shape binds neither, same rule as when drawing.
- The ends are hit-tested before the bounding box, since they sit inside it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Arrow labels
- Lines and arrows can now hold a label, the same double-click gesture as a
  box. The label straddles the midpoint, which for a straight line is where
  the bounding-box centre already is, so the existing layout carried over.
- A label on a line rides *on* it rather than inside it, so it gets a chip
  behind it — without one the line strikes straight through the words. The
  editor draws the same chip while typing.
- It does not wrap to the container's width and cannot grow it: wrapping a
  near-vertical arrow's label to its bounding box would squeeze it into a
  column one character wide, and an arrow cannot grow to fit anything.

Align and distribute
- Ctrl+Alt+arrows align the selection to its outer edge; Ctrl+Alt+C / +M
  centre it; Ctrl+Alt+H / +V spread it out. All one undo step.
- Align goes to the group's outer extent, which is what "align left" means —
  everything to the leftmost edge, not to the average.
- Distribute equalises the *gaps*, not the centres, and leaves the outermost
  two alone since they define the span. Spacing centres evenly would bunch
  wide shapes against their neighbours.
- Labels are excluded from both: a label has no position of its own, it goes
  wherever its container is.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
p6laris and others added 27 commits September 28, 2026 16:28
…ast, or a timer that mostly aren't changing

The 16ms animation tick forced a full-scene repaint unconditionally
whenever a text editor was open, a toast was alive, or Timer mode was
running - even though:
- a caret only visibly toggles every 500ms;
- a toast's opacity() only actually changes during its 0.15s fade-in and
  0.35s fade-out, not the ~1.3s steady middle of its 1.8s lifetime, yet a
  toast fires on nearly every action;
- Timer mode's visible output (the digits, the countdown-ring arc) only
  changes once a second or every total/128 of a second, not every 16ms -
  and every one of those redundant frames redraws a full background
  bitmap, a dim fill, and rebuilds a 128-segment arc path.

Each now tracks the wall-clock/countdown state that actually determines
its visible output (a 500ms phase for the caret; ToastNotification's new
is_fading() for the toast; (seconds, arc segment, overtime) for the
timer) and only sets needs_paint when that state has actually changed
since the last tick.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…invisible results

render_to_capture() discarded EndDraw's result and always proceeded to
copy pixels and return a "successful" capture - an oversized supersampled
export (an export scale applied to an already-large or multi-monitor-
spanning capture) could ask for a DC target past the GPU's max bitmap
size, fail EndDraw, and silently return a blank Copy/Save/PDF. It now
checks EndDraw's result and returns None on failure, which every caller
already reports as a failed copy/save rather than succeeding silently.
Also added max_bitmap_size() (ID2D1RenderTarget::GetMaximumBitmapSize)
and clamp the export's supersample factor so out_w/out_h cannot exceed it
in the first place.

Pasting an image bigger than that same limit (clipboard.rs allows up to
32768px pixels; the real device limit is usually 16384) had the same
failure mode from the other direction: image_bitmap's CreateBitmap failed
silently, leaving a selectable Shape::Image annotation that rendered as
nothing at all. place_image_shape() now downscales the pixel data itself
(nearest-neighbour - this is a rare safety-net path, not everyday paste
scaling, which only ever resizes the on-canvas display size) to fit
max_bitmap_size() before the shape is ever created, and the paste toast
says so when it happens.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…nned

render_to_capture converted spotlight.x/y with zoom_state.canvas_to_screen
before drawing the mask, but overlay.rs stores spotlight.x/y in screen-space
DIPs already (see the mouse-move handlers that set them from screen_pt/raw
mouse coords). The on-screen render_frame path uses them directly with no
conversion. The extra conversion in the export path shifted the spotlight's
exported position any time the view had a non-identity zoom/pan transform.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…L robustness

- svg_export: apply the view transform whenever zoom > 1x OR the pan offset
  is nonzero, not just above 1x. A whiteboard/infinite-canvas pan at rest
  zoom was silently dropped from SVG exports while PNG always applied it.
- overlay: commit any in-progress text edit before every export/save/copy
  path (copy_screen_to_clipboard, save_session, save_snapshot, export_svg,
  export_pdf) instead of letting PNG/PDF bake in the editor's card chrome
  while SVG (which reads straight from self.shapes) dropped the text being
  typed entirely.
- svg_export::esc: strip XML-illegal C0 control characters (keeping tab/LF/
  CR, the only ones XML 1.0 allows) so a pasted control byte can't produce
  an SVG no parser accepts; add xml:space="preserve" on text runs so
  significant whitespace survives.
- svg_export::FNum: guard non-finite values, writing 0 and logging instead
  of emitting "nan"/"inf" into the SVG's numeric attributes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
… marquee family expansion

validate_selection's retain() and select_release's marquee family-expansion
loop both did a Vec::contains inside a per-element loop - O(n*m) for
selections/sweeps with many shapes. Both now check membership against a
HashSet built once up front, same behaviour and ordering, less work as
selections grow.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…t every frame

render_hud ran three format! calls plus an encode_utf16 collect on every
single frame the HUD is visible, even though its text only actually changes
when mode/tool/stroke width/zoom/spotlight/background do. Added a
HudTextKey - the discretized (Eq-able) form of exactly what reaches the
displayed string - and a small cache on D2DRenderer keyed by it; the text
is only rebuilt when the key changes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…lding it every frame

BadgeShape::Hexagon called CreatePathGeometry + a 6-point Open/BeginFigure/
AddLine/EndFigure/Close sequence on every frame the badge is on screen, a
GPU device-object allocation Circle/Square badges never pay (they use plain
D2D primitives). Routed it through the existing content-hash geometry_cache
(same pattern as the stroke geometry cache just above it), keyed on
center+radius with a discriminant tag so it can't collide with stroke
entries sharing the same map.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Same issue as the hexagon badge: render_snap_guides rebuilt a fresh
CreatePathGeometry for every marker on every frame while dragging with
snap active. Routed through the same content-hash geometry_cache, keyed
on position (the size is fixed at 5.0) with its own discriminant tag.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ometries

Both rebuilt a fresh CreatePathGeometry on every repaint of the timer card
(the progress arc walks up to 128 segments), even though the card can be
forced to redraw for reasons that have nothing to do with the timer itself
(caret blink, a fading toast elsewhere on screen) far more often than its
own discretized state (see P4-7's arc_segment) actually changes. Routed
both through the shared content-hash geometry_cache, keyed on segment
count/position with their own discriminant tags.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…uilding it every frame

render_loupe called CreateBitmapBrush on every single frame it was active,
even though only its transform (following the cursor) actually changes
frame to frame - the brush itself only needs rebuilding when the render
target or the background bitmap it wraps changes. Added a small cache
keyed on both identities, cleared on device loss alongside the other
device-dependent caches.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…pe hit test

The eraser drag ran shape_intersects_circle - a full per-segment geometric
test - against every shape on the canvas on every mouse-move, regardless of
how far away it was. Added a cheap AABB reject (the shape's exact bounds
padded by the hit radius) first: shapes clearly outside it are skipped with
four comparisons instead of the full test. Purely an early-out - the exact
test still runs for anything the bbox doesn't rule out, so hit behaviour is
unchanged.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…uilding it every frame

stroke_polyline (curved Line/Arrow shapes) called CreatePathGeometry fresh
on every frame, unlike the straight-segment path which just calls DrawLine.
Routed it through the shared content-hash geometry_cache, keyed on the
sampled points + stroke width with its own discriminant tag - a curved
shape that isn't actively having its bow handle dragged now costs nothing
more per frame than a straight one.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…tLayout

measure_text_block built a fresh IDWriteTextLayout on every call, and each
draw then paid for a second one inside DrawText - so a Shape::Text cost two
layouts per frame (three in the editor), and hover hit-testing via
shape_bounds_exact re-measured on every mouse move.

- Added a content-keyed text_layout_cache (text, font, wrap width) holding
  the layout plus its measured size. It is not tied to a render target
  (DirectWrite layouts are CPU-side), so it survives export and device loss.
- measure_text_block, Shape::Text, contained labels and the text editor now
  share that one layout, drawn via DrawTextLayout.
- The editor no longer splices a '|' into a copied string every blink: it
  draws the real text and the caret as a line placed by HitTestTextPosition.
- Drawing from the same layout that was measured also removes a latent
  mismatch where DrawText re-wrapped at a narrower rect than the measure.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…k, rebind a vanished monitor

- A pen contact cancelled or captured away without a WM_POINTERUP left
  pen_active set, and the bystander rule then swallowed every mouse event.
  WM_POINTERCAPTURECHANGED now clears it (abandoning any stroke in flight)
  and exit_overlay resets it too.
- The low-level mouse hook read Ctrl with GetKeyState, which reports the
  hook thread's stale state; it now uses GetAsyncKeyState.
- refresh_monitors kept the old index after the list re-sorted or a screen
  was unplugged. It now finds the current monitor by handle/position, falls
  back to the primary, and re-binds the window and drops the stale capture.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- Reset Toolbar Position passed physical pixels to update_layout; it now
  uses logical (DIP) size, so it centres correctly above 100% scaling.
- Tray Laser/Eraser/Blur and the K key set current_tool without updating
  the toolbar, leaving the wrong tool highlighted. Added select_tool() to
  do the whole switch and used it for all four.
- Pen strokes read pen_settings.stroke_width, which was hardcoded to 3.0
  and ignored the Settings default width. It is now seeded from config and
  updated when settings are applied.
- The timer card's dragged position is clamped into the current monitor on
  entry, so it cannot be stranded off-screen after a monitor change.
- read_dib added 12 mask bytes for every BI_BITFIELDS DIB, but V4/V5
  headers carry the masks inside biSize, so pixels were read 12 bytes late.
  It now adds them only after a 40-byte header, accounts for biClrUsed
  tables, and declines RLE/JPEG/PNG-compressed DIBs instead of misreading.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
… on-screen ones

Every render cache is keyed by render target, and export builds a fresh DC
target per call, so it cleared the on-screen brush/geometry/mosaic/image
caches and the next frame cleared its entries back, re-uploading every
pasted image after each Copy/Save/PDF. render_to_capture now swaps the
on-screen cache contents out for empty ones and restores them afterwards.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
… blit it

While open, the cheat sheet issued ~130 static DrawText calls every frame.
Its content is fixed, so it is now rendered into a premultiplied-alpha
compatible target keyed by render target + DPI and drawn with one
DrawBitmap; the cache is dropped on device loss and rebuilt on DPI change.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…t it per frame

render_frame redrew every committed shape on every repaint, so a pen stroke
in progress or a hover paid for the whole canvas. Committed shapes are now
rendered once into a premultiplied offscreen target and drawn with a single
DrawBitmap; the live stroke, editor, selection chrome and everything else
still draw on top each frame.

The layer is validated by comparing the shapes themselves (plus transform,
DPI, size and blur source) against a snapshot, not by tracking mutation
sites, so a missed invalidation costs speed rather than showing a stale
picture. It is bypassed while a select-drag is rewriting shapes each frame.
Device caches are keyed by the parent render target while the layer renders
so its compatible child does not flush them. Export still draws directly.

Not visually verified: needs a manual check of text, blur, pasted images,
zoom/pan and a Ctrl+C export.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
One conflict, in the cheat sheet (ui_hud.rs): master moved the row lists to
the top of the function, added the eyedropper row and sized the panel from
its content, while this branch renders the sheet once into a cached bitmap.
The row lists are now shared constants and the panel height comes from one
cheat_sheet_height() used by both the cached bitmap and the drawing code, so
the cache matches the content-sized panel.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Fix the BUGFIX-ROADMAP audit: crashes, leaks, zoom bounds, DPI, performance, hardening
Every segment of a pressure stroke was its own round-capped DrawLine, every
frame (about 25k calls across 50 long strokes). A committed solid stroke is
now a single path geometry, cached by content, filled once.

The geometry is the union of the same per-segment capsules the old drawing
produced: a rectangle per segment at the mean of its endpoints' pressure
widths plus a disc per point, all wound the same way and filled with the
winding rule so overlaps merge cleanly. The outline maths lives in
shapes::pressure_stroke_figures and is unit-tested. The stroke still being
drawn and dashed/dotted patterns keep the per-segment path.

A translucent highlighter stroke with pressure no longer double-darkens where
segments overlap. Not yet compared by eye against a pen or tablet.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
perf(P4-4): draw committed pressure strokes as one cached filled shape
… manual check

Adds a Status column to the index (with the commit for each item), a section
on where the implementation departs from the plan as written (P4-1, P4-4,
P4-5, P4-10, P4-11), what was skipped, and the manual checks still owed since
none of this has been run.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
docs: record BUGFIX-ROADMAP status and remaining manual checks
The new CI runs clippy with warnings as errors, so start it green: collapse
nested if-lets, replace an index loop with enumerate, use chunks_exact with a
const size, drop an eprintln inside a Display impl, and move the items that
sat after types.rs's test module in front of it. No behaviour change.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…, crates.io)

Mirrors the void project's pipeline, adapted for a Windows-only crate on master:

- ci.yml: fmt, clippy -D warnings, tests and a release build on windows-latest.
- publish.yml (manual): bump the version with cargo-edit, generate CHANGELOG.md
  with git-cliff, commit, tag and push using a PAT so the tag triggers the
  binary build, create the GitHub release, then cargo publish from a Windows
  runner.
- build-binaries.yml + reusable-build.yml: on a v* tag, build zoomify.exe, zip
  it with a SHA-256 and attach both to the release.
- cliff.toml, plus the crates.io metadata, MIT LICENSE, README and
  CONTRIBUTING.md that publishing needs.

Needs two repo secrets before the first release: RELEASE_GITHUB_TOKEN and
CARGO_REGISTRY_TOKEN (see CONTRIBUTING.md).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Adds assets/showcase.png (hero: name, tagline, features, and the overlay
drawing on a slide with spotlight and timer cards) and assets/features.png
(an iOS-style grid of the six modes and the main tools with their hotkeys),
and rewrites the README around them with CI, license and crates.io badges,
a hotkey table and a feature list. The images are drawn recreations of the
UI, not screenshots. assets/ is excluded from the crates.io package.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@p6laris p6laris closed this Sep 29, 2026
@p6laris
p6laris deleted the ci/release-pipeline branch September 29, 2026 10:06
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