Repository navigation
Conversation
…Live Zoom with virtual screen metrics & global hotkeys
… stroke styles, SRCCOPY blits, and synchronous UpdateWindow
…reen magnification coordinates, and fix static zoom instant pan
…a mode, input focus, and hotkey toggling
…transitioning to drawing and spotlight
…cate overlay and spotlight to static canvas
…adge attributes, canvas mouse tracking, and timer redesign
…bel, add shift snip guide lines
…nings, and apply rustfmt
… draw blinking, and silence live zoom
…pboard, and drag capture
… remove snip feature
…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>
…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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Mirrors the
voidproject's release pipeline, adapted for a Windows-only crate onmaster.ci.yml:
cargo fmt --check,clippy -D warnings, tests and a release build onwindows-latest, on push and PR tomaster.publish.yml (manual): bumps the version (exact, or
major/minor/patch), generatesCHANGELOG.mdwith git-cliff, commits, tags and pushes with a PAT so the tag triggers the binary build, creates the GitHub release, then runscargo publishon a Windows runner.build-binaries.yml / reusable-build.yml: on a
v*tag, buildszoomify.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 MITLICENSE,README.mdandCONTRIBUTING.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 aDisplayimpl, items moved ahead of a test module). No behaviour change.README showcase:
assets/showcase.pngandassets/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 publishingzoomify.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 uploadedcargo update --workspaceonly changes the version line inCargo.lock🤖 Generated with Claude Code