#### 2026-09-29 *0.9.92* - ColorPicker: two new wheel styles that pick colour and intensity together at full saturation. `ColorPickerWheelStyle::HueLightnessField` is a single field with the hue running top to bottom and the lightness from black through the pure colour to white; `HueLightnessSliders` is a colour (hue) bar plus an intensity bar from white through the colour to black. The hue bar and the new intensity bar share one gradient-bar renderer, whose handle now carries the marker outline so it stays visible on white. - DemoApp: the Colour Picker page gains a row showing both styles, and the field with collapsible sliders. - **DemoApp: example pages taller than the window could not be scrolled.** 0.9.22 made containers scroll only when they ask to, and the demo's display area, its one scroll region, was not among the places opted in. Pages built at a fixed height were also shrunk to the window, so they never overflowed it, and their lower rows (the Colour Picker's variants, most of Slider, Split Pane, Tabs, ...) were cut off at the window edge. The display area now opts in, and a page with a fixed pixel height or width keeps it rather than being squeezed or stretched to the window (stretched, the vertical bar would narrow the viewport and fabricate a horizontal overflow). Pages with no fixed size are still fitted to the window and scroll their own content. #### 2026-09-29 *0.9.91* - **`UltraCanvasDesktopShell`: the running desktop as a module.** The windows other applications have open and which one is active, the virtual desktops, the installed applications a launcher lists, a screenshot of the screen, the live state of the devices an info panel shows (webcam, microphone and speaker in use, Bluetooth, Wi-Fi with SSID, LAN, VPN, traffic, USB count, battery, keyboard layout) and the counts an application publishes for the desktop to show (`PublishNotice` / `ReadNotice`, one atomically written JSON file per application), and `ReserveScreenEdges` to keep maximised windows off a window's strips along the screen's edges (`_NET_WM_STRUT_PARTIAL`). `UltraCanvasDesktopShellMonitor` reports window and desktop changes on its own thread. Linux backend over EWMH, `XGetImage`, procfs and sysfs; a null backend elsewhere keeps the application list, the launcher and the notices working. The module the new UltraDesktop application (`Apps/UltraDesktop`, its own changelog) is built on, so any application gets the same window list. See `Docs/UltraCanvas/UltraCanvasDesktopShell.md`. - **`UltraCanvasWaveSeparator`**: the S-curve between two groups on one bar, one group's colour up to the curve and the next group's after it. An in-flow element like `UltraCanvasSeparator`; in the catalogue. - **`UltraCanvasToolbar` 1.5.0: item badges, real item reordering, wheel scrolling.** `SetItemBadge` / `SetItemBadgeCount` / `SetItemBadgeDot` / `ClearItemBadge` anchor an `UltraCanvasBadge` to an item (a count on the mail icon, a red dot on the webcam). `EnableItemReordering(true)` now reorders *items*: a press on an item followed by a drag moves it past its neighbours and `onItemReordered(from, to)` fires on release; before, the mode moved the whole toolbar on any press, which is what `ToolbarDragMode::Movable` is for. `MoveItem`, `GetItemIndex`, `GetItemOrder` and `GetItems` do the same from code (badges are children but never items). `ToolbarOverflowMode::Scroll` keeps items at their size and scrolls a full toolbar with the mouse wheel instead of squeezing them. `UltraCanvasButton::CanToggle` (new getter) is what the toolbar reads to reset a plain button's pressed look when its press became a drag. - **`WindowType::Desktop`**: a window the size of the screen at the bottom of the stack, on every virtual desktop, out of taskbars and undecorated (`_NET_WM_WINDOW_TYPE_DESKTOP` plus the sticky, below and skip-taskbar states on X11) - the window a desktop shell draws its wallpaper and bars into. The X11 window size limit rises from 4096 to 16384 px so a 5K screen is a valid size. - **UltraMail publishes its unread total** to the desktop through `UltraCanvasDesktopShell::PublishNotice("UltraMail", …)` whenever the account bar refreshes, so the desktop's mail icon carries the count. - **X11 windows carry a `WM_CLASS`.** Every window now sets its class hint to the name `Initialize()` was given (`UltraFiler`; instance `ultrafiler`), which is what a taskbar - UltraDesktop's or any other desktop's - matches against a desktop entry's `StartupWMClass=` to find the application's icon. Before, no UltraCanvas window had one, so the `StartupWMClass` lines in the shipped `.desktop` files matched nothing. `UltraCanvasApplicationBase::GetAppName` (new) exposes the name. - **An empty clipboard is no longer logged as an error.** The Linux clipboard reported "Selection conversion failed" whenever the owner had nothing in the requested target, which a clipboard monitor asks every half second; that answer is now silent. #### 2026-09-29 *0.9.90* - **VideoFX: portrait photos no longer lose two thirds of themselves.** A still image's framing is now `VideoFXSegment::imageFit` (and `VideoFXSlideshowOptions::imageFit`): `Cover`, `Contain` (black bars) or the new `BlurredBackground`, which shows the whole photo over a blurred, darkened, enlarged copy of itself - made once per photo from a 1/12-size render. The default `Auto` keeps `Cover` for 4:3, 3:2 and panoramic photos and switches to `BlurredBackground` for an image much taller than the frame, such as a portrait photo in a 16:9 slideshow. Pan and zoom move the sharp photo in front of its backdrop. Still images no longer take their framing from the video `fitMode`. `videofx slideshow --fit`. - **VideoFX text overlays no longer depend on the machine's fonts.** A text overlay without a `fontPath` now takes, in order: the font the application set with the new `VideoFX_SetDefaultFontPath()`; the framework's bundled Ubuntu font found next to the executable wherever UltraCanvas apps ship `media/` (`share/media/fonts`, `Resources/media/fonts`); a system sans font; and fontconfig's "Sans" only after a one-time check that this FFmpeg can load it. With none usable, the export fails before writing anything, with `NotAvailable` and a message naming the fix - on a minimal Linux install without DejaVu / Liberation / Noto it used to fail inside the filter graph. `VideoFX_GetDefaultFontPath()` reports the choice; `videofx` gains `--font`. - **A text overlay's own `fontPath` was ignored.** It was checked for existence and then never passed to FFmpeg, so every title was drawn in the default font. It is now used, ahead of the default. - **VideoFX: photos on the timeline, "Ken Burns" motion and slideshows.** - `VideoFXSegment::FromImage(path, seconds, motion)` and `FromImageFrame(rgba, ...)` put a still image on the timeline, with transitions, effects and overlays like any segment. `VideoFXImageMotion` moves a virtual camera across it: `ZoomIn`, `ZoomOut`, four pans, `Custom` start / end zoom and centre, `Still` (fitted like video), and `Auto`, which varies the move per segment and pans along the direction the frame crops. Moves are eased, zoom runs geometrically, and every frame is resampled at sub-pixel positions in VideoFX itself, so there is none of the stepping FFmpeg's `zoompan` shows. Large photos are shrunk once to what the closest zoom needs and loaded only when their segment plays. - `VideoFX_CreateSlideshow(images, output, options)` makes a slideshow in one call: seconds per image, a transition (crossfade by default), per-image captions, fade from and to black, 1080p30 unless sized. - JPEG EXIF orientation is honoured, in exports and in `VideoFX_ExtractFrame` / `ExtractThumbnails`. Reading a single image twice no longer fails (the image demuxers report end-of-file after any seek; a still is now re-read from a fresh open), and JPEG frames could not be extracted at all before. - `videofx slideshow` with `--seconds`, `--motion` and `--caption`; `VideoFXTest` grows to 241 checks. - **VideoFX is implemented (stage 1).** Until now the module was a specification: a README describing 264 functions, no sources, no build target. `VideoFX/` is now a built, headless module on FFmpeg (4.4 to 8.x), wrapped behind its own types - no FFmpeg header reaches a caller: - **Inspection:** `VideoFX_Probe` (container, duration, tags, every stream, display rotation), `VideoFX_ExtractFrame` / `VideoFX_ExtractThumbnails` (upright RGBA, scaled to fit), `VideoFX_SaveFrameImage` (PNG / JPEG). - **A segment timeline:** `VideoFX_Export` plays file ranges, colour cards and test patterns one after another, each with its own trim, speed (0.25-4x, pitch kept) and effects, fits them to one size / rate / sample format (letterbox, fill or stretch) and encodes MP4, MOV, MKV, WebM, AVI, animated GIF, MP3, M4A, WAV, FLAC or OGG. Picture and sound stay in sync across joins: a stream that runs short is padded (last frame / silence). - **26 typed effects** (brightness, contrast, saturation, gamma, exposure, hue, temperature, grayscale, sepia, invert, 3D LUT with strength, blur, sharpen, denoise, vignette, quarter turns, free rotation, flips, crop, fade in / out, volume, EBU R128 loudness), validated before anything is written; filter text is dot-decimal under any locale. - One-line helpers `VideoFX_Transcode`, `Trim`, `ApplyEffects`, `Concatenate`, `ExtractAudio`, `GenerateTestClip`; `VideoFX_TrimLossless` cuts by stream copy; `VideoFXExportJob` runs an export on a worker thread with progress and cancel. A failed or cancelled export removes its partial file. - A `videofx` command-line tool (`info`, `frame`, `transcode`, `trim`, `concat`, `effects`, `testclip`). - Without FFmpeg the module still builds, from a stub whose calls return `VideoFXResult::NotAvailable`, so applications need no `#ifdef`. - `Tests/VideoFXTest.cpp` (116 checks) covers the filter-text translation and the engine end to end on clips it generates itself. The Linux CI rows now install FFmpeg's development packages so it runs there. - The demo's module list shows VideoFX as partially implemented; transitions, overlays, keyframes, multi-track mixing and project files are the next stages (`Docs/Modules/VideoFX/README.md`, `Masterfile_modules.md` §16). - **VideoFX stage 2: transitions between segments, titles and logos.** - `VideoFXSegment::transitionIn` blends the previous segment into this one over 0.04-5 s: 30 `VideoFXTransitionType`s (crossfade, dissolve, fade through black / white, wipes, slides, smooth pushes, squeezes, circle / rect / radial reveals, pixelize, blur), on FFmpeg's `xfade`. The segments overlap by the transition's length and their sound is cross-faded over the same span, in every output including GIF and audio-only files. The exporter holds the last D seconds of a segment back until the first D seconds of the next are in; a clip shorter than the transition shrinks the overlap instead of failing. - `VideoFXSegment::overlays` draws `VideoFXOverlay::Text` (drawtext: literal text, font size as a fraction of the frame height, colour, shadow, background band, default system font or `fontPath`) and `VideoFXOverlay::Image` / `ImageFromFrame` (PNG transparency kept, RGBA from memory, scaled to a fraction of the height) on the output frame, at one of nine anchors or a custom position, with start / end times, fade in / out and opacity. `VideoFX_IsTextOverlayAvailable()` reports whether the FFmpeg build can draw text. - GIF output now builds its palette once per frame at the very end, so transitions and overlays get colours of their own and nothing is buffered for the whole file. - `videofx` gains `--transition NAME[:SECONDS]`, `--title TEXT` and `--watermark IMAGE`; `VideoFXTest` grows to 182 checks. #### 2026-09-29 *0.9.89* - **`UltraCanvasListView` clears the scrollbar's bounds when it hides it.** A hidden scrollbar kept the rectangle of its last visible layout, computed for an earlier size - a list arranged at its parent's full width before the split pane sized it reported its bar at x 1251 with a negative height while the list was 470 wide. Nothing painted it while hidden, but the stale rectangle was what `GetScrollMetrics()` and the layout log showed, and what a paint would use if the bar were shown again without a fresh `UpdateScrollbar`. `UpdateScrollbar` now sets the hidden bar's bounds to 0,0 0x0, so a hidden bar has no position at all. #### 2026-09-29 *0.9.88* - **An eSCL scanner that offers TLS was listed twice.** Such a scanner advertises both `_uscan._tcp` and `_uscans._tcp`, and discovery keyed each entry on its URL, which differs between the two - so the scanner appeared once as `escl:http://...` and again as `escl:https://...`. It is now recognised by the `uuid` in its TXT record (compared without regard to case or a `urn:uuid:` prefix), or by its host when it gives none, and listed once: over plain HTTP, since its certificate is almost always self-signed and TLS verification stays on, with the TLS address kept in the `escl-tls-url` attribute. Checked against one scanner advertised both ways over Avahi: two entries before, one after. `EsclScannerIdentity` is new and tested in `IODeviceScannerESCLTest`. - A stale comment went with it: `ULTRACANVAS_ESCL_SCANNERS` was described as the only way to reach a scanner on Windows, which stopped being true when the mDNS plugin learned to resolve there. - **The IODeviceManager backend tables now list IPP printing.** The IPP driverless printer backend and the rewrite of those tables to list only backends that exist merged one after the other, so the tables said nothing of IPP - and the README still marked it "IPP discovery planned". The Printers rows of `Docs/Dependencies.md` and the DemoApp's copy of it, the module README's overview and backend tables, and `intro.md` now say that driverless network printers work over IPP on every platform, and the README gains a *Network Printers* section showing `ULTRACANVAS_IPP_PRINTERS`, next to the one for eSCL scanners. - **The changelog check never ran on a pending entry.** `changelog.yml` triggered on `CHANGELOG.md`, the version cmake file, the script and itself, but not on `Docs/UltraCanvas/changelog.d/**` - and since the framework's number moved to `main`, a pending entry is how nearly every pull request records its change. So `check_changelog.py`'s rule that a pending entry carries no `####` header was never enforced in CI; #579's two entries, for one, merged without the check running. The workflow now triggers on that directory too. #### 2026-09-29 *0.9.87* - **Every application's signal handler calls `UltraCanvasApplicationBase::RequestExitFromSignal()`.** ArtCreator, DeviceExplorer, Texter, UltraAI, UltraAuthenticator, UltraCleaner and the demo application still called `RequestExit()` (which logs and runs a callback) and `std::exit` from the handler, running the static destructors under live threads. Each handler is now the one call, and the main loop turns it into an orderly exit: `Run` returns and `main` shuts down as on a closed window. The `g_app` globals the handlers needed are gone. - **`UltraCanvasListView` checks its scrollbar before it paints.** The scrollbar's range, visibility and bounds were computed only when the model changed or the element was arranged; a paint that came between the two (a model that grew while the element was still at an old size) drew the rows with no scrollbar until the next arrange. `Render` now recomputes what `UpdateScrollbar` would give for the current rows and bounds, and when the scrollbar disagrees it logs one `scrollbar was stale at paint` line to the debug stream and refreshes it before drawing. `GetScrollMetrics()` returns the same numbers (rows, row height, content and viewport height, range, offset, whether the scrollbar shows and where) for diagnostics and tests. - **Windows: the kernel network ETW source names a process it cannot open.** It reported `pid 4720` for every process that refused `OpenProcess`, while the socket-table backend already named the same process from the Toolhelp process list. The list is now one table (`UltraCanvasWindowsProcessNames.h`, internal to `OS/MSWindows`): the backend refreshes it with every snapshot, and the event source reads it for each event, refreshing on a miss at most every two seconds, so a connect event from the antivirus proxy reads `AvastSvc` like its row. The registry completes the rest: an event whose source knew only the PID (an empty executable path) takes the socket table's identity for that PID - name, path and user - when the table has the socket. #### 2026-09-29 *0.9.86* - **GutenPrint printed only the first page of every multi-page job.** The CUPS raster writer that feeds GutenPrint's filter put its `RaS3` sync word before every page, where the format has it once, at the start of the stream. The filter read the second one as the start of page 2's header, found every field four bytes out of step, and stopped there - with exit status 0 and no message, so the job reported success. Checked against GutenPrint's own `rastertogutenprint`: one page printed from a three-page job before the fix, three after. - The sync word is its own call now, `AppendCupsRasterSync`, made once per stream; `WriteCupsRasterPageHeader` became `AppendCupsRasterPageHeader` and writes the header alone. Renamed rather than quietly changed, so a caller still expecting the sync word fails to compile instead of printing garbage. - `Tests/IODevicePrinterTest` now builds a three-page stream and reads it back the way the filter does. Nothing tested the writer before. - **The CUPS backend's comment on device ids described something that never happened.** It said the printer's UUID made the same printer found by the IPP backend collapse into one entry; `cupsGetDests2` does not return `printer-uuid`, so ids are `cups:` and the IPP backend avoids the double listing from its side. The comment now says so. - **Driverless printing over IPP, on Linux, macOS and Windows.** An IPP Everywhere, AirPrint or Mopria printer now appears as a `PrinterDevice` with no driver installed and no print system in between - which on Windows is the first route to such a printer at all. One file in `core/IODeviceManager/` serves all three platforms, as the eSCL scanner backend does: IPP is HTTP and a binary encoding, and nothing in it is platform code. - Found over DNS-SD (`_ipp._tcp`, `_ipps._tcp`) through UltraNet's mDNS plugin, or named in `ULTRACANVAS_IPP_PRINTERS` for a printer on another subnet. Registered as `urn:uuid:` and shown by its DNS-SD instance name, which is unique where the model name is not. - A document the printer renders itself - PDF, JPEG, whatever it lists in `document-format-supported` - is sent as it is. Text and other images are drawn here and sent as **PWG raster**, which every IPP Everywhere printer must accept: landscape pages turned onto the portrait sheet, and every second side of a duplex job turned the way the printer's `pwg-raster-document-sheet-back` asks, so no even page comes out upside down. Copies and page ranges are said once, never twice. Pages are drawn inside the printer's own margins, because a PWG raster page is printed edge to edge as it is. - A printer busy with one job refuses the next; the job waits and asks again, as CUPS's IPP backend does, for up to three minutes. - Refused by name rather than half-printed: a PDF to a printer that renders none, a page range the printer cannot apply, a printer that takes neither the document nor PWG raster. - Capabilities, status, supplies (`marker-*` and PWG's `printer-supply`), job queue, job status and cancel all work. An IPP 1.1 printer is asked again in 1.1 and remembered. - New: `UltraCanvasIODevicePrinterIPPProtocol.h` (RFC 8010 encoding both ways, attribute mapping, the send-or-draw plan) and `UltraCanvasIODevicePrinterPwgRaster.h` (PWG 5102.4 writer), both pure; `Tests/IODevicePrinterIPPTest` covers them with 232 assertions, the PWG compression checked by a decoder written from the specification. - `Tests/IODevicePrinterIPPLiveTest` prints to CUPS's reference printer `ippeveprinter`, which it starts itself, and is skipped where that is not installed. Not yet run against a physical printer. - **Printing an image crashed a program that had never opened a window.** `MakePageSourceForJob`, shared by the GutenPrint, GDI and IPP renderers, decoded the image without starting the image library, and the library does not fail when it is not started - it crashes. A command-line tool or a server printing a PNG hit it; an application that had opened a window did not. It now starts the library once, as the eSCL scanner backend already did for its own decoding. - **A printer was going to be listed twice on Linux and macOS, and the design that was meant to prevent it had never worked.** The CUPS backend keys a queue on `printer-uuid` so the IPP backend could collapse into it - but `cupsGetDests2` does not return that option, for CUPS's discovered queues or configured ones (checked against CUPS 2.4.7). The IPP backend now matches CUPS's queues itself, by the UUID in a `dnssd://` URI, the DNS-SD instance name, or the same address, and leaves those printers to CUPS. #### 2026-09-29 *0.9.85* - **The dependency tables no longer claim IODeviceManager backends that do not exist.** `Docs/Dependencies.md` and the DemoApp's in-app copy (`UltraCanvasDependenciesExamples.cpp`) listed ICA and AVFoundation for macOS and WIA, TWAIN and Media Foundation for Windows. None of them has a backend. The one Windows device backend is the printer backend on the print spooler (`OS/MSWindows/UltraCanvasWindowsIODevicePrinter.cpp`, winspool and gdi32). The single "Scanners / cameras / print" row is now three, one per category, listing only what `UltraCanvasIODeviceBackends.cpp` registers: - Printers: CUPS on Linux and macOS, the Windows print spooler. - Scanners: SANE on Linux, and the eSCL network backend (over UltraNet) on all three. - Cameras: V4L2 on Linux. ICA, WIA, TWAIN, AVFoundation and Media Foundation are marked *planned*, and a note says that on macOS and Windows a USB scanner or any camera is not found yet. The Win32 row of the library-links table now names winspool. - **IODeviceManager's README describes the module that exists.** It marked Scanner, Camera and NetworkCamera "Production" and listed WIA, TWAIN, ICA, MediaFoundation, AVFoundation, ONVIF and RTSP backends that were never written. Its examples called `DiscoverNetworkCameras()`, `CapturePhoto()`, `SetPTZ()`, `AddeSCLScanner()`, `Scan(config, bytes)` and `ScanColorMode::RGB`, none of which exist. - The category and backend tables now carry each entry's real state, taken from `Gaps.md`: printers available on all three platforms, scanners and webcams partial, the rest planned. - Microphone and Speaker are marked as the open decision `Gaps.md` records. The categories that were never `IODeviceCategory` values are gone. - Every example is rewritten against the headers and compiles: scanning through `ScannerDevice::Scan(ScannedImage&)`, cameras through `CaptureFrame` / `StartStream` / `SetControl`, printing through `PrintFile`, eSCL scanners named in `ULTRACANVAS_ESCL_SCANNERS`, and a custom device through `RegisterDevice`. - `intro.md`, which the DemoApp shows as the module's introduction, no longer claims TWAIN, WIA, ONVIF or libgpiod. `Gaps.md` says which categories exist. - **CI now builds IODeviceManager's optional Linux backends.** The Linux jobs install `libudev-dev`, `libcups2-dev` and `libsane-dev`, so configure reports the udev hot-plug watcher, the CUPS printer backend and the SANE scanner backend as ENABLED. `OS/Linux/UltraCanvasLinuxIODeviceWatcher.cpp`, `core/IODeviceManager/UltraCanvasIODevicePrinterCUPS.cpp` and `OS/Linux/UltraCanvasLinuxIODeviceScanner.cpp` are now compiled on every pull request. Until now no CI build had any of the three libraries, so these files compiled to nothing and a compile error in them would have gone unnoticed. - `package-linux.sh` leaves `libudev.so` on the host instead of bundling it: libudev reads the running udev's database, so it has to be the system's own. - libcups and libsane are bundled like every other library. Leaving them to the host would stop every packaged application from starting on a system without them, because the core library links both. The Linux package therefore now prints through CUPS and scans through SANE, which it could not before. The bundled SANE loader still uses the host's scanner drivers: Debian's libsane reads `/etc/sane.d` and searches `/usr/lib//sane`, `/usr/lib/sane` and `/usr/lib64/sane`, which covers the Debian, Arch and Fedora layouts. #### 2026-09-29 *0.9.84* - **DemoApp: `ShowFullSizeImageViewer` is now `ShowInMediaViewer`.** It has opened far more than bitmaps since it moved onto `UltraCanvasMediaViewerWindow` - vector drawings (SVG, EPS, XAR, CDR, DWG) and 3D models (STL) go through it too - so the name now says what it does. Every caller in the demo was updated. - **DemoApp: removed the dead `UltraCanvasBitmapExamples.cpp`.** Its eight per-format pages (`CreatePNGExamples` ... `CreateBMPExamples`) had not been reachable since the Bitmap menu switched to `CreateBitmapFormatDemoPage`, and its two helpers (`ExtractImageMetadata`, `CreateImageInfoLabel`) were used only by those pages. The declarations and the CMake entry went with it. #### 2026-09-29 *0.9.83* - **Windows: double-clicking a JPG in UltraFiler put up an "entry point not found" box (`WNetGetConnectionW` in `daxexec.dll`) and did not open the picture.** When Photos or another Store app is the default, the shell loads its activation DLL into UltraFiler's own process. There that DLL failed to resolve an import: the loader showed its modal box, and `ShellExecuteEx` came back "access was denied". - The loader's hard-error boxes are now off on the launching thread around every default open and "Open with" launch. - A registered handler that still fails to start is handed to `explorer.exe`, which activates it from its own process, as a double-click in Explorer would. #### 2026-09-29 *0.9.82* - **`.pl` is Perl or Prolog by what the file says, not by chance.** Perl and Prolog both claim `.pl`, and the extension lookup walks an unordered map, so which language a `.pl` file was highlighted as - and what the Filer called it - depended on the hash order of the build. - `SyntaxTokenizer::SharedExtensionLanguages(extension)` (new, static) lists the languages a shared extension can be, the default first: `.cls` VBA / LaTeX, `.m` MATLAB / Objective-C, `.pl` Perl / Prolog. `SetLanguageByExtension` picks that default instead of the map's first claimant. - `SyntaxTokenizer::LanguageFromContent` also tells `.pl` apart: a `#` or `#!` line, `use`, `my`, `our`, `sub`, `package`, `require` or POD is Perl; a `%` or `/*` comment or a `:-` clause is Prolog. - `UltraCanvasFilerWidget` names every shared extension after its content ("Prolog Text"), and after the default when the file does not say. `GetPreviewableFormats()` labels a shared extension with all of its languages, so the Display > Thumbnails > Text switch reads "VBA / LaTeX", "MATLAB / Objective-C" and "Perl / Prolog". #### 2026-09-29 *0.9.81* - **HTML can be opened in the WYSIWYG editor.** New `HTMLReader/HTMLRichDocumentImporter.h`: `ImportHTMLToRichDocument` / `AppendHTMLToRichDocument` turn an HTML page or fragment into a `UCRichDocument`, through the HTML reader's own parser and style resolver. - Mapped: paragraphs, headings, lists (nested, start numbers, letter and Roman formats), rules and line breaks. - Text formatting: bold, italic, underline, strike, sub/superscript, code, links, text and highlight colours, and font families and sizes. - Layout: alignment, left indents, and the space between blocks, collapsed as CSS collapses margins. - Pictures (`data:` URIs, or any other source through a `resolveImage` callback) become a picture paragraph when alone in their block, else sit inside the line. - Tables with several columns keep spans, cell colours, borders, padding and widths. One-column layout tables are unwrapped into the text flow, and a table inside a cell becomes lines of that cell. - **Blocks carry a quote level.** New `RichDocBlock::quoteLevel`: how many quotes a block sits inside. It applies to any kind of block, so a quoted list or table stays one. - `UltraCanvasRichTextEdit` draws a bar per level and indents the block. - Enter keeps the level. Enter on an empty quoted line, or Backspace at the start of a quoted block, steps one level out. - `ToHTML` nests `
`. `ToPlainText` and `ToMarkdown` prefix the lines with `> `. - **`UCRichDocument::ToHTML(RichDocumentHTMLOptions)`**: an `imageSource` hook decides a picture's `src`, for example `cid:` for mail; without it pictures stay `data:` URIs. - Pictures now carry their `width`/`height`. - Headings and picture paragraphs keep their alignment. - **The HTML reader reads ``, `bgcolor` and ``**, which much mail HTML is still written with. As in a browser, CSS for the same property wins. - **An inline picture in the rich text editor no longer runs past the right edge** of an indented or quoted paragraph: it is fitted to the line, not the column. - **UltraNet can send HTML mail with a plain-text version and embedded pictures.** - New `UltraNetMimeBuildInput::alternativeText` builds `multipart/alternative`. - Inline attachments with a Content-ID travel with the HTML in `multipart/related`. - Both text parts are quoted-printable, so long HTML lines stay within SMTP's 998-character limit. - `UltraNetMailMessage` carries the same as `alternativeText` and `inlineParts`, and the SMTP plug-in passes them on. #### 2026-09-29 *0.9.80* - **Programs inside archives can be run.** An entry inside an archive has a virtual path, which no system can execute, so `UltraCanvasFilerWidget` ignored a double-click on a program in a zip. `ExtractAndRunEntry` now unpacks the archive holding it — the whole archive, so the program finds its DLLs and data beside it — into a run folder, starts the program there and deletes the folder once the program and everything it started have ended. Double-click, Enter and `OpenEntryWithOS` do it for such an entry, and the context menu offers it as *Extract and Run*. - New `UltraCanvasArchiveRun.h`: `IsRunnableArchiveEntry` (Windows by extension — `.exe`, `.com`, `.bat`, `.cmd`, `.msi`; POSIX by the execute bit the archive recorded, or `.AppImage`), `LaunchWatchedProgram` (a launch whose end can be waited for: a job object on Windows, so an installer's second stage counts; a process group of its own on POSIX, with a failed `exec` reported instead of lost), `CopyDownloadMarking` (Windows: the archive's `Zone.Identifier` goes onto the unpacked files, so SmartScreen still checks them) and the run folders — each with a marker naming the processes using it, removed only when nothing holds a file in it, and swept up by `SweepArchiveRunFolders` after an application that closed while its program still ran. - `FilerEntry::archiveExecutable` carries the execute bit an archive recorded; `chooseArchiveRunRoot` lets the host pick where run folders go. - `Tests/ArchiveRunTest.cpp` covers the rule, the run folders and the watched launch, including a program that exits while its child runs on. #### 2026-09-29 *0.9.79* - **New element: `UltraCanvasBusyIndicator`**, the turning ring that says *working on it* when there is no percentage to show (a network call, a sync, a scan). `CreateBusyIndicator(id, x, y, size)` makes one, and `Start()` / `Stop()` / `SetRunning(bool)` control it. By default a stopped indicator draws nothing and runs no timer, so it can stay in a status line permanently. The angle comes from elapsed time, so a late timer tick never slows the ring. `BusyIndicatorStyle` sets the arc and track colours, the thickness, the arc length, the speed and the frame interval. Docs: `Docs/UltraCanvas/UltraCanvasBusyIndicator.md`; it is listed in the element catalogue. UltraMail's status line is the first user. - **HTML reader: images sit where a browser puts them, and linked images are shown.** - An `` in a block (`

`) was drawn in the middle of the line. Block flow gave its element the full column width, and the image element draws its bitmap centred in its box. Each image now sits on a full-width line of its own at its natural size (still capped at the column width, keeping its aspect ratio). It is placed at the start of the line, or centred or right-aligned by the `text-align` it inherits. - That `text-align` now also comes from the presentational `align="left|center|right|justify"` attribute on `p`, `div`, `td`, `th`, `h1`–`h6`, `caption` and `img`, and from `
`, which is now a block element. All of these are still common in email HTML. CSS `text-align` still wins over the attribute. - An image inside an inline element was replaced by its `[alt]` text. This covers ``, the banner and button of nearly every newsletter, and ``. Such images are now lifted onto lines of their own, and one inside a link is clickable (it calls `BuildOptions::onLinkActivated` with the link's href). The text around them stays in its runs. - **An image in the middle of a sentence flows in the text** instead of taking a line of its own. Images in a block that also has text (`

Rated out of five

`) become inline images of the text run. A block of images alone keeps one image per aligned line. The image stands on the baseline, the line grows to hold it, it is scaled to the line when wider, and inside a link it is part of the link. - Inline images honour vertical alignment: CSS `vertical-align` (`baseline`, `middle`, `top`/`text-top`, `bottom`/`text-bottom`) and the `` values `middle`/`absmiddle`, `top`/`texttop` and `bottom`/`absbottom`. - New in `UltraCanvasLabel`: `LabelInlineImage` (with `LabelInlineImageAlign`), `SetInlineImages()` and `InlineImageRect()`. An image is drawn at a U+FFFC placeholder in the text, in a box reserved with `TextAttributeFactory::CreateShape`. See `UltraCanvasLabelExamples.md`, *Inline Images*. - `ElementBuilder::BuildImage` takes an optional link href. - Test: `Tests/HTMLImageAlignTest.cpp` (headless builder + CSSLayout; placement for each alignment source, an oversized image, a clicked link, an inline image laid out and drawn on an offscreen context, and each vertical alignment measured against a baseline image in the same line). - **UltraNet decodes mail in every charset, not just UTF-8 and Latin-1.** `UltraNet_MimeDecodeHeader`, `UltraNet_MimeParse` and `UltraNet_MimeGetDisplayBody` passed any other charset through as raw bytes. Japanese mail in ISO-2022-JP therefore showed as `$B3t<02q - Component Demonstration`. Each app takes the number from its own changelog through `cmake/UltraCanvasVersion.cmake`; UltraMail, UltraSocial, EmailCleaner, AnchorPoint, UltraFIBU and UltraWin Manager gain the `_VERSION` compile definition for it, and UltraViewer's now carries its own version instead of the framework's. `AGENTS.md` (*Versioning*) makes the title a rule for new apps. #### 2026-09-28 *0.9.75* - **The Linux release binaries carried full debug info — about 800 MB of the portable bundle.** The top-level `CMakeLists.txt` added `-gdwarf-4` to work around binutils ld < 2.40 misreading Clang's DWARF5, on the belief that it was "a no-op without -g". On Clang every `-gdwarf-N` also *turns on* debug info, so every Release build on the Linux leg was a debug build in size: the packaged executables and `libUltraCanvas.so` came to 907 MB, 100 MB once stripped. It is now `-fdebug-default-version=4`, which only picks the format for a build that asks for `-g`. The version test beside it read `${CMAKE_MATCH_1}` in the same `if()` as the `MATCHES` that sets it, so it took the branch on any ld (it reported "GNU ld 2.42 < 2.40"); it is nested now. - `package-linux.sh` strips the executables and the UltraCanvas libraries it copies from the build tree (`--strip-unneeded`, so `.dynsym` stays and plug-ins still bind to the executables' exported core symbols). A Debug or RelWithDebInfo tree packaged by hand no longer ships its DWARF either. - `package-macos.sh` now bundles **UltraFiler** and **UltraViewer** (the universal media viewer) as `.app` bundles beside Texter, the demo, UltraNetMonitor and DeviceExplorer, so the macOS CI artefact (`UCDemo-MacOS-*`) and the signed release carry them, as the Linux portable bundle already did. UltraViewer declares itself a Viewer (`LSHandlerRank` Alternate) for images, SVG, video, audio, PDF, EPUB and plain text, so Finder offers it under *Open With*. #### 2026-09-27 *0.9.74* - **DemoApp: the SVG examples page shows the UltraFiler palette in place of `demo.svg`.** `media/vector/SVG/demo.svg` is removed, and its tile now loads `filer-palette-rev3.svg`, the proposed revision-3 colour palette for the Filer widget; the other seven drawings stay where they were. #### 2026-09-27 *0.9.73* - **Mail sessions take an authentication method.** `UltraNetMailOptions::auth` (`UltraNetMailAuth`, `UltraNet/UltraNetPlugins.h`) restricts an IMAP, SMTP or POP3 sign-in to one family - `Password`, `EncryptedPassword` (CRAM-MD5 / DIGEST-MD5, POP3 APOP), `OAuth2` (XOAUTH2 / OAUTHBEARER), `Kerberos` (GSSAPI), `NTLM` - or skips it (`None`, for a relay that trusts the network). `Any`, the default, is the old behaviour: whatever the server offers. The three plug-ins now share one sign-in routine, `ultranet_curlmailauth::Apply` (`UltraNet/UltraNetCurlMailAuth.h`, header-only), which also refuses up front a method that cannot work with the credentials given (OAuth2 without a token, a token with a password method) instead of letting the server reject it. - POP3 now signs in with an OAuth2 token too (it only ever sent a username and password). - **IMAP, SMTP and POP3 errors now say *why* a connection failed.** They used to report only curl's error class — for a rejected server certificate that was "SSL peer certificate or SSH remote key was not OK", which does not say whether the certificate is self-signed, expired, issued for another host name or missing its intermediate. The plug-ins now keep curl's per-transfer reason ("SSL certificate problem: unable to get local issuer certificate", "... certificate has expired", ...) in `UltraNetResult::message`, which UltraMail shows under its summary. This is the only trace a Windows GUI build leaves, since it has no stderr for `ULTRANET_CURL_VERBOSE`. Helper: `ultranet_curlerror::Perform` (`UltraNet/UltraNetCurlError.h`, header-only). - **POP3 over TLS trusts the same CA anchors as IMAP and SMTP.** The POP3 plug-in never set the CA bundle or, on Windows, the system certificate store, so on Windows every `pop3s://` sign-in was left with the libcurl build's own (non-existent) CA path. #### 2026-09-27 *0.9.72* - **Every application now handles file names in any script on Windows.** The UltraFiler fix (a name outside the Windows code page ended the program with *filesystem error: in __wide_to_char: Illegal byte sequence*) is now applied everywhere. About 500 more conversions between `std::filesystem::path` and `std::string` go through UTF-8 instead of the code page. They are in Texter, UltraCleaner, UltraMail, EmailCleaner, UltraSocial, UltraPaint, ArtCreator, UltraAuthenticator, UltraNetMonitor, the DemoApp, VirtualFS, UltraCloud, UltraVault, UltraWin, UltraMessage, SmartHome, the plug-in loaders and the document plug-ins (LaTeX, Word, OCR, CDR). - `PathToUtf8` / `PathFromUtf8` moved from `UltraCanvasUtils` to the new header-only `UltraCanvasPathUtf8.h` (C++17, no link dependency), so headless engines and VirtualFS can use them. `UltraCanvasUtils.h` includes it, so existing callers are unchanged. On Windows the conversion is now done in the header and never throws: invalid UTF-8, or an unpaired surrogate in an NTFS name, becomes U+FFFD. The new `OpenFileUtf8` replaces `std::fopen` for a UTF-8 path; on Windows it goes through `_wfopen`. - The plug-in loaders (`UCPluginOpen`, the UltraNet and UltraCloud registries) load with `LoadLibraryW` instead of `LoadLibraryA`, so a plug-in folder under a non-ASCII user name loads. `DescribeFileReadError`, `DescribeFileWriteError` and `WriteFileAtomically` (`UltraCanvasFileError.h`) now also open UTF-8 paths correctly. - New rule in `AGENTS.md`: file paths are UTF-8 in every application. `scripts/check_path_string.py` enforces it in CI (`path-strings.yml`). It flags `.string()`, `.generic_string()` and `fs::path(std::string)`, and a site that is correct as it stands (a path built from a wide string) says so with `// path-string-ok: `. The baseline is empty. - New test `PathUtf8Test` covers Thai, Cyrillic, CJK, emoji and NFD round trips, every kind of malformed UTF-8, unpaired surrogates, and a real file created, listed and opened under a Thai-plus-emoji name. The Windows build of the test passes under Wine. - **UltraFiler no longer quits on Windows when a folder holds a name outside the system code page.** On a Thai Windows 10 machine, opening a folder stopped UltraFiler with *Unhandled exception: filesystem error: in __wide_to_char: Illegal byte sequence*. With libc++ (the MSYS2 CLANG64 / CLANGARM64 toolchain), `std::filesystem::path::string()` converts the UTF-16 name to the process's ANSI code page. It throws if a single character has no equivalent there, such as an emoji, a CJK name or an accented Latin letter on a Thai system. The UTF-8 `activeCodePage` in the application manifest would avoid this, but Windows ignores it before 10 version 1903, so it cannot be relied on. The Filer path now converts through `PathToUtf8` / `PathFromUtf8` (`UltraCanvasPathUtf8.h`), which go through UTF-16 and never depend on the code page. The code that changed: `UltraCanvasFilerWidget` (folder listing, copy / move / delete / rename, archives), `UltraCanvasBreadcrumb`, `UltraCanvasVolumeMonitor`, `UltraCanvasCloudStorage`, `UltraCanvasHostFileIcons`, the file loader's extension and base-folder lookups, and the well-known-folder list in `UltraCanvasUtils`. On a system where the manifest's UTF-8 code page is in effect, the result is byte-for-byte what it was before. #### 2026-09-27 *0.9.71* - **Bar connections for bar charts on the chart engine.** A line from the value end of each bar to the same series' bar in the next category - it traces every series across the groups of a clustered chart, and anchored at the bar edges it is the series line of a stacked chart. - `BuildBarConnections(projection, spans, options)` (`Engine/UltraCanvasChartSeries.h`) builds one screen polyline per unbroken run of each series. `ChartBarConnectionShape::Straight` or `Curved`; the curve is a monotone cubic, so it is smooth, stays flat between equal bars and never overshoots the bars it joins. `ChartBarConnectionAnchor::BarCenter` meets the middle of each bar's top, `BarEdges` runs corner to corner along it. A missing value breaks the line unless `bridgeGaps` is set. The line is sampled through the projection, so it follows the rings under Polar. - `UltraCanvasChartEngineElement::RenderBarConnections` strokes them in the series colours with a `ChartBarConnectionStyle`: width, a wider stroke for an emphasised (hovered) series, dashes, dot markers at every bar, and a halo in the plot-area colour so a line stays readable across a bar of its own colour. - DemoApp's Bar Charts page shows it: the Clustered tab joins the four fruit series (Off / Straight / Curved, and "From bar edges"), and the Stacked tab offers the same controls for series lines. Hovering a bar widens its series' line. #### 2026-09-27 *0.9.70* - **The macOS and Linux packages now ship UltraNetMonitor, DeviceExplorer and `ultramsg`.** CI already built all three on every row, and the Windows package already carried them (it takes every `.exe` in the build tree), but the macOS and Linux packagers work from a fixed list and never gained them. - Linux (`package-linux.sh`): the three join the portable bundle as `bin/` with a wrapper launcher beside the other apps. - macOS (`package-macos.sh`): `UltraNetMonitor.app` and `DeviceExplorer.app` are built, signed and notarized like the Texter and Demo bundles, with their own icons. `ultramsg`, the UltraMessage command line, is not an app, so it ships as `ultramsg/bin/ultramsg` with its dylibs in `ultramsg/Frameworks/`. It is signed and notarized but not stapled, because a ticket cannot be stapled to a bare executable; Gatekeeper checks it online instead. `--dmg` puts that folder in the disk image next to the bundles. #### 2026-09-27 *0.9.69* - **`UltraCanvasMediaViewer::ClassifyFile` is public.** It answers which view a path opens in (`MediaKind::Image`, `Vector`, `Model`, `Video`, ...) from the name alone. A host needs this to decide whether a file is worth fetching before it can be shown: UltraFiler uses it to preview pictures, vector drawings and 3D models from FTP and cloud drives, and not videos or documents. An unknown extension still answers `Image`, so check `IsSupportedMedia` first. - **FTP rename, delete and new folder work in subfolders and on names with spaces.** `UltraNet_FtpRename`, `UltraNet_FtpDelete`, `UltraNet_FtpCreateDirectory` and `UltraNet_FtpRemoveDirectory` had three bugs: - The name was cut from the URL still percent-encoded, so `RNFR My%20Photo.jpg` asked for a file that does not exist. Any name with a space, a bracket, `+`, `&` or a non-ASCII letter failed with a 550. - libcurl sends quote commands before it changes into the URL's folder, so every command ran in the login folder. A rename in a subfolder failed, a new folder was created at the top of the server, and a delete in a subfolder could remove a same-named file at the top instead. Commands now name the entry by its path from the login folder, as libcurl reads the URL. - An `sftp://` URL was sent FTP commands, which SFTP does not speak. It now gets libcurl's SFTP commands (`rename`, `rm`, `rmdir`, `mkdir`) with quoted full paths. A name containing a line break is refused, since on FTP it would start a second command. The command text is built in `UltraNetFtpQuote.h`, is covered by `UltraNetFtpQuoteTest`, and was checked against a real FTP server. #### 2026-09-27 *0.9.68* - **DemoApp: the Message Centre page moved to *ULTRA OS modules* as *Ultra Message*.** It was listed under *Complex UI Elements*, which holds widgets any application can use on their own. The Message Centre is the view onto UltraMessage's broker and journal, so it now sits with the other ULTRA OS services (Ultra Database, Ultra Net, Ultra Vault) and is reached from the ULTRA OS overview. The page itself is unchanged. #### 2026-09-27 *0.9.67* - **The startup screens open in the middle of the app's window, not the middle of the monitor.** `UltraCanvasSplashScreen::Show` centred the splash on the parent window's screen, so with the main window anywhere but screen-centre the splash sat off to one side of it. It now centres over the parent with `CenterOnParent`, clamped to the parent's monitor, and still centres on the screen when no parent is given. UltraTexter's splash is the one caller. - DemoApp's startup information window gets the same treatment: it is created with the main window as its parent and centred over it after it is shown. Before, it was left wherever the window manager put it. - **The root build's text editor target is now `Texter`.** It was `UltraCanvasTexter`, so the program was `UltraCanvasTexter`, `UltraCanvasTexter.exe` and `UltraCanvasTexter.app`; they are now `Texter`, `Texter.exe` and `Texter.app`. `cmake --build … --target Texter` builds it. `package-linux.sh` (the `Texter` launcher), `package-macos.sh`, `package-win.sh` (the signing step) and `.gitignore` follow, and the Windows version resource names `Texter.exe`. The macOS bundle identifier stays `com.cloverleaf.UltraCanvasTexter` so existing preferences and signing identity carry over. The standalone `Apps/Texter` build still produces `UltraTexter`. #### 2026-09-27 *0.9.66* - **A PostgreSQL connection can log in with a password.** The driver was meant to read the password from UltraVault, but that lookup sat behind `ULTRADATABASE_HAS_VAULT`, which no build defined, and it called `UltraVault_GetSecret`, which does not exist. So every connection with `credentials` failed with "UltraVault is not built in". Only passwordless logins (peer / trust / `.pgpass`) worked, and that is all CI's multi-user test uses, so nothing failed. - The build now links UltraVault into UltraDatabase and defines the flag wherever the PostgreSQL driver is built. Configure prints "PostgreSQL (passwords from UltraVault)". - The driver reads the password with `UltraVault::Get`. The application opens the vault; the driver never opens one itself, since that would quietly give an empty in-memory vault and turn a setup mistake into "not found". A closed vault, a missing key and an unreadable vault now each give their own message. - The password no longer outlives the connect call. The vault's copy, the driver's copy and the connection string are all overwritten once libpq has them. The connection string is sized up front so appending never reallocates and frees a buffer holding the password, and the escaping writes straight into it instead of through a temporary copy. - **New result code `UltraDbResultCode::CredentialsUnavailable`.** The driver returns it when it cannot obtain the password on this machine (the vault is closed, the key is missing, or UltraVault is not built in). In that case no connection was attempted. It used to return `ConnectionFailed`, so a caller could not tell "fix this machine" from "fix the network", and UltraFIBU reported a missing vault key as "the server cannot be reached". Failures on the way to the server, including a wrong password, are still `ConnectionFailed`. - New `UltraFIBUServerLoginTests` needs no database server. It stores a password containing a quote and a backslash in a memory vault, and a fake PostgreSQL server on the loopback interface asks for a cleartext password. The test checks that exactly the stored password reaches the wire. It also checks that `OpenServer` gets as far as the server with a stored key, and is refused before connecting when the key is missing or the vault is closed. CI's "was the test registered" check now covers it too. - **CI:** the Linux jobs also build `Apps/AnchorPoint` on its own and run its protocol tests (see AnchorPoint 0.2.1). The login test builds on macOS, which has no `MSG_NOSIGNAL`: it falls back to ignoring `SIGPIPE`. - **Docs:** the UltraDatabase README status table lists the PostgreSQL driver as implemented, and explains that the application opens the vault. #### 2026-09-27 *0.9.65* - **CorelDRAW (`.cdr`) import on Windows.** The CDR plugin was off in every Windows build: the top-level CMake skipped the pkg-config checks with `if(NOT WIN32)`, and CI passed `-DULTRACANVAS_PLUGIN_CDR=OFF`. MSYS2 packages everything the plugin needs, so the Windows builds (CLANG64 and CLANGARM64) now install librevenge, lcms2, ICU and Boost (plus libcdr as the fallback), find them through MSYS2's pkgconf, and build the patched libcdr from `third_party/libcdr`. `package-win.sh` already copies every MinGW DLL a packaged binary imports, so librevenge, lcms2 and ICU ship with it. - CI now fails if the CDR plugin, or its vendored libcdr, is not enabled on any platform. That check found macOS silently without CDR import: Homebrew's ICU is keg-only, and the top-level gate ran pkg-config before the CDR subdirectory added ICU's pkgconfig dir, so `libcdr-0.1` (which requires `icu-i18n`) and the ICU check both failed. The gate now adds it first. - The macOS and Windows jobs run `VectorFormatsPluginTest` (`detailed.cdr` with its masked bitmaps and PowerClips) and `CDRWriterTest`. Those rows build no full test suite (`BUILD_TESTS` is Linux-only), so the new `ULTRACANVAS_BUILD_VECTOR_FORMAT_TESTS` option builds just these two; their definitions moved to `Tests/VectorFormatsTests.cmake`, which `Tests/CMakeLists.txt` includes as before. - **Windows: one graphics plugin registry per process.** The registry's storage (`Plugins()`, `ExtensionMap()`, `Initialized()`) sat in inline functions in `UltraCanvasGraphicsPluginSystem.h`. On Windows each module gets its own copy of an inline function's statics. The core is a DLL there and the apps link the format plugins into the executable, so plugins registered into the executable's copy, while the core's own readers saw an empty one. Those readers include the supported-format inventory (file dialogs, the Filer's classification) and the vector previews. The storage is defined in `core/UltraCanvasGraphicsPluginSystem.cpp` again: still built on first use, and now one copy for every module. `VectorFormatsPluginTest`'s inventory checks, which now run on Windows, found it. #### 2026-09-26 *0.9.64* - **WebSocket, CoAP and AMQP receiver threads no longer abort or outlive shutdown.** Each receiver held a `shared_ptr` to its own connection, and its `Stop()` always joined: - A callback that closed its own socket (`UltraNet_WebSocketClose` from `onText`, for example) joined the receiver from itself: `std::system_error` ("Resource deadlock avoided") and `std::terminate`. The same happened when the receiver dropped the last reference itself. `Stop()` now detaches when called on the receiver, and a small lock makes starting and stopping the thread race-free. - `UltraNet_Shutdown` left open WebSockets running on libcurl through `curl_global_cleanup`. It now stops every receiver and frees every easy handle first. - The CoAP and AMQP plug-ins' `Shutdown()` only emptied their session tables, so no receiver was ever told to stop. CoAP's worker even kept running `coap_io_process` through `coap_cleanup`. `Shutdown()` now stops and joins every receiver before the sessions and the library go. - Receivers still running at exit (no shutdown call) read connection and callback tables that static destruction had already freed. Those tables are now allocated once and never destroyed. - **`UltraNet_ParseUrl` accepts schemes libcurl does not speak.** It passed URLs to libcurl without `CURLU_NON_SUPPORT_SCHEME`, so `coap://`, `amqp://`, `sip://`, `rtp://` and `grpc://` URLs were rejected as invalid. The plug-ins for those schemes could never connect. `UltraNet_BuildUrl` had the same restriction. #### 2026-09-26 *0.9.63* - **A zero-width or zero-height ellipse no longer kills a window's drawing.** `RenderContextCairo::DrawEllipse`, `FillEllipse` and `Ellipse` scaled the context by the radii with `cairo_scale`; a zero radius is an invalid matrix, which cairo answers by putting the whole context into a permanent error state that `cairo_restore` does not clear - everything drawn afterwards in that window silently did nothing. ArtCreator's ellipse preview hit it on the first step of every drag. A flat ellipse now adds the line it collapses to (`FillEllipse` adds nothing), a point adds nothing, non-finite input is ignored, and the arc starts its own sub-path instead of joining whatever path was current. `Scale()` and `SetTransform()` already refused degenerate matrices. - **CorelDRAW files open complete: libcdr vendored and patched.** libcdr (0.1.7, the engine LibreOffice also uses) lost two things `media/vector/CDR/detailed.cdr` depends on, and LibreOffice shows the same broken result: bitmap transparency masks (drop shadows became black boxes, a cut-out logo overlay an opaque white sheet over everything) and PowerClip contents (the cards' leaves, waves and gloss - parsed, never drawn). The patched copy lives in `UltraCanvas/third_party/libcdr` (MPL 2.0; `README.md` and `ultracanvas.patch` document both fixes, meant for upstream) and is built by the CDR plugin when librevenge, lcms2, ICU, zlib and the Boost headers are present; the system libcdr remains the fallback. CI installs the Boost, lcms2 and ICU headers. - libcdr reads the 8-bit mask CorelDRAW stores after a bitmap (colour model 99) and re-encodes the bitmap as RGBA PNG. - libcdr reads loda argument `0x1f45` (PowerClip) and draws the clipped vect after its frame: an SVG image over the contents' box whose `clipPath` is the frame outline. - **The SVG importer reads `` and SVG images.** `clip-path` references become `VectorStyle::ClipPath` over `VectorClipPath` definitions; an `` whose href is `data:image/svg+xml` is read as an editable group placed by its box and `preserveAspectRatio`, its definitions renamed so nested documents cannot collide. - **The editing canvas resolves definitions.** `UltraCanvasVectorCanvas` drew layers through `VectorRenderer::RenderLayer` without the document, so clip paths (and gradients or symbols referenced by id) resolved to nothing and drew unclipped. `VectorRenderer::SetDocument()` is new; the canvas sets it around the layers. - **A fully transparent paint draws nothing.** `RenderContextCairo` set no source for a colour with alpha 0 and no pattern, leaving cairo's previous one - black by default - so a `fill-opacity="0"` shape was filled black (CorelDRAW writes stripes of them; one became a black bar across a card). - **Right click sets the background colour everywhere a picker offers it.** Choosing the background (line, secondary) colour with the right mouse button left the background swatch unchanged in three places. - `UltraCanvasColorSwatchBar` ignored every button but the left one. A right click now raises the new `onColorAdjustSelected(Color)` callback (the selection outline stays on the left-click colour); unset, right clicks are still ignored. Fast repeat clicks, which arrive as double-clicks, now select like single clicks. - A right-button drag in `UltraCanvasColorPicker` could end with the background swatch showing the foreground colour: a host that re-synced the foreground from `onBackgroundChanged` (ArtCreator does, from the selected shape) wrote it into the working state that was holding the background, and the release committed that. `SetColor` / `SetForegroundColor` during such a drag now set the saved foreground, and the release keeps the background the drag produced. - `SetBackgroundColor(c, notify = false)`: `notify` raises `onBackgroundChanging` / `onBackgroundChanged`. The built-in eyedropper's right-button sample and the swap arrow now report the new background through `onBackgroundChanged`; before, the swatch changed but the host never heard about it. - The swap arrow could leave both swatches the same colour. It reported the new foreground first; a host that re-syncs both swatches from its selection in `onColorChanged` put the old background back, and the swap then reported that. The swap now sets the background from the value it captured before notifying. - **A program could crash on exit after using file associations.** The association service's worker thread resolves applications and icons in the background; the service's destructor joins it, but only once the current lookup ends. The statics that lookup used — the desktop icon cache on Linux (`FindDesktopIconFile`), the icon cache directory on Windows and macOS, the sweep's extension list — were function-local statics first built on that thread, so they were destroyed *before* the service and freed under the running lookup. `FilerNameEncodingTest` printed `ALL PASSED` and then crashed in CI. They are now allocated once and never destroyed. The Linux backend's MIME/application index is a namespace-scope global, built before the service, and so already outlived it. - **More statics that background threads use now outlive them at exit.** An audit of every library thread that can still be running at exit found the same pattern in five more places. Each of these is now allocated once and never destroyed: - the NetworkMonitor name table and name listeners, used by the name-source workers; - its connection attribution, event listeners and recent-event ring, used by the event-source workers; - UltraDatabase's handle, prepared-statement and transaction tables and their mutex, which UltraMessage broker sessions journal through; the broker is stopped by an `atexit` handler that is registered before these tables are first built; - `JSONValue::NullValue()`, returned by every missing-key lookup, including those on UltraMessage threads; - UltraNet's c-ares channels (the default channel and the per-server-list map) and the empty server list, used by detached async DNS lookups. The per-server-list channels' comment already said "leaked on purpose", but the map destroyed them at exit. - **Imported SVG drawings keep their shapes, placement and text.** Opening an SVG in ArtCreator (or previewing one through the vector reader) lost most of an optimised file and misplaced the rest. Five defects in the shared vector code, each visible in the sample files under `media/vector/SVG/`: - `VectorStorage::ParsePathString` read path data with `istream >>`, so it broke on the compact form every optimiser and editor writes: numbers run together (`423.38-18.759`, `.95-.16.857`), repeated coordinates after one command letter, and arc flags without separators (`a1 1 0 01 5 5`). Such paths became runs of empty commands; `robot.svg` showed a few fragments of 632 shapes. The reader now follows the SVG path grammar (dot-decimal, locale-proof). - `VectorStorage::ParseTransformString` "skipped the comma" by reading one character after each number, which with a space separator ate the first digit of the next: `translate(483.572 574.049)` became `(483.572, 74.049)` and `scale(1 -1)` lost its sign. `rotate(a cx cy)` now turns about its centre. - The SVG importer left inherited paint unset — a shape without its own `fill` (black by default in SVG), or inside ``, drew nothing, because the renderer has no style inheritance. The importer now writes the inherited fill and stroke into each element; an explicit `none` is kept. - A transform on a top-level `` (which becomes a layer) and the viewBox's offset and scale were ignored; the importer now places them in a group inside each layer, so the drawing lands on the page (`photo-camera.svg`, Xara's `Logo_Texter.svg`). - `VectorRenderer` culled elements by comparing bounds in their parent's space against the viewport in document space, dropping whole subtrees of transformed groups; it now culls through the accumulated transform (also for ``). Text was drawn with its top-left, not its baseline, at the text position, and at 4/3 of its size (the context's font size is in points at 96 dpi, the model's in drawing units). - `Tests/SVGConverterTest` pins each case. - **`UltraCanvasFileLoader` loads and saves editable vector documents.** `LoadVectorDocument(path, error, notes)`, `SaveVectorDocument(doc, path, error, notes)`, `GetVectorLoadExtensions()`, `GetVectorSaveExtensions()` and `CanLoadVectorDocument()` read into and write from the shared `VectorStorage::VectorDocument` with the Vector plugin's converters. They go through the existing `VectorPreviewProvider` seam, which gains optional `Import` (with the reader's notes), `SaveExtensions` and `Save` members that `RegisterVectorFormatsPlugin()` fills in; core still links no reader. `UCImage` keeps opening `.svg` as pixels through librsvg. - **Every vector sample in the repository opens, and looks like its source.** Checked against independent references - each file's embedded preview (Xara, CorelDRAW), ezdxf (DXF), LibreOffice (CDR) - through `UltraCanvasFileLoader::LoadVectorDocument`: - **CorelDRAW files are read.** `CDRConverter::CanImport()` is true when the CDR plugin is built: libcdr's parse becomes SVG through librevenge's generator and the SVG importer turns that into the document, so a `.cdr` arrives as editable shapes (the three samples: 50, 400 and 725 objects). `ImportFromString` / `ImportFromStream` spool to a temporary file for libcdr. What libcdr drops stays dropped - in `detailed.cdr` the bitmaps' transparency and four card images' rotation, exactly as in LibreOffice. Windows builds still have no CDR plugin (no libcdr there). - **The readable and writable extensions are no longer hand-written lists.** `UltraCanvasVectorFormatsPlugin` keeps one converter table; `GetSupportedExtensions()` / `GetSaveExtensions()` collect the extensions each converter declares where `CanImport()` / `CanExport()` is true, and `CreateConverterForExtension()` picks from the same table. A reader that depends on an optional plugin appears exactly when it is built. New on the read list as a result: `cdr`, `svgz`, and Xara's `web`. - **`.svgz` reads.** `SVGConverter::Import` goes through `UltraCanvasFileLoader::LoadFile`, which inflates gzip transparently. - **Hairlines stay visible.** `VectorRenderOptions::MinStrokePixels` (default 1): no stroke is drawn thinner than one device pixel, as in a CAD viewer. A 0.25 pt pen on a plan 10,000 units wide shown at 7 % faded to nothing; the AI samples' 0.26 pt cutting outlines read as grey haze. - **A DXF whose declared extents are absurdly small or large is scaled like one without.** `millennium-falcon.dxf` declares 58 x 42 metres; kept as points that was a 2 cm page under 1 pt pens - solid black. Declared extents between 200 and 20,000 units are still kept as they are. - **Embedded images draw.** `VectorRenderer` passed an image's `data:...;base64,` source to `DrawImage` as a file path, so every embedded image (SVG, and every bitmap in a CorelDRAW file) drew nothing. It is decoded once, cached (cleared by `ClearCaches`) and drawn. - **Previews are the right size.** `RenderVectorDocumentPixmap` put the fit scale on the context and passed it again as `PixelRatio`, which the renderer applies on top: every Filer thumbnail and media-viewer preview of a drawing was drawn at the fit squared - a large drawing shrunk into a corner, a small one enlarged and cropped. - The AI documentation gains a *render trap* note: a `.ai` saved without PDF compatibility is a blank page to Ghostscript, poppler, ImageMagick and every PDF viewer, correctly, so they are no reference for it. - **CorelDRAW bitmaps keep their transparency.** CorelDRAW stores a transparent bitmap as a colour image followed by an 8-bit mask (colour model 99); libcdr reads only the colour image, so drop shadows became solid black boxes and cut-out overlays opaque sheets - `detailed.cdr`'s four business cards vanished under a white overlay. `CDRConverter` now reads the masks from the file (`Bitmaps.dat` in a ZIP-format CDR, inline in a RIFF one), matches each to the image libcdr produced, and re-embeds it as RGBA PNG. Pinned by `VectorFormatsPluginTest`. Six CMYK bitmaps in that file that libcdr places no object for (leaves, waves, the shield's gloss) are still missing. #### 2026-09-26 *0.9.62* - **R, Scala, MATLAB and VBA are switched on in the syntax highlighter.** Their rules were written but their `RegisterLanguage` lines in the `SyntaxTokenizer` constructor were commented out, and the four factory functions were never declared. They are declared and registered now, so the text editor highlights them and the Filer shows their files as text. - MATLAB claims only `.m`: `.mlx` (a ZIP) and `.mat` (binary data) are not source text. - VBA claims `.vba`, `.cls` and `.frm`, and no longer `.bas`. BASIC claimed `.bas` as well, and the extension lookup walks an unordered map, so which of the two won was left to chance. `.bas` is BASIC. - Scala also claims `.sbt` build files. - The demo app's text samples add `sample.r`, `sample.scala`, `sample.m` and `sample.vba`. - `.cls` and `.m` are shared: `.cls` is also a LaTeX class, `.m` also Objective-C. `SyntaxTokenizer::LanguageFromContent(extension, text)` (new, static) reads the head of such a file and names the language it really is, or "" when the extension is not shared or the text does not say. `SyntaxTokenizer::ClearLanguage()` (new) returns to plain text. - `UltraCanvasTextArea::SetProgrammingLanguageForFile(filename, text)` (new): the filename / extension match, with a shared extension settled by the text. A language without rules (LaTeX, Objective-C) leaves the text plain rather than coloured as the other language. - The Filer names such a file after its content ("LaTeX Text", "Objective-C Text"), reading the first 8 KB of a local `.cls` / `.m` once per size and modification time; the media viewer's text preview picks its highlighting the same way. #### 2026-09-26 *0.9.61* - **Legacy Word `.doc` files open with their formatting.** The reader used to pull out only the text: headings, bold, lists and tables were lost, and a table became tab-separated lines. It now reads the binary format's formatting tables (character and paragraph FKPs, the stylesheet, list definitions and overrides, table rows): headings, bold, italic, underline, strike-through, super/subscript, font, size, colour, alignment, bullet and numbered lists, tables with a header row, column widths and cell alignment, hyperlinks, page breaks and embedded PNG/JPEG pictures. `UCWordDocumentIO::LoadDoc` is the new name; `LoadDocText` remains as a deprecated alias. - **Numbered lists keep counting across the paragraphs between their items** in all three readers (ODT `text:continue-numbering`/`continue-list`, DOCX numbering instances, DOC list overrides), and honour start values and restarts. A document numbered "1. … note … 2." showed "1. … 1." before. New model pieces: `RichDocBlock::listStartNumber`, `RichListNumbering`, and `RichDocOrderedItemNumber()`, the one count that readers and `UltraCanvasRichTextEdit` share. Markdown writes the number, HTML writes `
  • `, ODT writes `text:start-value`. - **Tables keep their column widths and cell alignment** (`RichDocBlock::tableColumnWidths`, `RichTableCell::align`). They are read from ODT, DOCX and DOC, drawn by `UltraCanvasRichTextEdit`, written back by the ODT and DOCX writers, and kept in step when the editor inserts or deletes a column. - **ODT: a space between two styled spans is no longer lost.** tinyxml2 drops whitespace-only text nodes, so "**bold** red" imported as "boldred". The reader now keeps them and applies ODF's white-space rules itself. - **ODT: tables of contents and other indexes show their text** (the generated `text:index-body`), instead of being skipped. - **`UltraCanvasRichTextEdit`: list numbers and bullets take the item's text size and font.** An 11 pt list was drawn with 14 pt numbers. - **DemoApp: the OpenDocument page uses the WYSIWYG element.** Documents used to go through Markdown into a TextArea, which lost fonts, sizes, colours, alignment, list numbers and table layout. They now go straight to a read-only `UltraCanvasRichTextEdit`. - New `Docs/UltraCanvas/WordProcessingFeatureCoverage.md` lists what each reader, the model and the view support compared with OpenOffice/LibreOffice Writer, and orders the remaining gaps: paragraph indents and spacing, tab stops, cell borders, number formats, then page layout and positioned frames. - **Paragraph indents, spacing and tab stops.** The model gains left, right and first-line (or hanging) indents, space above and below, proportional line spacing, tab stops (left, centre, right, decimal) and a document default tab interval (`RichDocBlock::leftIndentPt` … `tabStops`, `UCRichDocument::defaultTabStopPt`). The ODT, DOCX and DOC readers read them from styles (with inheritance) and direct formatting. `UltraCanvasRichTextEdit` lays them out: a hanging indent, tab-aligned columns and each document's own paragraph spacing instead of a fixed gap. Both writers save them, and LibreOffice reads the saved files back with the same values. Enter carries the paragraph's geometry into the new paragraph. - **Symbol fonts become Unicode.** Text in Symbol, Wingdings 1–3 or Webdings, whether stored as the font code, as U+F000 + code, as DOCX `w:sym` or as a DOC `sprmCSymbol`, is mapped to the character it shows (☎ ✉ ✓ α ≥ …). The symbol font is then dropped, so the characters draw correctly where that font is not installed. A letterhead's Webdings phone and e-mail icons showed as boxes or stray letters. The table is generated from dingbat-to-unicode (BSD-2-Clause) by `scripts/generate_symbol_font_map.py`. - **ODT: font names resolve to their family.** A run said "Liberation Sans1" or "StarSymbol1" (the font-face declaration's key) rather than the font's family. - **DOCX: a lone space between two styled runs is no longer lost** (` `, dropped by tinyxml2), the same bug the ODT reader had. - `ITextLayout::SetTabs` (Cairo) now invalidates the measured extents, so a layout measured before its tabs were set is re-measured. - **Table borders and cell backgrounds.** Each cell carries its frame (width and colour per side) and fill (`RichTableCell::borderTop` …, `backgroundColor`), read from ODT cell styles, DOCX table styles, table and cell borders and shading, and DOC cell and table borders and shading. `UltraCanvasRichTextEdit` draws a document's table as the document frames it (`RichDocBlock::tableBordersFromDocument`), so a letterhead's borderless layout tables no longer show a grid. An editable view shows faint guides instead, as Writer does. Both writers save the frames, and LibreOffice reads them back identically. Tables written without document frames (from Markdown) now get a thin grid in ODT as they already did in DOCX, and HTML output carries the frames as CSS. Rows and columns added in the editor copy their neighbour's frame. - **List number formats, multi-level labels and document bullets.** Ordered items carry a number format (1, 01, a, A, i, I, none) and a label template in Word's `%1.%2)` notation (`RichDocBlock::numberFormat`, `numberTemplate`); unordered items carry the document's bullet (`bulletText`). The ODT, DOCX and DOC readers fill them, with symbol-font bullets mapped to Unicode. `FormatListNumber()` and `RichDocListLabel()` spell the label ("iv.", "b)", "1.2.") and `UltraCanvasRichTextEdit` draws it, lining a level's text up behind its widest label. Both writers save the labels, and LibreOffice reads them back identically; HTML output sets `
      `. Enter keeps an item's label format, and indenting takes the format of the new level. The DOCX writer now gives each list its own Word numbering. Before, all numbered lists in a document shared one list, so Word kept counting from one list into the next. - **Highlight, fixed line heights, paragraph borders and backgrounds.** Runs carry a highlight colour (`RichTextRun::highlightColor`); paragraphs carry an exact or at-least line height (`lineHeightPt`, `lineHeightAtLeast`) and a frame and fill (`paragraphBorderTop` …, `paragraphBackground`). The ODT, DOCX and DOC readers fill them, through style inheritance. `UltraCanvasRichTextEdit` draws them, joining consecutive paragraphs with the same frame into one box and adding the frame's room to the paragraph's space. Both writers save them, and LibreOffice reads them back identically. HTML output carries them as CSS. A paragraph frame inside a table cell fills in the sides the cell has none on, so a letterhead's rule under the sender line shows. - **DOCX writer: run properties in schema order.** `w:u` and `w:vertAlign` came before `w:color` and `w:sz`, which Word's schema does not allow. - **Table width and position, cell padding and vertical alignment.** Tables carry their width (points or percent), alignment and indent (`RichDocBlock::tableWidthPt`, `tableWidthPercent`, `tableAlign`, `tableIndentPt`); cells carry padding and vertical alignment (`RichTableCell::padding*Pt`, `verticalAlign`). The ODT, DOCX and DOC readers fill them, `UltraCanvasRichTextEdit` lays them out, and both writers save them; LibreOffice reads them back identically. A letterhead's address table and its rule are as narrow as in the original. - **`UltraCanvasRichTextEdit`: document lengths are scaled like the text.** Font sizes reach Pango as points at 96 DPI, but indents, tab stops, spacing, line heights, border widths and table sizes were drawn at one pixel per point, a quarter too small beside the text. They now use the same 96/72 scale. - **`UltraCanvasRichTextEdit`: the caret and clicks in a table cell line up with its text.** Text was drawn 4 px right and 2 px down from the cell's corner, but the caret and hit testing used the corner itself. - **Page layout: page size, margins, headers, footers and page numbers.** `UCRichDocument::page` (`RichPageSetup`) holds the page size, margins and header/footer distances; `pageFurniture` and `firstPageFurniture` hold the headers and footers, with another pair for the first page (`firstPageDiffers`); `RichTextRun::field` marks page number and page count fields. The ODT, DOCX and DOC readers fill them, and both writers save them (ODF page layout and master page with `style:header-first`, DOCX `w:sectPr` with header and footer parts and `w:titlePg`); LibreOffice reads the saved files back with the same pages, headers and fields. Headers and footers no longer end up in the body text. Markdown, HTML, plain text and the Filer preview show the first page's header and footer around the body. - **`UltraCanvasRichTextEdit::SetPageView`: pages like Writer's print layout.** The document's pages are drawn on a desk with their headers and footers, page numbers filled in per page, and the text column is the page's, so a table sized for the page fills it. Blocks move to the next page whole; a page break starts one. The DemoApp's OpenDocument page uses it. Outside page view the first page's header and footer sit above and below the body. - **Paragraph font for empty lines.** `RichDocBlock::paragraphFontSizePt` and `paragraphFontFamily` carry the paragraph style's font, or the paragraph mark's for an empty paragraph, from all three readers; both writers save them. `UltraCanvasRichTextEdit` measures blank lines and text without a size of its own with them. DOCX text now takes its paragraph style's size and font (and `w:docDefaults`), in table cells too. Table rows are as tall as their text, not at least the view's default line. - **`UltraCanvasRichTextEdit`: picture sizes are points.** Pictures from documents were drawn at one pixel per point, a quarter too small; they now use the 96/72 scale of the text. Pictures inserted in the editor are stored at their pixel size in points (a pixel at 96 DPI). - **Symbol fonts in headers and footers** are mapped to Unicode too. #### 2026-09-25 *0.9.60* - **Media viewer Details panel: image metadata, scrollable, laid out as Markdown.** `UltraCanvasMediaViewer::UpdateDetailedInfo` listed only the header facts (size, dimensions, channels, colour space, dpi, loader) and never read the file's own metadata, so EXIF, IPTC, XMP, ICC and PNG text chunks were invisible in UltraFiler and UltraViewer. It now adds a "Metadata" section from `PixelFX::Header::ReadMetadata`, one sub-section per block, or a line saying the file carries none. The details were drawn by `UltraCanvasMediaSurface` as plain text in a box fixed at 280 px, which the header facts alone already filled, and only over images. They now show in an `UltraCanvasTextArea` (`MarkdownHybrid`, dark theme) placed over the active view for every media kind: a heading per section over a two-column Property / Value table, scrolled by the wheel, Up / Down and PageUp / PageDown, closed with Escape. New `SetDetailsVisible()` / `ToggleDetails()` / `IsDetailsVisible()`; the surface's `SetInfoText()` / `ToggleInfoPopup()` / `IsInfoPopupVisible()` are gone (nothing outside the viewer used them). Docs: `UltraCanvasMediaViewer.md` §Details panel. - **Markdown tables in `UltraCanvasTextArea` keep short columns readable.** When a table was wider than the view, every column shrank in proportion, so one long cell (a path, a URL) squeezed a column of short labels until they broke mid-word ("Dimens-ions"). `NormalizeTableGroupWidths` now lets a column that fits its fair share keep its natural width and shrinks only the wider ones. - **PixelFX decodes IPTC and XMP into one row per tag** (`PixelFX/PixelFXMetadataDecode.h`, `Header::DecodeIPTC` / `Header::DecodeXMP`). libvips lists EXIF tag by tag but hands IPTC and XMP over as raw blocks, so `Header::ReadMetadata` - and with it the Details panel and `UltraCanvasMetadataDialog` - showed only "iptc-data: 56 bytes" and "xmp-data: 2988 bytes". IPTC is read from bare IIM (TIFF) and from the Photoshop APP13 "8BIM" wrapper a JPEG carries: record-2 datasets get their IIM names (Keywords, By-line, City, Caption/Abstract, ...), repeated ones are joined, dates and times are written 2026-09-20 / 14:32:11+01:00, and Latin-1 text becomes UTF-8 unless the block declares UTF-8. XMP is read with tinyxml2: every property of every `rdf:Description` as `prefix:Name`, written as an attribute or an element - language alternatives (x-default first), bags and sequences, structures (`prefix:Struct/prefix:Field`, arrays of them indexed), resources. A block that does not decode keeps its size row. The raw `exif-data` row is dropped once libvips has listed the EXIF tags, since it repeated them as "data: 518 bytes". Test: `Tests/PixelFXMetadataDecodeTest.cpp` (bare and wrapped IIM, encodings, truncated and lying blocks, every XMP form, malformed XML, value length cap). - **PixelFX writes EXIF values the way a camera app shows them** (`Header::HumanizeExif` / `Header::SplitExifString` in `PixelFX/PixelFXMetadataDecode.h`). `Header::ReadMetadata` showed libvips' raw strings, only trimmed: `28/5 (f/5.6)`, `51/1 30/1 0/1 (51)`, `0/1 ( 0)`, `ResolutionUnit: 1`. Now: `f/5.6`, `1/250 s`, `50 mm`, `ISO 400`, `-0.67 EV`, aperture and shutter speed converted from their APEX values, `LensSpecification: 24–70 mm f/2.8`, dates as `2026-09-20 14:32:11`, `Orientation: Rotated 90° clockwise` and the meaning of every coded number (`MeteringMode: Pattern`); a code libexif does not know stays a number. GPS reads `51° 30′ 0″ N (51.5°)`, `35 m` (or `below sea level`), `13:32:11 UTC`, `123.4° (true north)`, speed in km/h, mph or knots. `…Ref` and unit fields fold into the value they qualify (`XResolution: 300 dpi`); unset values (a `0/1` resolution, a digital zoom of 0), strip and thumbnail offsets are left out; the embedded thumbnail's fields are named `Thumbnail …` instead of colliding with the image's. The EXIF tags are formatted together because a value can depend on another field. `TrimExifAnnotation` is gone; `SplitExifString` replaces it. Tests: `Tests/PixelFXMetadataDecodeTest.cpp` gains the strings libvips 8.15 produces for a Canon JPEG, GPS above and below sea level, decimal minutes, and malformed rationals. - **Metadata tags carry names a person reads** (`Header::FriendlyTagName` in `PixelFX/PixelFXMetadataDecode.h`, applied by `Header::ReadMetadata`). The Details panel and `UltraCanvasMetadataDialog` listed technical keys: `DateTimeOriginal`, `FNumber`, `GPSLatitude`, `By-line`, `Caption/Abstract`, `dc:subject`, `Iptc4xmpCore:CreatorContactInfo/Iptc4xmpCore:CiEmailWork`, `jpeg-chroma-subsample`. They now read `Date taken`, `F-number`, `Latitude`, `Author`, `Caption`, `Keywords`, `Creator contact › Email`, `Chroma subsampling`, from tables of the common EXIF, IPTC and XMP tags; a tag without a name of its own is split into sentence-case words with acronyms kept (`SensingMethod` → `Sensing method`) and its XMP prefix dropped, IIM's title-case names become sentence case, and a PNG text chunk shows its own keyword. The Details panel also stops escaping parentheses, which showed as `Time zone \(taken\)` because `UltraCanvasTextArea` does not unescape inside bold. Tests: `Tests/PixelFXMetadataDecodeTest.cpp` gains the names, including the word splitting. - **The last raw metadata values are tidied.** XMP dates read `2026-09-20 14:32:11 +01:00` (a `Z` as `UTC`), `True` / `False` read Yes / No, and a rating reads `4 of 5` (`Not rated`, `Rejected`). IPTC, XMP and the Photoshop "8BIM" block that ImageMagick stores in a PNG as a "Raw profile type" text chunk of hex digits are decoded (`Header::DecodeRawProfile`) instead of shown as hex. libvips' own fields read as values (`Header::TidyOtherValue`): Progressive / Interlaced Yes / No, Loop count `Forever` / `Once` / `3 times`, Frame delays `100 ms per frame`, a GIF palette `16 colours`, the background `RGB 255, 255, 255`; `resolution-unit` is left out, and `orientation` when EXIF has it. A resolution of 25.4 dpi - libvips' stand-in when the file stores none - is left out of the Image group and the Details panel, and the panel writes `300 dpi` instead of `300 x 300 dpi` and rounds `95.9866` to `95.99`. Tests: `Tests/PixelFXMetadataDecodeTest.cpp` gains XMP values, raw profiles and libvips' fields. #### 2026-09-25 *0.9.59* - **A vertical toolbar from the builder is as wide as it was asked to be again.** `UltraCanvasToolbarBuilder` creates every toolbar at 800 x 48 before it knows the shape. Since toolbars started treating their constructed thickness as a floor (0.8.44), turning one vertical kept that 800 px width as its minimum, and `SetDimensions()` never replaced it: at the origin it only set the bounds, which layout overwrites, and away from the origin it set a size the old floor still beat. Texter's markdown side toolbar asked for 40 px and took half the window. The same stale floor held horizontal builder toolbars to at least 48 px, so a 24 px status bar could not be 24 px. The builder now sets the toolbar's thickness again, from the width or height it was given (or 48 when it was given none), after both `SetOrientation()` and `SetDimensions()`, in either order. `UltraCanvasToolbar::SetThickness()` is the new public call for this. `Tests/ToolbarThicknessTest` pins both cases. #### 2026-09-25 *0.9.58* - **The date picker's calendar is kept inside the window vertically, as it already was horizontally** (`UltraCanvasDatePicker::CalculatePopupPosition`). A calendar that fit neither below nor above its field was placed at a negative y, and what was cut off was its header - the month name and the arrows to change it. No other month could be reached. It is now pinned to the window's top edge in that case, so the navigation stays usable even where the grid covers the field. Found in UltraFIBU's start window, whose date field sat too low in a 440-pixel window; seen and checked under Xvfb before and after. - Not changed, and noted for a separate change: the calendar's "Today", "Clear", month and weekday names are fixed English strings (`UltraCanvasDatePicker.cpp`), with no way for an application to translate them. The placeholder and the first day of the week already are settable. #### 2026-09-25 *0.9.57* - **Syntax highlighting carries state from line to line.** The text area highlighted every line on its own, so only the first line of a `/* block comment */` (or `(* *)`, `{ }`, ``, `--[[ ]]`, … in the languages that have them) was drawn as a comment: the lines under it were coloured as code. The tokenizer now takes what the line above left open and returns what this line leaves open (`SyntaxLineState`, through the new `SyntaxTokenizer::TokenizeLine(line, state)`); the text area keeps the state with each cached line layout and rebuilds a line whose starting state changed, so typing or deleting a `/*` recolours the lines below it at once. - A line longer than 8000 characters is split into segments for layout. A string or `//` comment cut at a segment boundary now continues into the next segment; neither runs past a real line break. This matters most for minified CSS, which previously restarted the CSS scanner from a guess at every segment — dozens of times in a 294 KB Bootstrap build. - CSS keeps its place in the rule structure (selector list, declaration block, at-rule prelude, open blocks) across lines, replacing the per-line guess the CSS scanner used until now. - **Type names are drawn in the type style.** `GetStyleForTokenType` had no case for `TokenType::Type`, so words a language lists as types (`size_t`, `uint8_t`, `std::string`, Pascal's `Integer`, …) fell through to the default text colour and `tokenStyles.typeStyle` was ignored. The light and dark themes now give types their own colour. #### 2026-09-25 *0.9.56* - **CSS is highlighted by structure, not by a word list.** The text area — and with it the Filer / media-viewer preview of a `.css` file — coloured CSS with the generic tokenizer, which split `background-color` at the hyphen, knew a few dozen property names, took the `//` in `url(http://…)` for a comment that swallowed the rest of the line, and could not tell a selector from a declaration. A dedicated CSS scanner now colours tags, `.classes`, `#ids` and `:pseudo`s in selectors; properties (including `--custom` and `-vendor-` ones) before the `:`; values, `var()`/`calc()`/`url()` calls, numbers with their units, `#hex` colours and `!important` after it; and `@media`/`@font-face` preludes. A minified stylesheet on one line is scanned exactly; in a multi-line file each line starts from a guess based on its own braces and semicolons. The unused CSS keyword tables and the invalid `//` comment were removed from the CSS rules. #### 2026-09-25 *0.9.55* - **Password fields show the eye button by default.** `UltraCanvasTextInput` (1.6.0) now starts with `showPasswordToggle = true`, and so does `TextInputBuilder`: every field in password mode — `CreatePasswordInput()`, `SetInputType(TextInputType::Password)` and the framework's own password input dialog — carries the in-field "view hidden text" button, where before each caller had to ask for it and most did not. A masked field with no way to read it back turned every typo into a blind retry. Plain fields are unaffected, since the button is only painted in password mode; a field that must never show its text still calls `SetShowPasswordToggle(false)`. `CreateRevealablePasswordInput()` is kept and is now the same as `CreatePasswordInput()`. #### 2026-09-25 *0.9.54* - **Every source-text type is a Text format of the file display.** The Filer's extension table named 19 text types. Swift, Rust, SQL, Go, Kotlin, Java, PHP, Lua, Ruby, C#, CSS, Pascal, the assemblers and the rest of the syntax highlighter's languages were "Other": a blank sheet instead of the miniature page of their text, and no switch for them under Display > Thumbnails. - `SyntaxTokenizer::GetLanguageExtensions()` (new) lists every registered language with the extensions it claims. The highlighter is the one list of source-text extensions in the framework. - `UltraCanvasFilerWidget` classifies an extension that neither its table nor a registered plugin claims as Text when the highlighter knows it, and names its type after the language ("Swift Text"). `GetPreviewableFormats()` lists each one under Text, so a settings page offers a switch per type. Binary members of a language's list (MATLAB `.mat` / `.mlx`, gzip `.svgz`) are left out; a binary file under a text extension still previews safely, since the reader stops at the first NUL. - New test `FilerSourceTextFormatsTest`. #### 2026-09-25 *0.9.53* - **mDNS discovery works on Windows.** `Plugins/UltraNet/mdns` browsed with a raw `DnsQuery_W` for PTR records and stopped there. That names the services on the network and cannot say where any of them is, which is worse than it sounds: entries came back, every one of them without a host, and every one was dropped by the caller. Discovery looked implemented and found nothing. eSCL scanners were auto-discoverable on Linux and macOS and not on Windows. - **Browsing is half of DNS-SD; the other half is resolving.** A browse answers what is out there, a resolve answers where it is, and only the second produces something a caller can connect to. The backend now does both - `DnsServiceBrowse` then `DnsServiceResolve` per instance - and fills in `host`, `port`, `ip` and the TXT keys exactly as the Avahi and Bonjour backends do. - **The entry points are bound with `GetProcAddress`, not imported.** They arrived in Windows 10 1703. Importing them would stop the module loading at all on anything older and take the whole plug-in down with it, so an older Windows keeps the PTR-only query instead: names without addresses, which a caller skips. Verified rather than assumed - the linked DLL's import table lists only `DnsQuery_W` and `DnsFree` from `dnsapi`. - **The name arithmetic moved somewhere it can be tested** (`Plugins/UltraNet/mdns/MdnsNames.{h,cpp}`). Avahi and Bonjour hand back the instance, type and domain already separated; Win32 hands back one escaped wire name, and splitting that on `.` works until a device is called "Lab.Scanner". The splitting, the RFC 1035 unescaping (`\.`, `\\`, `\032`), the TXT formatting and the RFC 5952 address formatting live in a translation unit with no platform in it. - **The escaped name is what goes back to the resolver.** Unescaping first and re-joining asks about a different name - one label deeper - that no service answers to. `Mdns::ResolveNameFor` exists to say so in one place, and the test asserts the two forms differ. - A valueless TXT key is kept distinct from a key set to an empty value: DNS-SD uses the first as a boolean flag, Windows reports it as a null value, and `key` and `key=` are not the same record. - `Tests/MdnsNamesTest`: 46 assertions, none needing Windows or a network. - **Not yet run on Windows.** The translation unit compiles and links under CI's own defines and the tested half passes everywhere, but nobody has browsed a real network with it. Recorded in `Gaps.md` with the second thing found on the way: Bonjour puts the *escaped* instance in `dn` where the other two backends put the unescaped one. #### 2026-09-24 *0.9.52* - **"Namensänderung" was drawn as "Namens•nderung".** Two ways a name that is not UTF-8 reached the file display, and both are closed: - *Archives.* A ZIP entry without the UTF-8 flag is named in the DOS code page of the machine that made it - IBM437 by the ZIP specification, and what Windows Explorer, WinZip and older 7-Zip write, so "ä" is the byte 0x84. libarchive passes those bytes on untouched on Linux, so the VirtualFS listing showed U+FFFD and `ExtractAll` created a folder whose name was not UTF-8 at all. The libarchive provider now reads every entry name through one helper: libarchive's own UTF-8 conversion when it has one, the stored bytes when they already are UTF-8 (Info-ZIP on Linux and macOS write those unflagged), and otherwise IBM437 for ZIP and Windows-1252 for the other formats. Extraction writes that UTF-8 name to disk. - *The locale.* libarchive converts names through the C library, so in the "C" locale (a test runner, a service, a session without `LANG`) every non-ASCII name - Thai, Russian, Chinese, flagged UTF-8 or not - came back empty and `archive_read_next_header` answered `ARCHIVE_WARN`, which every loop in the provider took for the end of the archive. The provider now pins `LC_CTYPE` to UTF-8 for its thread while it reads, and a warning no longer ends a walk. - *Names already on disk.* A file named in a legacy code page (an old Latin-1 tool, an unzip that did not re-encode) is shown decoded by `UltraCanvasFilerWidget::DisplayNameOf` instead of as U+FFFD. The new `RepairLegacyEncodedName` / `IsWellFormedUtf8` in `UltraCanvasTextUtils.h` pick Windows-1252 or IBM437, whichever makes letters of the stray bytes, and leave UTF-8 - including decomposed (NFD) names - untouched. The entry keeps its real bytes for every file operation. The rename field opens on the decoded name, and an edited name is written as UTF-8. - New tests: `VirtualFSNameEncodingTest` (a hand-built ZIP with an IBM437 "Namensänderung/Grüße.txt" and UTF-8 Thai, Russian and Chinese entries, listed, read and extracted in the "C" and a UTF-8 locale) and `FilerNameEncodingTest` (the repair, `DisplayNameOf`, caption wrapping of Thai / Cyrillic / CJK names, and a real folder scan). - **The delete confirmation lists what is about to go, with icons.** Deleting a folder showed a wrapping grid of 64-pixel tiles for its first ten entries. Only image files got a picture there: a folder, a DLL or a certificate was an empty square over a name cut at eleven bytes, and several selected items were not shown at all. The dialog now has an `UltraCanvasListView` in the Details view's form: - Columns: icon and name, size, modified. The icon is the display's own (`DrawEntryIcon`, via a small list delegate), so every row gets the glyph or host icon the file display gives that entry. Sizes and dates are formatted as in the Details view. - Several items selected: the list is those items, with a caption that counts folders and files and adds up the files' size. One folder: the list is its contents, folders first and then by name (only the rows shown are stat-ed), with *Folder "X" contains N items (first 40 shown)*. A single file gets no list. At most 40 rows, ten visible, with a scrollbar; each row's tooltip is the full path. - **Delete asks "Move to the Trash" or "Delete permanently".** Every delete in the Filer widget used to be permanent - there was no trash at all, and Shift+Del did exactly what Del did. The confirmation now carries the choice as two radio buttons, and the line under the question follows it ("It can be restored from the Trash." / "This cannot be undone."). Del opens it on the trash, Shift+Del on the permanent delete, as in Explorer; the context menu gains **Delete Permanently** (Shift+Del) beside **Delete** (Del). - New `UltraCanvasTrash.h`: `MoveToTrash`, `TrashAvailable`, `TrashDisplayName`. Windows recycles through `SHFileOperationW` with `FOF_ALLOWUNDO`, and `FOF_WANTNUKEWARNING` makes the shell ask before an item the Recycle Bin cannot hold is destroyed. macOS uses `NSFileManager trashItemAtURL`, so Finder's Put Back works. Linux and the BSDs follow the freedesktop.org Trash specification 1.0: the home trash for files on the home drive, the drive's own `.Trash/$uid` or `.Trash-$uid` for a USB stick or second partition (never a copy across drives), names claimed with `O_EXCL` on the `.trashinfo`, and one `rename` per item. The trash itself, anything in it and a folder holding it are refused. Android and WebAssembly have none (`TrashAvailable()` false). - `UltraCanvasFilerWidget`: `FilerDeleteMode { MoveToTrash, Permanently }`; `DeleteSelection(preferred)`, `DeleteEntries(victims, preferred)`, `DeletePaths(paths, onDone, mode)` (default still Permanently: its caller confirmed), new `ConfirmDeletePaths` (the dialog for paths the display is not showing) and `CanMoveToTrash`. A trash move is one step per entry and asks nothing about write-protected entries; an entry the trash refuses stops at a "Cannot Move to the Trash" problem dialog and is never deleted for good instead. Where the trash cannot take the entries (inside an archive, on a remote drive, no trash on the platform) the trash option is greyed out and the dialog says why. - The confirmation's folder preview cut names at 11 bytes, which split a Thai, Cyrillic or CJK character; it now cuts at 12 characters, and the names in the dialog are shown decoded when they are not UTF-8. - New test: `TrashTest` (the freedesktop backend against a private `XDG_DATA_HOME`: files, folders, links, UTF-8 names, name collisions, refusals, and a second drive's `.Trash-$uid` when `/dev/shm` is one). - **`UltraCanvasFilerWidget::SetFileListEmptyMessage()`**: what an empty file list says in the middle of the display, instead of "No entries". A search can now explain an empty result: what it looked through, and what it left out. `ShowFileList()` resets it, so History and Favorites keep their "No entries". The empty-display notice (`DrawEmptyState`) draws a message of several `\n`-separated lines, each centred; before, it drew one line, however long. - **3D models on a comma-decimal desktop: FBX and DirectX .x did not load, PLY and DXF came out wrong.** Six model readers still parsed numbers with `atof` / `strtod`, which follow `LC_NUMERIC`. The Linux backend calls `setlocale(LC_ALL, "")` for keyboard input, so on a German, French or Italian desktop the '.' in "1.5" was not a decimal point. The Filer's 3D thumbnails and detail view, like every other viewer, showed the text FBX and the .x samples as nothing at all, and the PLY and DXF samples with their geometry scrambled. The same defect was fixed for OBJ and X3D in 0.9.42; the six readers it did not reach are fixed here: - PLY (`AsciiTokens`), DXF 3D (`Tag::Number`), COLLADA (`ReadFloatChild`) and STEP (`UltraCanvasStepFile`) read through `TryParseFloat`; the FBX and DirectX .x tokenizers through `ParseFloatClassic`, which for .x also bounds the scan by the buffer instead of letting `strtod` run past the end of a truncated file. - The STEP converter's two `snprintf` calls only build in-memory lookup keys, so they are marked `locale-ok` rather than changed. - `scripts/locale_numbers_baseline.txt` loses the seven entries. - New test `ModelLocaleDecimalTest` loads every text-based sample in `media/3D` (PLY, DXF, COLLADA, FBX, STEP, .x, OBJ, X3D, VRML) in "C" and in a comma-decimal locale, and requires the same mesh vertex for vertex. Against the old readers it fails six checks: FBX and .x fail to load, PLY and DXF differ. The standalone model tests link `UltraCanvasTextUtils.cpp` for the helpers. - **Extracting an archive could write files outside the destination ("zip slip").** `VirtualFSLibArchiveProvider::ExtractAll` joined every entry path to the destination as it was and set none of libarchive's secure-extract flags, so an entry named `../../.bashrc` landed outside the folder the user picked, and an archive that first extracted `link -> /etc` could then write `link/passwd` through it. UltraFiler's Extract reaches this with any archive the user unpacks. - Every entry path is checked before it is joined: an absolute name (and on Windows a drive letter or a UNC path) or any `..` component is refused. The entry is skipped, the rest of the archive still extracts, and the refused names are listed in `lastError`; the result is `InvalidPath` instead of `Success`. - libarchive's own guards run behind that check: `ARCHIVE_EXTRACT_SECURE_NODOTDOT` and `ARCHIVE_EXTRACT_SECURE_SYMLINKS` (no write through a symbolic link on disk). `SECURE_NOABSOLUTEPATHS` cannot apply, because the path handed over is always the absolute destination plus the entry. The destination's own symbolic links (macOS `/tmp` → `/private/tmp`) are resolved first, so only links the archive put there count. - Hard links: the target stayed relative to the archive root and resolved against the process's working directory. It is now held to the same rule and prefixed with the destination like every other path. - `ARCHIVE_WARN` from `archive_write_header` (an owner that could not be restored) no longer aborts the whole extraction, and an entry libarchive refuses (`ARCHIVE_FAILED`) is skipped and reported, not the end of the walk; the result is then `WriteError`. - The skipped entries reach the user. `VirtualFSManager::ExtractAll`, `VirtualFS_ExtractAll` and `UCVFSBridge::ExtractArchive` take an optional `std::string* outError` with the provider's own account: a heading line ending in ':' per kind of problem, then one entry per line, with the destination prefix trimmed from libarchive's reasons. It is an out-parameter rather than a shared "last error" because extractions run on worker threads. `UltraCanvasFilerWidget` shows it as an **Extraction Incomplete** dialog that lists the entries, and puts one sentence in the status line; the bare "Extraction failed for X" is left only for an archive that could not be extracted at all, and now carries the reason. - New test `VirtualFSExtractSafetyTest`: a hostile ZIP (`../escape.txt`, an absolute name, `a/../../escape2.txt`, a link out followed by a file through it) and a tar with a good and a climbing hard link, extracted from another working directory, plus a destination reached through a symbolic link. The hand-written ZIP builder is shared with `VirtualFSNameEncodingTest` as `Tests/VirtualFSTestZip.h`. #### 2026-09-24 *0.9.51* - **A file could be put onto a drive but never taken off one.** `CloudService` had `Upload` and no `Download`, although every provider - FTP, WebDAV, Nextcloud, Dropbox, OneDrive, Google Drive - has implemented `Download` since the module was written. The facade simply never exposed it, so the only way bytes moved was outwards, and the FTP transfer progress added in the last release reported a direction nothing could ask for. `Download` now sits beside `Upload`: it takes the full local path to write, because the caller is the one who knows what the file should be called and a provider inventing the name could not see a collision it was about to cause, and it checks that the destination folder exists first - every provider but FTP writes with an `ofstream`, which fails with nothing more useful than "cannot write" when the directory is missing. - **Dragging a file off a drive onto a local folder now copies it down.** `UltraCanvasFilerWidget` gained `remoteDownload`, the exact mirror of `remoteUpload`: the host is handed the remote paths and the local folder, queues the transfers and refreshes when the server has answered. Before this, the widget passed those entries to the local paste machinery, which handed `std::filesystem` an `ultracloud://` path no disk has - so the drag that most obviously means "copy this off the server" did nothing at all. A drop that carries entries from a drive *and* files from this disk at once - a selection dragged out of a drive pane and one out of a local pane - is split, and each half done its own way. - **`UltraCanvasFilerWidget::UniquePathIn` is public.** It answers what a "Keep both" paste would call a file in a given folder ("name (2)", with the extension kept on the end). A host that writes into a folder without going through the widget - saving a file fetched off a drive - needs the same answer, and a second implementation of it would be a second set of rules about what "(2)" means. - **The element catalogue was missing seventy elements, and now cannot be again.** `Docs/UltraCanvas/UltraCanvasUIElements.md` answers the question that comes before every piece of new UI - *does an element for this already exist?* - and it listed only the ~60 elements in `UltraCanvas/include/`. The ~70 under `include/Plugins/` got one sentence: "charts, diagrams and document views live under `UltraCanvas/Plugins/` with their own docs". That is not an answer to anyone searching the page for what they need: in 2026-09 a second progress bar was written from scratch for UltraFiler's status strip because `UltraCanvasGaugeDiagramElement` - the framework's progress bar, in `GaugeMode::LinearBar` - was in `include/Plugins/Diagrams/` and in no table. The duplicate was found and deleted, and the gauge got a row; the audit behind this entry shows it was three of seventy-five. - **Every plugin element is now catalogued**, in three tables under *Charts, diagrams and codes*: 34 chart elements (from line/bar/scatter/area through contour surfaces, spectrograms, Gantt and Kanban to the engine you derive a new chart type from), 26 diagram elements (flow, node and compositor graphs, UML, ER, SysML, mind map, Sankey, Venn, word cloud, packet layout and the rest) and the codes and document views - `UltraCanvasQRCode`, `UltraCanvasBarcodeElement`, `UltraCanvasPDFView`, `UltraCanvasMarkdownDisplay`. Each row says what the element is FOR, because the reader knows the need and not the name. - The vector format decoders (`UltraCanvasSVGElement`, `UltraCanvasCDRElement`, `UltraCanvasEPSElement`, `UltraCanvasXARElement`) are named in a closing note rather than given rows: they work behind `UltraCanvasVectorElement` and `UltraCanvasImageElement`, which are what a caller reaches for. Saying so is worth more than silence. - `UltraCanvasNewDocumentDialog`, missing from the dialogs table, turned up in the same audit and was added. - **`scripts/check_element_catalogue.py` keeps it complete.** An element in the tree that is not named on that page fails the check - the moment the author is best placed to write the one row that saves the next reader a week. A page with holes in it is worse than no page: it is read as a complete answer to "does this already exist?", and a hole reads as "no". Deliberate omissions (a base class, a decoder behind a catalogued facade, a platform implementation) go in `scripts/element_catalogue_exempt.txt` with their reason; there are six. Runs in CI as `element-catalogue.yml`. - **A drive's entries could be dragged out of the window and copied to the system clipboard, and neither gave the receiver anything it could open.** A path on a drive is `ultracloud:///`: it names a file on a server, not a file on this computer. `UpdateItemDrag` handed those straight to `StartNativeDragOfPaths` when the pointer left the window, and `EntriesToClipboard` mirrored them to the system clipboard as a `text/uri-list`, so another application would accept the drop or the paste and then fail on a path nothing there can resolve. - **The native drag is refused for them.** The gesture is not lost: it carries on as the widget's own in-window drag, which is where it can actually do something - dropped on a local folder it downloads. - **A copy puts the entry NAMES on the system clipboard as text**, rather than paths or nothing at all. The clipboard still has to be *taken* - a paste reads the system clipboard before the internal one, so leaving the previous copy's file list in place would paste those files instead of these - and text takes it while giving another application something usable. The paths stay on the widget's internal clipboard, which is shared between panes, so copy in a drive pane and paste in a local one works. - **Ctrl+V now does on the keyboard what a drop already did with the mouse.** `Paste` began with `RefuseWriteHere`, so pasting files INTO a drive was refused outright although dropping the same files on it uploaded them, and pasting a drive's entries into a local folder handed `std::filesystem` a path no disk has and did nothing at all. Into a drive is now an upload (through `remoteUpload`), out of one is a download (through `remoteDownload`), and a clipboard holding both kinds is split with each half taking its own route. Only the two genuinely unsupported cases still refuse: a paste from one place on a drive to another (no provider has a server-side copy) and pasting raw clipboard data - an image, text - as a new file on a drive. - **Cut and Duplicate are greyed out on a drive** instead of being offered and then refused. A cut is a move, and moving a file off a drive is a download followed by a destructive delete with nothing to undo it if the first half only partly arrived; a duplicate is a server-side copy no provider offers. A cut that ghosts the entries and then cannot complete is worse than one that never starts. - `RefuseWriteHere`'s message said a drive could be browsed and not changed. That stopped being true when uploads landed: it now names what a drive *can* do - files copied to and from it, renamed, deleted, folders created - so the refusal points somewhere instead of just closing the door. - **Assistant sessions must now say where the code ended up.** `AGENTS.md`'s *Reporting back* rules asked every reply that reports work to end with `## Next Task` and `## Other recommendations`, and neither of those says whether the work reached anyone. A session could write a feature, commit it, push it and describe it in detail while never mentioning that no pull request had been opened - and "done and pushed" reads as delivered, so a reader had no way to tell. That happened: three commits over two replies, 1130 lines, and the omission only surfaced because the user asked. A third block, `## Delivery`, now comes first and answers three questions in order of danger. **Is anything still uncommitted?** - these sessions run in a container that is reclaimed when the session ends, so an edit that was never committed is not pending, it is gone, and a reply describing it as written reports a delivery that never existed; a reply reporting finished work may not end with a tracked file uncommitted unless it says so in as many words. **How much, and is it pushed?** - files and +/- lines from `git diff --shortstat`, the branch and SHA, or that the commits are still local. **Is it a pull request?** - its number and state, or the words "no pull request". The numbers come from `git status --short` and `git diff --shortstat` run before the block is written, not from memory: the block exists to catch the gap between what the assistant believes it delivered and what the repository holds. Required whenever any code was written, including when the answer is unwelcome. `CLAUDE.md` carries the short form. - **And it is checked rather than remembered.** Every other rule in `AGENTS.md` that mattered got a script; this one governs what an assistant writes rather than what lands in the tree, so it gets a Claude Code hook instead. `.claude/settings.json` runs `.claude/hooks/check-delivery.sh` on `Stop`: a turn that would end with an uncommitted tracked file or an unpushed commit is blocked once, with the paths and commits listed. It refuses silence rather than unfinished work - stopping again after the message is allowed, so the assistant can commit, push, or say plainly what it is leaving behind. The same script runs on `SessionStart --brief`, restating the rule and reporting anything a previous session left behind. `.claude/settings.json` and `.claude/hooks/` are now **committed** - `.gitignore` excluded all of `.claude/`, and a cloud session clones this repository fresh, so a hook that is not in the repository does not exist for the next chat; personal session state stays ignored. The settings also pre-approve the read-only git commands the rule requires (`status`, `diff`, `log`, `rev-parse`, `fetch`, ...) and the repository's guard scripts, so measuring the answer is never what stops someone from giving it. #### 2026-09-24 *0.9.50* - **Spreadsheet: the fill handle fills.** The small square at the corner of the selection was drawn but dragging it did nothing. Dragging it down, up, right or left now shows a dashed outline of the range and, on release, fills it from the selection: two or more numbers continue as a series (1, 2 → 3, 4, 5), a text ending in a number counts on ("Item 1" → "Item 2"), formulas are copied with their relative references shifted (`=C2*$D$1` → `=C3*$D$1`), and anything else is repeated, formatting included. The fill is one undo step, and every formula is recalculated so totals reading the new cells update. - `SpreadsheetSheet::AutoFill` was a plain copy that nothing called; it now implements the above. New: `UltraCanvasSpreadsheet::AutoFillSelection` and the free function `ShiftFormulaReferences`. - A header sort now recalculates every formula too, so formulas outside the sorted block that read it are up to date. - New `SpreadsheetAutoFillTest`. - **Spreadsheet: sort a selected block from its column headers.** Select two or more rows and each column header over the block shows an up/down sort button, like the ListView's sortable headers. Clicking it sorts only the selected rows by that column; the other selected columns move with it, so every row stays together, and the title and totals rows outside the block stay where they are. Clicking the same button again reverses the order, the header shows the direction, and Ctrl+Z undoes the sort. - When the block contains formulas, the button first shows an OK/Cancel warning: sorting moves formulas with their rows but does not rewrite their references, so a row formula can end up reading another row. `SetSortFormulaWarningEnabled(false)` turns it off. - New API: `SortSelectionByColumn(column, order)`, `SetHeaderSortEnabled` / `IsHeaderSortEnabled`, `GetHeaderSortColumn` / `GetHeaderSortAscending`, `SetSortFormulaWarningEnabled`, the `onSelectionSorted` callback, and `SpreadsheetSheet::CountFormulaCells`. - DemoApp: the Spreadsheet page's hint and status line explain and report the header sort. - New `SpreadsheetRangeSortTest` covers the block sort. #### 2026-09-24 *0.9.49* - **New: per-call name servers for UltraNet DNS** (`UltraNetDnsOptions` in `UltraNet/UltraNetDns.h`; `UltraNet_DnsResolve` and `UltraNet_DnsResolveAsync` each gain an overload that takes one). `options.servers` names the servers a single lookup asks - "9.9.9.9", "9.9.9.9:5353", "[2620:fe::fe]:53" - and `options.timeoutMs` its deadline; a default-constructed value is the process default. Every backend honours it: c-ares runs the lookup on a channel of its own per distinct list (kept for the process, so a channel is never destroyed under an abandoned query, and a list that comes back reuses it), libresolv points a private resolver state at IPv4 servers on the given port, and dnsapi hands DnsQuery an IP4_ARRAY (IPv4, port 53; anything else is `Unsupported`, a new `UltraNetResultCode` appended after `Unknown`). A lookup with servers of its own bypasses the UltraNet cache and the getaddrinfo / getnameinfo paths, so A / AAAA go to the named server too and PTR goes out as the in-addr.arpa / ip6.arpa name. Two pure helpers come with it: `UltraNet_DnsParseServer` (an entry into address and port) and `UltraNet_DnsReverseName`. - **The libresolv backends answer A / AAAA, bound their retries by the deadline, and report a timeout as `Timeout`** - `retrans` is the deadline in whole seconds and `retry` one round, instead of the default 5 s x 2 tries x every server, and `TRY_AGAIN` maps to `Timeout` rather than `HostNotFound`. dnsapi maps `ERROR_TIMEOUT` the same way. c-ares maps "could not contact DNS servers" to `ConnectionRefused` instead of `Unknown`, and `UltraNet_DnsSetServers` keeps the ports of its entries (`ares_set_servers_ports_csv`; the plain csv call dropped them). - **The DNS deadline test is deterministic now.** `dns_resolve_honours_its_ deadline` asks a server that never answers - a UDP socket the test opens on the loopback and never reads - through the per-call option, so the only way back is the deadline and the c-ares assertion is `Timeout` again, exactly, offline, on every runner (0.9.33 had loosened it because a local caching resolver answered inside the millisecond). An unroutable address is not as reliable: a sandbox that rejects the packet outright answers "cannot contact" at once. New `Tests/UltraNet/test_dns_servers.cpp`: the entry parser and the reverse name, the validation both entry points apply before any query, a 1.5 s lookup at the silent server (Timeout on c-ares), and PTR through the reverse name; `UltraNetApiStatus` gains a per-call servers probe that proves the option offline the same way. #### 2026-09-23 *0.9.48* - **New: `UltraCanvasMessageCenter` — the desktop message centre as one element** (`UltraCanvas/include/Plugins/UltraMessage/UltraCanvasMessageCenter.h`, target `UltraMessageCenter`, `Docs/UltraCanvas/UltraCanvasMessageCenter.md`; UltraMessage proposal §11). Every chat, mail and system notification on the UltraMessage feed in one view, built from catalogue elements only: an `UltraCanvasSegmentedControl` for *All / Chats / Mail / System*, `UltraCanvasChip` filters (unread, one per service), an `UltraCanvasTextInput` search, an `UltraCanvasTreeView` of sources (conversations, mail accounts, applications with unread counts), an `UltraCanvasListView` of rows (unread mark, who, what, time) and a detail pane whose `UltraCanvasButton`s mark read / unread, dismiss, open, and invoke a notification's own actions. `Connect()` reads the journal and subscribes to the feed; it posts `feed.read`, `feed.dismissed`, `system.notification.dismissed` and `system.notification.action` back so sources and adapters stay in step. A chat or mail row mirrored from a notification stands in for it; a replacing message takes its row. `Ingest()` feeds rows without a bus; `onOpen`, `onUnreadCountChanged`, `onSelectionChanged`, `onError`; `MessageCenterStyle` hides the sources, detail, search or filters for a compact embedding. Catalogue row added. - **DemoApp: Message Centre page** (Extended functionality) hosting a private broker with an in-memory journal, seeded chats, mails and notifications, and a *Post another* button that adds live traffic. - **Tests:** `UltraMessageCenterTests` (in-tree, headless): the translation of feed messages into rows, sections / sources / filters / search, the mirror and replace rules, and the element on a private bus receiving live messages, reading the journal and answering with `feed.read`, `system.notification.action` and the dismissals. #### 2026-09-23 *0.9.47* - **A gauge's `LinearBar` can say "busy, total unknown".** `SetIndeterminate` drops the value entirely and slides a block along the track - a download whose server sent no length, a queue still being counted - where before the only honest option was to leave the bar at zero, which reads as progress that is stuck, or to hide it and say nothing. It animates on a timer the gauge owns, started and stopped with the flag and torn down with the element: a caller reporting bytes has nothing to report while the total is unknown, so a bar driven by those reports would freeze whenever a chunk was in flight. LinearBar only; other modes ignore it. - **A gauge's `LinearBar` fits the box it is given.** It is the framework's progress bar - "Horizontal or vertical bar (e.g. download progress)" - but it was sized only as a dashboard gauge: a caption over a 28 px bar with the value spelled out underneath, which needs some 114 px of height before any of it fits. In anything shorter it laid out for the height it wanted rather than the height it was given and drew its bar and its value outside the element, which is what kept it out of the one place a progress bar is most wanted - a status line, a list row, a panel footer, all of them twenty-odd pixels tall. Below the height its caption and value line need it now drops both, drops its side padding, and is simply the bar across the whole element. A gauge with the room to be a dashboard gauge is unchanged, pixel for pixel. - **`UltraCanvasGaugeDiagramElement` is in the element catalogue.** It was not, so `Docs/UltraCanvas/UltraCanvasUIElements.md` - the file every assistant and contributor is told to consult before building UI - offered a progress *dialog* and nothing else, and the gauge was findable only by already knowing its name. That is exactly how a second progress bar gets written. - **An FTP transfer reports its bytes.** `UltraNet_FtpUpload` and `UltraNet_FtpDownload` set up libcurl without a progress callback, so a file moving to or from a server was silent from first byte to last and nothing above them could draw a progress bar however much it wanted to. Both install one now, feeding the module's existing global transfer callbacks (`UltraNet_SetTransferCallbacks`) - the same bag every HTTP request already reports through, so a caller sets it once and hears about every transfer whatever the protocol. Listings and the one-shot verbs are left alone: they move too little for anyone to watch. #### 2026-09-23 *0.9.46* - **UltraCanvasFilerWidget: files dropped onto a remote folder are uploaded.** A new optional hook, `remoteUpload`, receives the paths dropped onto a remote folder shown in the widget (from another program, or from another display of the same window); the host puts them onto the drive and refreshes. Without it the drop went through the local paste path and was refused as "not a writable folder". Dragging a remote display's own entries onto one of its folder tiles is refused with a message that says so, instead of "not a folder". Used by UltraFiler 1.47.0. #### 2026-09-23 *0.9.45* - **Connection events carry the loopback chain.** `NetworkConnectionEvent` gains `loopbackRole`, `localPeer` and `forProcesses`, as on `NetworkConnection`: the registry fills them from the socket table it already attributes from (decoded by `NetworkMonitor_ListConnections`), remembers them with the process so a Closed carries what its Opened had, and the snapshot differ fills them from its own table and keeps a closing connection's chain from the read that still saw the peer's socket owned (`chainDecoded` says a source did). A tuple the table lacks - a connection younger than the table, as often as not - makes the registry read the table again, at most every 20 ms, which also attributes conntrack's NEW events better. The store records them with each event (schema version 5, migrated in place), its text filter matches the peer and the `for` list, and the events CSV gains `loopback_role`, `local_peer` and `for`. NetworkMonitor 0.9. #### 2026-09-23 *0.9.44* - **The activity store keeps loopback chains.** A recorded flow carries the chain its sightings decoded - `RecordedFlow::loopbackRole`, `localPeer` and `forProcesses`, the same as on `NetworkConnection` - so "what did the mail client fetch on Tuesday" has an answer although the mail server only ever saw the antivirus proxy. A sighting with a chain replaces the recorded one; a sighting without (the mirror socket already gone) keeps it. The daily totals keep the last `for` their flows carried (`DailyProcessTotal::forProcesses`), the text filter matches the peer and the `for` list on both, and the flows CSV gains `loopback_role`, `local_peer` and `for`. Schema version 4, migrated in place; a file from an earlier version reads back with no chain, as before. NetworkMonitor 0.8. #### 2026-09-23 *0.9.43* - **The dependency tables now list libudev.** IODeviceManager's Linux hot-plug watcher links libudev when the configure step finds it, and without it `StartMonitoring()` returns `BackendUnavailable`. Nothing said so outside `UltraCanvas/CMakeLists.txt`. `Docs/Dependencies.md` and the DemoApp's in-app copy (`UltraCanvasDependenciesExamples.cpp`) gain a *Hot-plug watching* row: libudev (optional) on Linux, no watcher yet on macOS or Windows. libudev is also added to the library-links table (LGPL 2.1, part of systemd). Its effect on DeviceExplorer is documented in that app's own docs. #### 2026-09-23 *0.9.42* - **The callback-cycle check now runs in CI, and the rule is written down.** `scripts/check_callback_cycles.py` shipped in 0.9.32 with nothing calling it, which is the same blind spot as a test no pipeline builds. `.github/workflows/callback-cycles.yml` runs it with `--strict` on every pull request that touches the roots it scans — `UltraCanvas/core`, `UltraCanvas/include`, `UltraCanvas/dialogs`, `Apps`, `SmartHome` — plus the script and the workflow itself. Triggers, path filters and the concurrency group mirror `ui-reuse.yml` exactly, including the base-branch list that covers stacked pull requests (`main` and `claude/**`): #455 once reached 1059 changed lines with no job running because that list said `main` alone. - **`AGENTS.md` states the rule** beside "Build UI out of UltraCanvas elements", where the next author is already reading, and in the house rules beside the line about running the UI check before pushing. A callback stored on a widget must not capture a `shared_ptr` to that widget or to a container above it; capture the back-reference raw. The entry says which captures are ownership rather than a cycle, so the rule cannot be read as "never capture anything". - Verified by reintroducing one of the 53 cycles that 0.9.32 removed: the workflow's exact command reports it and exits 1, and exits 0 again once reverted. On a clean tree it takes about five seconds over 1173 files, so it costs a CI slot, not a CI budget. - **The framework's version number is assigned on `main` now, not on the branch.** Line 1 of this file *is* the version — cmake reads it and every `project(VERSION …)`, compile definition and packaging script follows — so every branch wanted to write that one line, and two open at once always collided. On 2026-09-23 one branch was renumbered five times in a morning (0.9.23 → 0.9.27 → 0.9.28 → 0.9.29 → 0.9.31), each renumber throwing away a six-platform CI matrix, and 0.9.29 was consumed and lost in the churn. - **A branch now writes `Docs/UltraCanvas/changelog.d/.md`** — just the bullets, no header, no number. Two branches adding two files cannot conflict, and there is nothing to renumber when `main` moves. - **`.github/workflows/changelog-fold.yml`** folds whatever is pending into this file under the next patch version once it lands on `main`, and deletes the entries. `scripts/fold_changelog.py` does the same locally (`--check` to look without touching anything, `--version` for a release that must carry a chosen number). - **`build.yml` gained a `gate` job.** The merge commit still has the entry pending, so its line 1 is the *previous* release; building the release there would package new code under an already-published number. The gate skips the release build for that one commit and lets the fold commit — which carries the right number — produce the artifacts. Pull requests are never gated. - **`check_changelog.py` refuses a `####` header inside a pending entry**, since a number chosen on a branch is the collision the directory exists to end, and would otherwise be folded in verbatim as a second header. A hand-cut hotfix that must carry a specific number can still be written straight into this file as a top entry, held to the same rules as before. - `AGENTS.md` documents the flow where the old "pick the next number" instruction used to be. Application changelogs are unchanged: one product to a file, little contention. - **The locale-decimal defect, swept through the file formats.** `AGENTS.md` has warned since the CSS and SVG fixes that the remaining `std::stof` / `atof` / `snprintf("%f")` call sites are the same defect waiting to be reported. A census found 195, not the ~110 estimated — but most are chart labels and other text shown to a person, where following the reader's locale is *correct*. What was actually broken is every place a number crosses into a file format or a wire protocol, and those are fixed here. - **`UltraCanvas::FormatFloatClassic`** joins `ParseFloatClassic` in `UltraCanvasTextUtils.h`, promoting the helper the SVG converter had kept to itself. `std::to_string(1.5)` renders as `1,500000` under de_DE and `snprintf("%.6g")` as `1,5`; this formats as "%.6g" does with the decimal point pinned to '.'. - **Three writers were corrupting documents, not just misreading them.** `SerializeColor` wrote `rgba(255,0,0,0,500000)` — the alpha's comma is the channel separator, so the colour read back as a five-argument function. `SerializePathData` wrote `M 1,5 2`, which reads back as the point (1, 5): the exact defect fixed in the SVG converter and left here. The ODS formula writer emitted literals through an unimbued stream, and a comma there splits one argument into two. - **~40 readers now parse dot-decimal**: the CDR transform matrices and dash patterns (11 sites), the chart CSV loaders, the JSON readers in the compositor and node diagrams, the ODF/OOXML attribute readers, tone curves, templates, `rgba()` alpha, the spreadsheet formula tokenizer and metrics, and the Z-Wave and KNX `temperature` parameters. Most of them also **stopped throwing**: `std::stof` threw on malformed input in readers whose job is to survive a damaged file, and several had no `catch` at all. - **The CSV importer was undoing its own work.** It normalises the user's chosen decimal separator to '.' and then called `std::stod`, which read that back through `LC_NUMERIC` — so on a comma-decimal desktop a column of `1.5` imported as 1. - **Left alone deliberately**: text a person typed in their own locale — the numeric text input, the spinner, the colour picker, spreadsheet cell entry and filter values — and every label rendered for display. `AGENTS.md` draws that line and it is the right one. - **`scripts/check_locale_numbers.py` now enforces the rule**, and found what the sweep above missed — including a `std::strtod` in the vector storage arrowhead parser that the sweep's own grep had excluded, because its lookbehind rejected the `:` in `std::strtod`. A checker does not get tired at site 40. - It reports a locale-dependent read anywhere, and a locale-dependent write in a file that writes a format (Storage / Writer / Export / FileIO / Serializer / Converter, or anything under `DataFormats/` or `Vector/`), including a stream that is never imbued. - The stream rule skips any file that mentions `std::locale::classic` at all. The first version flagged the SVG converter — whose streams are correct, because every number goes through its own imbued `Num()` — so it was pointing at the reference implementation of the fix. - Text a person typed or reads says so at the site with `// locale-ok: `, and eleven such sites now do. They never reach the baseline; `scripts/locale_numbers_baseline.txt` is debt — 100 format and protocol sites the sweep did not reach (the OBJ, XAR, X3D, STEP and PDF converters, the LaTeX reader, the Linux hardware probe reading `/proc`, the xlsx reader) — and it should trend to empty. - `.github/workflows/locale-numbers.yml` runs it `--strict`, so a new one fails the build. Verified in both directions: adding a `std::stof` fails the gate, removing it passes. - **The OBJ and XAR converters are fixed rather than baselined** — 29 of the 100 sites, and the two where a misread number is a wrong drawing or a wrong model. OBJ's 17 `strtof`/`strtod` reads became dot-decimal, and its `ScopedPrecision` — the guard that shapes every number the OBJ and MTL writers emit — now pins the decimal point as well as the digit count, *before* its compact-precision early-out. Without that it wrote `v 1,5 0 2`, which every other OBJ reader takes as a different vertex, since OBJ separates components with spaces. XAR's ten `atof` reads (dash lengths, width profiles, stamp matrices) became dot-decimal, and its writer's `Num()` — the one place every number it emits passes through — uses `FormatFloatClassic`. The baseline is down to 34 keys. - **X3D as well, where the reader failed hardest.** On a comma-decimal desktop its `ParseNumbers` read *nothing at all* from `point="1.5 0.25 -2.75"` — the `.` is that locale's digit-group separator, so the very first token failed and the extraction stopped there, and every coordinate, transform, colour and key frame in the file came back empty rather than merely wrong. Both encodings share those parsers, so `.x3d` and `.x3dv` alike. Every number now passes through one of two stream types that carry the format's own locale, and the writer's `ScopedPrecision` pins the decimal point beside the digit count exactly as OBJ's now does. That last one also matters for whole numbers: `coordIndex` wrote the index 123456 as `123.456`, because digit grouping is the same locale's business. The baseline is down to 31 keys, 68 sites. - **Matter thermostat setpoints: the units were right, the range was not.** `SendThermostatCommand` takes whole degrees and multiplies by 100 for `OccupiedHeatingSetpoint`, which the spec carries in hundredths in an int16 — so the conversion was correct all along. But the parameter guard accepted the full int16 range, and `temperature=1000` became 100000 hundredths, which overflows the attribute. It is bounded to ±327 now, the range that survives the conversion, and both sides say which unit they are in. The facade still cannot express a half-degree setpoint; that is an API limit, noted where the conversion happens. #### 2026-09-23 *0.9.41* - **UltraCanvasFilerWidget: a remote folder on its way shows as loading, not as empty.** A new optional hook, `remoteListingStatus`, is asked when `remoteListing` answered with an empty listing; a non-empty answer puts a turning progress ring, "Loading folder" and the host's own status line ("Connecting to Backup NAS and reading /photos - 7 s") where "Folder is empty!" used to go, and the line follows the fetch on a 50 ms timer until the host's `Refresh()` brings the data. A remote listing the host refused (an unreachable server, a rejected login) now shows its reason in the folder area too, instead of an empty folder. Used by UltraFiler's FTP and cloud drives (UltraFiler 1.46.0). - **A mouse press hides the tooltip.** A click answers what the tooltip was for; before, a button whose click changed the layout under the pointer (UltraFiler's tree-dock button) left its tooltip floating over the new content until the mouse moved. #### 2026-09-23 *0.9.40* - **NetworkMonitor decodes loopback chains.** A mail client that talks to an antivirus mail proxy on 127.0.0.1:12993, which talks to the mail server for it, used to show as two unrelated processes. `NetworkMonitor_DecodeLoopback` pairs every connection whose peer is on this machine with its mirror - the socket on the other end, an IPv4-mapped spelling matched to its plain one - and fills `NetworkConnection::loopbackRole` (client or server: the server is the side a listener holds) and `localPeer` (the process on the other end) on both; on the outbound connections of a process that serves loopback clients it fills `forProcesses`, the applications that traffic is really for, an inference labelled as such. `ProcessTrafficSummary` gains `viaProcesses` and `servesProcesses`; `NetworkMonitor_ListConnections` decodes every snapshot before its filters; both snapshot CSVs carry the chain (`loopback_role`, `local_peer`, `for`; `via`, `serves`). `ProcessIdentity::Label()` is "name (pid)". Pure and tested from a fixture of a client, a proxy and its outbound connection. - **Windows names the processes it cannot open.** The IP Helper backend reads the Toolhelp process list once per snapshot - every PID's executable name, no handle and no elevation needed - and uses it for a process `OpenProcess` refuses, so an antivirus service reads as `AvastSvc` rather than `pid 4720`. The path and the user still need elevation, and the capabilities' note says so. #### 2026-09-23 *0.9.39* - **`UltraCanvasListView::onContextMenu(row, event)`** - a right-button press in the rows area, with the row under the pointer (-1 below the rows) selected alone first, as every desktop does, so the handler's menu acts on what the user pointed at. When set, the press is consumed; when not, a right press is handled like a left one, as before. The usual handler opens an `UltraCanvasMenu` of type `PopupMenu` at `event.pointerWindow`; the ListView page shows it. First consumer is UltraNetMonitor's process list. - **NetworkMonitor exports a snapshot as CSV.** `NetworkMonitor_ExportSummaryCsv` writes the per-process roll-up, one row per process with its distinct peers and hosts semicolon-joined; `NetworkMonitor_ExportConnectionsCsv` writes the connections, one row each with the process behind it. Both in the order given, RFC 4180 quoting, dot-decimal numbers, absent counters as empty fields, never zero. The quoting and the UTC timestamp the store's exports used move to `NetworkMonitorCsv.h`, shared by all four. Tested from fixtures. #### 2026-09-23 *0.9.38* - **NetworkMonitor connection events.** `NetworkMonitorEvents.h`: a connection reported as it opens, is accepted or closes, rather than found in the next snapshot - the event-rate collection the proposal asked for (§2.1, §5.2), so the connections shorter than a polling interval are in the record. Same shape as the name sources: an `IConnectionEventSource` implements it, `NetworkMonitor_RegisterEventSource` runs it, every `NetworkConnectionEvent` goes to the listeners (`NetworkMonitor_AddEventListener`) and into a bounded ring (`NetworkMonitor_RecentEvents`). On its way through, the registry names the peer from the name table and, for a source that reports no process, attributes the event from a socket table it refreshes a few times a second - in either orientation, so a tuple whose source is the remote side becomes an *Accepted* on the listener's process - and remembers the match, so the *Closed* that follows is attributed though the socket is gone. `NetworkMonitorCapabilities::connectionEvents` is true while a source runs. - **The snapshot differ** (`NetworkMonitor_CreateSnapshotDiffEventSource`): reads the socket table at an interval and reports what appeared and what went, with the process and the counters the table carries. Runs on every platform with a backend; misses connections shorter than its interval, and says so. - **nf_conntrack on Linux** (`NetworkMonitor_CreateSystemEventSource`, `OS/Linux/UltraCanvasLinuxNetworkMonitorEvents.cpp`): the kernel's connection tracker over `NETLINK_NETFILTER`, NEW and DESTROY, with the bytes each direction moved when accounting is on. Needs `CAP_NET_ADMIN` and a tracker that a firewall rule has activated; an idle tracker is reported, never silently empty. The message parser (`NetworkMonitorConntrack.h`) is pure and tested from captured bytes on every platform; the source never adds a rule. - **The kernel network ETW provider on Windows** (`OS/MSWindows/UltraCanvasWindowsNetworkMonitorEvents.cpp`): connect, accept and disconnect with the PID, and the sends and receives summed per connection into the *Closed* event's counters - the per-connection bytes the IP Helper backend cannot give. Elevated only; compiled on CI, not yet exercised at run time. Null on macOS. - **The store records events.** Schema version 3 (older files migrate in place): a `connection_events` table, `NetworkMonitor_RecordConnectionEvent` / `QueryConnectionEvents` / `ExportEventsCsv`, retention and purge cover it, `StoreStats` counts it. - Tests: the conntrack parser against a captured NEW and DESTROY, the registry's ring, listener, naming and both-orientation attribution against sockets the test opens, the differ reporting opened, accepted and closed for a loopback connection attributed to the test's PID, the platform source starting where it can, and the store's events. - A registry never holds its lock while asking a source a question, since a source may read the capabilities, which ask the registry. - **`UltraCanvasApplicationBase::RequestExitFromSignal()`** - the one call a signal handler may make. `RequestExit()` logs and runs the exit-request callback, neither of which is async-signal-safe, and the applications' handlers called it (and then `std::exit`, which ran the static destructors under live threads). The new call stores a lock-free flag; `RunOnce()` turns it into `RequestExit()` on the main thread at the next iteration, so `main` returns and the application's destructors run in order. UltraNetMonitor uses it; the other applications' handlers are unchanged and can adopt it the same way. #### 2026-09-23 *0.9.37* - **`UCEvent::ToString()` names the right event again.** The name table it indexes by `UCEventType` carried three entries with no enum counterpart (`KeyChar`, `Shortcut`, `WindowClosing`), so every event from `TextInput` onwards printed as the name of an earlier one - a `WindowResize` logged as `WindowCloseRequest`, a `Timer` as `Drop`. The three are gone, the table is now a compile-time array with a `static_assert` that its length equals the enum's, so the two cannot drift apart again without failing the build, and an out-of-range value prints `OutOfRange` instead of reading past the end. - **`scripts/check_changelog.py` refuses a runaway version number.** The guard required line 1 to be strictly above every other version in the file and above `main`'s, and nothing more - so when a renumbering script took the highest patch number across every minor in the file and wrote 0.9.120 over a `main` on 0.9.32, the check passed and that number would have become the released version. A new top entry must now be within ten of the release before it (open pull requests each hold one number, so a small gap is normal), and a minor or major bump must start near .0. Applied per file and, with `--base`, against the base's version. - **`scripts/check_changelog.py --base` no longer misreports files `main` changed during an uncommitted merge.** It decided "edited by this branch" by comparing the working copy with the merge base's, so in the middle of a merge of `main` every changelog `main` had released on since the fork "differed" and was reported as still claiming `main`'s version - a false alarm that vanished once the merge was committed, which the message did not say. A file identical to `main`'s copy is now never this branch's edit. #### 2026-09-23 *0.9.35* - **DemoApp: the ListView page's multi-column table shows the sorting API** (`Apps/DemoApp/UltraCanvasListViewExamples.cpp`). Table 2 used to copy and `std::stable_sort` its own rows on every header click. It now hands the view an `UltraCanvasListSortFilterProxy` in front of the model: File Name sorts naturally, and Size gets a column comparator that reads the number in front of "KB". A new **Sortable columns** checkbox next to the section title turns header-click sorting on and off. Turning it off restores the model's own order and clears the header triangle. The click and selection handlers now map proxy rows back through `MapToSource()` before they look up a file, so the status label names the right file while the table is sorted. - **DemoApp: the ListView page's subtitle no longer runs under the status box.** It was one 600 px line and the status box starts at x = 600, so the end of the sentence was hidden. It is now two lines, 570 px wide, and the status box stays where it was. - **`UltraCanvasListView.h`: removed an orphaned comment.** It said the view itself cycles a column's sort on a header click, and it sat above no declaration. The view never sorts: a header click only fires `onHeaderClicked`, which is what the surrounding comments say. #### 2026-09-23 *0.9.34* - **New: UltraNet's OAuth2 app registry** (``, `UltraNet_OAuth2SetApp` / `SetBuiltInApp` / `AddAppEnvPrefix` / `SetAppAlias` / `ParseAppsIni` / `LoadAppsFile` / `GetApp` / `HasApp` / `ClearApps`, `Masterfile_modules.md` §UltraNet). The client id, secret and redirect URI an application signs in as, per provider, in one place per process: Set() from code, then the environment (`ULTRANET_OAUTH__CLIENT_ID` and the prefixes modules add), then an INI file, then a build's baked-in default - a tier taken whole, never a secret from one tier under a client id from another - and then an alias chain. UltraMail's `OAuthApps` and UltraCloud's `SetOAuthApp` / `GetOAuthApp` / `HasOAuthApp` each carried a copy of this lookup with a different priority chain (UltraMail knew the INI file and the baked-in client, UltraCloud neither) and different environment names, so a Google client registered for Gmail was invisible to the composer's cloud picker two menus away. Both are profiles of the registry now, the way the app credential vaults became profiles of `UltraVault::DeviceKeyVault` in 0.9.23: UltraMail adds the `ULTRAMAIL_` prefix, loads its `oauth.ini` into the shared file tier and registers the baked-in client as the floor; UltraCloud adds `ULTRACLOUD_`, its `127.0.0.1:53682` redirect default, and the aliases `googledrive` -> `google` and `onedrive` -> `microsoft`, so one Google and one Microsoft registration serve mail, Drive and OneDrive when the consent screen carries the scopes. Every documented name and priority keeps working; a registration under the specific id wins over the alias at every tier. `UltraCloud::OAuthApp` and `UltraMail::OAuthApp` are the one `UltraNetOAuth2App` (the cloud struct's built-in redirect default moved into `GetOAuthApp`). Tests: `Tests/UltraNet/test_oauth2_apps.cpp` (six cases: tier order and whole-tier precedence, the built-in floor surviving `ClearApps`, prefix order, aliases with chains and cycles, the tolerant file load); the UltraMail and UltraCloud suites run unchanged. #### 2026-09-23 *0.9.33* - **Fix: `dns_resolve_honours_its_deadline` was red on the macOS Apple-silicon row of every build since it landed** (`Tests/UltraNet/test_dns_timeout.cpp`, from PR #514). The test asserted `Timeout` for a 1 ms lookup of a name under `.invalid`, but that runner's local resolver answers NXDOMAIN inside the millisecond, so c-ares reported `HostNotFound` - the deadline was met, not missed, and the assertion failed on the base branch (`main` at a916fe6b) as well as on every pull request that merged it. The test now accepts either outcome; a hang or any other code still fails, which is what it is there to catch. #### 2026-09-23 *0.9.32* - **The demo leaked its whole widget tree, and every callback in it.** A widget owns its callbacks, so a callback that captures a `shared_ptr` to that widget — or to any container above it — closes a cycle that neither end ever escapes: the refcount never reaches zero, and the subtree, its images and its render buffers stay allocated for the life of the process. 53 callbacks across 25 DemoApp files did exactly that (`[btn, ...]` on `btn->onClick`, and six that captured the container they had just been added to). Every one now captures the back-reference raw (`[btn = btn.get(), ...]`), which is valid for precisely as long as the callback can run, because the thing holding the callback is the thing being pointed at. Forward captures — a popup the lambda keeps alive, a sibling label, the `make_shared` state a toggle button counts in — are untouched: those are ownership, not a cycle. - The DemoApp is the framework's worked example, so the pattern was being copied outwards; `UltraCanvasDemo.h` now states the rule where the next author will read it. - `BuildScheduleSummary` (PERT examples) took its chart by `const shared_ptr&` and had one caller, a callback the chart owns. It takes a raw pointer now, for the same reason. - One capture in the table demo was of a container the lambda never used, in a body that is entirely commented out. It captures nothing now. - **`scripts/check_callback_cycles.py` now finds these**, because a sweep that is not enforced comes back. It reads each function's `AddChild` graph, so it catches a callback that captures a container two levels above it, not just one that captures itself — and it reports a capture only when that name is *demonstrably* a `shared_ptr` in scope (`make_shared`, a declared `shared_ptr`, or a factory whose declared return type is one, harvested from the headers). The first version matched names alone and called three raw pointers in Texter and UltraFiler leaks: `auto* editorPtr = editor.get()` and a `T* target` parameter own nothing. A name is not a type, so an unresolved one is left alone rather than guessed at. - Run against this release's parent it reports all 53, and against the tree as it now stands, none. `--strict` makes it a gate; a genuine exception opts out with `// callback-cycle-exempt: `, as the UI reuse check does. 4.6 s over 1168 files. - **Matter attribute writes read their numbers locale-independently.** `EncodeTextValue` turned the facade's text into a TLV value with `std::stoll` / `std::stod`, and `std::stod` consults `LC_NUMERIC` — which the Linux backend sets from the environment for XIM. On a comma-decimal desktop (de_DE, fr_FR, ru_RU, pt_BR) `"1.5"` stopped at the point and went to the device as **1**, and `"-0.25"` as **-0**: a silently different value than the caller asked to write, which is the failure mode the function's own comment says cannot happen. Reproduced under `de_DE.UTF-8` before the change and verified after it. Integers now go through `std::from_chars` and doubles through `ParseFloatClassic`, per the rule in `AGENTS.md`. - Both of the old calls also **threw** on input the character guards let through — `std::stoll("--")`, or a number too large for `int64_t` — unwinding out of the Matter SDK's write path. Neither replacement throws; text that is not a number after all falls through to the string encoding, where the device's schema check reports it as a failed write. - The float parse now has to consume the whole string, so `"1e"` is a string rather than the 1 that `std::stod` silently made of it. - **A mistyped Matter command parameter no longer throws, truncates or divides by zero.** `SendCommand` read its ten numeric parameters with `std::stoi`, and there is not one `catch` in the file: `endpoint=on` threw `std::invalid_argument` straight out of the call, and a long run of digits threw `std::out_of_range`. Where it did not throw it lied — the result was cast into the field's width unchecked, so `level=999` reached the device as **231** — and `colorTemp=0` reached `1000000 / kelvin`, an integer division by zero. This is not behind `ULTRACANVAS_WITH_MATTER`: it is in every build, reachable from `SendGroupCommand` too, which forwards the same parameters to every member of a group. - All ten now go through one `ReadIntParam`, which reads with `std::from_chars` (no throw, no locale) and checks the value against the range its field can actually carry — level and saturation 0..254, brightness and position percent, hue 0..360, endpoint and transition the uint16 range, thermostat temperature the int16 one. A parameter that is absent still leaves the caller's default; a bad one is reported through `ReportError(-302, …)` with the name, the text and the range, and the command is refused rather than half-executed. - `colorTemp` is accepted as 16..1000000 K, which is exactly the range whose mireds conversion (`1000000 / K`) lands in the uint16 field the device is given — and which cannot be zero. `mireds` still wins when both are supplied, and `brightness` still wins over `level`, as before. - **Stricter than `std::stoi` in two places, deliberately**: `" 3"` and `"3x"` were accepted before (it skips leading space and stops at the first non-digit) and are refused now. A device command is not the place to guess what half a number meant. #### 2026-09-23 *0.9.31* - **Seven ownership defects found by auditing every raw `new` in the tree.** A census of the 45 hand-written allocations outside vendored code (the rest of the framework allocates through `make_shared` / `make_unique`) turned up three leaks, one growing side table and three lifetime bugs. All are fixed here; the other 38 sites were already correct and are unchanged. - **`ZWaveProtocol::GetScenes` leaked its array on every call.** It allocated `new uint8_t[numScenes]` and then passed `&sceneIds` to OpenZWave's `GetAllScenes`, which allocates the array itself and assigns it through the out-parameter — that is why its contract asks the caller to `delete[]` the result, which the function already did. The buffer allocated up front was overwritten before anything read it. It now starts as `nullptr` and takes both the array and the count from `GetAllScenes`, which also drops the redundant `GetNumScenes` call. - **`UltraNet_TlsWrap` no longer keeps a second table of TLS contexts.** `g_ctxByHandle` was written on every wrap and never erased: it grew by an entry per TLS connection for the life of the process, and each entry outlived the `Ctx` it pointed at, so `UltraNet_TlsHandshake` or `UltraNet_TlsGetInfo` on a closed handle dereferenced freed memory instead of reporting `InvalidHandle`. The socket entry already owns that pointer and clears it in `UltraNet_SocketClose`, so both entry points now ask it through the new `ultranet_internal::GetTlsCtx` hook. The table, its mutex and the two includes they needed are gone. - **A synchronous DNS timeout hung the calling thread forever.** On expiry `Resolve` called `ares_cancel` while still holding the lock its `wait_for` had taken; `ares_cancel` answers the query it cancels on the spot, on the calling thread, so `OnHostCallback` re-entered that same mutex and the thread deadlocked against itself — with c-ares's worker stuck behind the channel lock `ares_cancel` held. Every caller of `UltraNet_DnsResolve` whose lookup did not beat the deadline stopped there. A one-millisecond deadline against an unresolvable name now returns `Timeout` eight times out of eight under ASan/UBSan, where the old code did not reach its second query. - **A timeout no longer takes every other DNS query down with it, or leaves c-ares writing into a dead stack frame.** `ares_cancel` cancels every query in flight on the shared channel, not just the one that timed out, and there is no per-query cancel to replace it with. So the query is abandoned instead, which needs the answer to have somewhere to land: the `Pending` was a local of `Resolve`, and c-ares's worker wrote into it after that frame was gone. It is a `shared_ptr` now — the caller drops its reference when it stops waiting, the query holds one until its callback answers, and the last one out frees it. This also retires the hand-written `delete p` on the async path, so a throwing user callback no longer leaks the state. - **The UltraMessage accept path closed the same descriptor twice.** When `MakeWakePipe` failed, `Listener::Accept` closed the accepted fd and then returned, letting `~Connection` shut down and close it a second time — by which point another thread may have been handed that number. The `Connection` owns the descriptor from the assignment onwards, so the explicit close is gone, matching what `ConnectToBus` already did. - **`UltraCanvasMathParser` and `UltraCanvasMathLayout` are no longer copyable.** Both hold a raw `Impl*` and `delete` it in their destructors with no copy operations declared, so any copy would have double-freed. No caller copies one today; the copy constructor and assignment are now `= delete` rather than waiting for one to. - **`UltraNetTests` now covers the deadline itself** (`test_dns_timeout.cpp`, three cases): that a one-millisecond lookup comes back at all, that the query it abandons leaves the shared channel usable for the next one, and that every asynchronous query answers its callback. None of it calls `UltraNet_DnsResolve` directly — each resolve runs on a thread of its own under a watchdog, because the failure being guarded against is a hang, and a test that hangs stops a CI run instead of failing it. When the watchdog fires the suite says so and exits non-zero rather than carrying on: the c-ares channel is a static whose destructor would block on the same lock at exit. Against the code as it stood before this release the first case fails in thirty seconds; against the code in it, all three pass in well under a second. - **Two demo buttons leaked their captured state.** The toggle and counter examples captured `new bool(false)` / `new int(0)` raw pointers in their `onClick` lambdas and never freed them. They are `make_shared` now — the DemoApp is the framework's worked example, so a leak in it propagates. #### 2026-09-23 *0.9.30* - **UltraCalendar proposal: the OAuth app registration is UltraNet's** (`Docs/Research/UltraCalendarDesignProposal.md`). The accounts section, the two gap tables and open question 3 described the Google / Microsoft app registration as UltraMail's baked-in client and named the two module lookups as two patterns; the shared OAuth2 app registry (`UltraNetOAuth2Apps.h`, 0.9.29) makes it one, so the proposal now says the calendar reads `UltraNet_OAuth2GetApp("google")`, adds an `ULTRACALENDAR_` environment prefix as its profile, and marks the question resolved. 0.9.29 is the registry's own entry, on its pull request. #### 2026-09-23 *0.9.28* - **New design proposal: UltraCalendar, a stand-alone calendar for ULTRA OS** (`Docs/Research/UltraCalendarDesignProposal.md`). A calendar *separate* from UltraMail - a headless `UltraCalendar` module beside a thin `Apps/UltraCalendar`, the split UltraCloud uses - that works with the calendar service the user already has, or with an ULTRA OS-hosted one, and lets the user decide which, on first run and again later. The investigation checks every need against the tree: UltraNet already has the custom HTTP verbs CalDAV needs (UltraCloud's WebDAV provider sends `PROPFIND` and `MKCOL` that way), the OAuth2 + PKCE flow, and DNS; UltraDatabase, UltraVault, UltraMessage and the date / time pickers are there; what is missing is an iCalendar engine, a CalDAV client, a Microsoft Graph client, and two elements - a day / week time grid and a month grid with events - which the proposal argues belong in the framework because `UltraCanvasCalendarView` is a date picker, not a schedule. - **CalDAV is the one client protocol.** The ULTRA OS cloud, Nextcloud, iCloud, Google (over OAuth2), Fastmail and the German mail providers are all presets over a single `CalDavProvider`; Microsoft, which has no CalDAV and is retiring EWS, is the single second implementation over Graph, converting to iCalendar at its edge so the store sees one format. The ULTRA OS cloud is therefore a standards server (CalDAV + CardDAV + WebDAV behind one ULTRA account) and the client needs nothing invented for it - UltraCloud's roadmap item 4, made concrete. - **Wrap libical**, never write an `RRULE` expander: the reference implementation, MPL-2.0 / LGPL-2.1 dual-licensed and packaged on all three CI platforms, behind an UltraCalendar-owned API that exposes no libical type. - **Local-first, as UltraMail:** a raw `.ics` per event beside an UltraDatabase index, an instance cache the views read, and a pending-change queue that is the outbox pattern for calendars. - **Copy, do not bridge, when migrating:** the wizard copies calendars with their UIDs intact into the target, then offers to keep the old account read-only for a grace period, disconnect it (never deleting on the old server), or keep both. Export to `.ics` is the same engine, so the door opens both ways. - **Invitations stay out of the calendar's process:** UltraMail shows the iMIP card and hands the answer over two new UltraMessage topics; server scheduling (RFC 6638) is used where the server has it. Reminders go out through the platform's notification API, which the Phase 2 adapters (0.9.27) mirror into the feed - the tree still lacks the outbound seam that raises a toast, and the proposal asks for it as framework code. - Found on the way: three civil-date types live in the tree (`UCDate`, `UltraCanvasCalendarDate.h`, `UltraFIBUDate`), and the ULTRA OS cloud service has no specification beyond a roadmap line - the proposal's section 8 is the client's requirement list for it. The per-account secret store the calendar needs is `UltraVault::DeviceKeyVault` (0.9.23), which UltraMail, UltraSocial, UltraFiler and EmailCleaner already share. #### 2026-09-23 *0.9.27* - **New: UltraMessage Phase 2, first slice — adapters and the first feeds** (`Docs/Modules/UltraMessage/README.md` §3.6, `Masterfile_modules.md` §13). The broker hosts *adapters*: broker-side plugins (`Internal::IAdapter`, `UltraCanvas/core/UltraMessage/UltraMessageAdapter.h`) that publish under their own verified identity and receive the feed's `system.notification.action` / `.dismissed` back. API `UltraMsg_ListAdapters`, `UltraMsg_EnableAdapter`, `UltraMsg_GetAdapterState` with `UltraMsgAdapterInfo` / `UltraMsgAdapterState` (status, message, remedy, mode); the switch is persisted in the journal (`adapters` table, schema v2); `ultramsg adapters [enable|disable ]`. - **New: `freedesktop-notifications` adapter** (Linux, `UltraCanvas/OS/Linux/UltraMessage/UltraMessageFreedesktopNotifications.cpp`, GDBus, built where `gio-2.0` is found): serves `org.freedesktop.Notifications` (`Notify`, `CloseNotification`, `GetCapabilities`, `GetServerInformation`, `ActionInvoked` / `NotificationClosed` back to the application) so every desktop application's toast becomes a `system.notification`; where GNOME, Plasma or dunst own the name it reads the same calls passively in monitor mode (`BecomeMonitor`), reporting `needs-permission` when the bus refuses. `im.received` toasts are mirrored to `messaging.message`, `email*` ones to `mail.message`, each with `mirrorOf`. - **New: `windows-notification-listener` adapter** (Windows, `UltraCanvas/OS/MSWindows/UltraMessage/UltraMessageWindowsNotificationListener.cpp`, C++/WinRT, built where the projection headers are found — CI's MSYS2 rows install `cppwinrt`): reads the Action Center through `UserNotificationListener`, polling every two seconds since Windows sends a desktop process no change event; every toast becomes a `system.notification`, what leaves the Action Center a `system.notification.dismissed`; a feed action clears the toast (the listener cannot press its buttons). `needs-permission` with the Settings remedy until the user allows access, re-checked without a restart. - **New: category guessing and shared mirrors** — `Internal::GuessAppKind` classifies an application by identity (Telegram, Signal, Slack, Teams … / Thunderbird, Outlook, Windows Mail, Evolution …) where no category hint exists, on Windows and for the many Linux applications that set none; the chat / mail mirrors moved to `Internal::PublishMirror`, shared by every notification adapter. - **New: UltraMail publishes new mail to the feed** — `UltraMail::FeedPublisher` (`Apps/UltraMail/engine/UltraMailFeedPublisher.{h,cpp}`): the sync workers hand it every stored envelope and it posts `mail.message` as `org.ultraos.ultramail` for unread, recent (7 days) mail, at most 100 per account per ten minutes. A no-op in a build without `UltraMessage`. - **Tests:** `Tests/UltraMessage` grows to 34 cases; on Linux the suite starts a private `dbus-daemon --session` and drives the adapter over real D-Bus (serving, mirrors, replace/close, actions signalled back, the switch, monitor mode with a rival owner). `Tests/UltraMail` gains the publisher's filter and rate-limit tests. The Linux CI row installs `dbus`. - **Build:** UltraDatabase's source list lives once in `cmake/UltraDatabaseSources.cmake` (`ultradatabase_sources( )`), used by the in-tree build and the standalone `Tests/UltraMessage` tree, so a new driver (the Postgres one broke the standalone link) is one edit. #### 2026-09-22 *0.9.26* - **A window minimised by the user now reports it.** `IsMinimized()` and the `onWindowMinimize` callback only ever reflected the application's own `Minimize()` call; a click on the title-bar button changed nothing, so an application had no way to notice it had been put away. The Linux backend now watches the ICCCM `WM_STATE` property and raises `WindowMinimize` when it becomes iconic and the new `UCEventType::WindowRestore` when it returns to normal; the base window updates its state on both and calls `onWindowMinimize` / `onWindowRestore`. The first consumer is UltraAuthenticator, which locks its vault on minimise. - **Configure no longer fails on machines with Clang installed.** The Linux compiler auto-detection built the C++ driver name with a `REGEX REPLACE` whose replacement used `\1` for an optional group; CMake 3.28 rejects that as an "out-of-range escape", so every configure that did not name the compiler explicitly stopped at line 59. The suffix is now matched separately and appended. #### 2026-09-22 *0.9.25* - **Every 3D sample audited for the fault the STL aeroplane had**, by measuring rather than squinting: each file of the E-45 aircraft was loaded through the same path the demo pages and the Filer's thumbnails use, and its silhouette profile matched against the export that draws correctly (the OBJ) over all 24 axis-aligned rotations. Ten of the thirteen agree. Two did not, and are fixed: - **`media/3D/PLY/E-45-Aircraft.ply` held Blender's Z-up coordinates.** PLY declares no up axis and this reader takes the format as Y-up - the convention of the tools that write it most - so the aircraft stood on its nose in every viewer that believed it. The sample is rotated into the Y-up frame, where it agrees with the OBJ export vertex for vertex; `Tests/ModelPLYTest.cpp` pins the new axes and says why. - **`media/3D/FBX/E-45-Aircraft-6.1-ascii.fbx` mis-declared itself.** Its GlobalSettings said UpAxis = Y while its geometry is Z-up (the mesh node connects straight to the scene, with no rotation to make up the difference), so it too came out nose-down while the binary FBX of the same scene was upright. The file now declares the Z-up frame it is actually in; no vertex is touched, and the reader is unchanged. - Three samples are left as they are, with what they are: - `X3D` and `VRML` hold the aircraft turned 180 degrees about its up axis - upright, facing the other way. No format says which way a model must face, so this is the files' own choice rather than a fault. - `XFile` comes through **mirrored**: its mesh nodes are drawn with a transform of determinant -1, because the DirectX .x format is left-handed and the reader deliberately leaves that reflection in the root frame (as its header documents) instead of converting to the right-handed frame the rest of the model pipeline uses. The aircraft is left-right symmetric, so the mirror reads as the model lying the wrong way up rather than as an obvious left-right swap. Converting on import (negate one axis, reverse the winding) is the fix, and it belongs to the reader rather than to the sample. - The `.dae`, `.blend` and `.abc` exports carry half a hull each, which is what they were exported as; `Tests/ModelPLYTest.cpp` already says so. #### 2026-09-22 *0.9.24* - **The hostel plan in the DWG demo was a black smudge in the corner of an empty sheet.** Two faults in one tile, both of them general. - **A lineweight is a plot width, and it was being scaled by the block it sat in.** The DXF/DWG reader resolves an entity's lineweight into points and the block's INSERT becomes a group transform, which then multiplied the pen along with the geometry. The hostel's elevations are inserted at 1054x, so their 1 pt pens came out 1054 units wide and painted a quarter of the sheet solid black. `Ctx::penScale` now carries the accumulated insert scale and `MakeStroke()` divides the width - and the dash lengths - by it, so a drawing strokes the same whatever scale its blocks are inserted at. `Tests/DWGReaderTest.cpp` checks that every circle in the synthetic drawing, inserted at 1x and at 2x, comes out the same width on the page. - **One forgotten speck decided the framing.** That drawing carries a 4 x 0.7 unit hatched scrap a quarter of a million units away from the plans, so fitting the union of everything (what `GetBoundingBox()` returns, and what AutoCAD's zoom-extents does) left the plans a postage stamp in the corner. `VectorStorage::ContentBounds()` is the same box with such specks left out - a run of drawables is ignored only when it holds at most 1% of them AND stands at least a fifth of the drawing's extent clear of the rest, so a frame, a title block or a legend always counts - and `UltraCanvasVectorElement`'s fit and centring use it. Nothing is removed from the document: the speck is still drawn, still exported, and still reachable by panning. - **The STL aeroplane stood on its back.** `media/3D/STL/Toy airplane model...stl` was exported with the model turned 180 degrees about X - its wheels at the top of the file's Z range and its wings at the bottom - so the Z-up correction every viewer applies stood it on its canopy. The sample is rotated to the orientation the format assumes (+Z up), which fixes it in the STL page, the media viewer and the Filer's thumbnails alike. The import path is unchanged: `UltraCanvasSTLLoader` still reads the file as written and still declares Z-up, and the page reports the same extents (114.05 x 79.49 x 55.69) as before. #### 2026-09-22 *0.9.23* - **The DWG / DXF demo page was five white squares, and so was everything below them.** Four separate faults, each of which hid the next. - **`UltraCanvasVectorElement` declared no CSS box.** Its `(identifier, x, y, w, h)` constructor called the identifier-only base constructor and then `SetPosition()`/`SetSize()`, which write `finalBounds` and nothing else - so the layout engine arranged the element as a widget that asked for no size, and it collapsed to nothing. A container never renders a child that does not intersect its content area, so the element was not drawn at all: not its document, not even its background. It now passes x/y/w/h to the base constructor like every other widget, which stamps the px size and the absolute origin. Callers that pass 0 (the flex/grid ones - the AI page, the media viewer, the plugin's own element) are unchanged. - **Painting used the parent's frame.** `Render()`, the background, the border, the debug box, the document transform, the hit test and the wheel anchor all added `finalBounds.x/y`, although the container has already translated the context to the element's origin and delivers pointer events in element-local coordinates. Everything is element-local now; `ScreenToDocument()`/`DocumentToScreen()` speak that frame too. - **A fit was clamped to the interactive zoom limit.** `ZoomToFit()` ran its computed scale through `options.MinZoom`, so a 10 000-unit site plan in a 280 px tile was pinned at 0.1 and the tile showed an empty patch of the drawing's middle. The fit is honoured as computed and becomes the lower bound for zooming out (`MinAllowedZoom()`), so a wheel-out still stops at the whole drawing. - **The element swallowed its host's events.** `OnEvent()` handled panning and selection and returned false for everything else without ever calling the base, so the demo's click-to-open-fullscreen and its hover status line never ran. The host callback is consulted first now. - **One collapsed transform used to end all drawing in the window.** The bathroom sample (`media/3D/DWG/bagno_3d_1.dwg`) carries a block standing in a vertical plane; projected to plan view its transform scales one axis to zero. Cairo latches a non-invertible matrix as a permanent error on the `cairo_t`, after which every later fill, stroke, text and image is silently dropped - which is why the DXF row, the info panel and the "How it works" panel below the drawings were blank as well. - `VectorRenderer` skips an element whose transform is singular (it has no area to draw), and - `RenderContextCairo::Scale/SetTransform/Transform` refuse a matrix they cannot invert and log it once, so no caller can kill a context this way. - `Tests/VectorElementViewTest.cpp` pins all of it: the box survives the parent's layout, a fit below `MinZoom` fits, painting is element-local, a singular transform leaves the context able to draw, and the host's event callback runs. #### 2026-09-22 *0.9.23* - **New: `UltraVault::DeviceKeyVault` — an application's own vault on UltraVault** (``, target `UltraVault`, reference `Docs/Modules/UltraVault/README.md`). One encrypted vault file in a directory the application owns, unlocked without a prompt by a random passphrase kept owner-only in `device.key` beside it (`TryAutoUnlock`) or by an explicit master password (`Unlock` -> `UnlockStatus`, which tells a wrong password from a build without crypto; `PersistDeviceKey` makes the next run silent); per-account `Store` / `Retrieve` / `Has` / `Remove`, an OAuth2 token set beside the password slot (`StoreOAuthTokens` …, `MethodFor` -> `SignInMethod`), and migration of the 0.1 XOR-sidecar format (`vault.key` + `creds.dat`) on the first unlock. A `DeviceKeyVaultProfile` — vault file name and key prefix in the `..` convention — tells one application's vault from another's. This is UltraMail's `CredentialVault` 0.6.0 moved into the framework: UltraSocial carried a copy of it that had never left the 0.1 format, so the two had drifted apart; both apps are now one-line profiles of the one class (UltraMail 0.10.2, UltraSocial 0.1.1). The legacy reader keeps a private Base64 decoder because UltraVault stays off the UltraCanvas library on purpose (the link-time split that keeps UltraCrypt UI-free). Covered in `Tests/UltraVaultTests.cpp`: locked-until-unlocked, first-run key + vault creation and reopen, profile-prefixed keys, no plaintext on disk, wrong / empty passphrase, token sets, the "vault without a device key must prompt" case, and the migration. - **UltraCloud keeps no secret files of its own any more.** `FileSecretStore` — obfuscated per-account files, XOR against a `cloud.key` beside them — was a third copy of the weak format UltraMail and UltraSocial had left behind, and it is gone: `VaultSecretStore` (in whichever UltraVault the application opened, under `cloud..*`) is the store, `MemorySecretStore` the process-lifetime one for tests and demos, and `MigrateLegacyFileSecrets` carries an old directory into a store once, deleting each file as its secret lands and the key file when none is left. UltraVault is a hard dependency of the module now (`ULTRACLOUD_USE_ULTRAVAULT` stays defined for consumers that test it). The UltraCloud suite covers all three (`Tests/UltraCloud/test_secrets.cpp`). UltraMail 0.10.2 and UltraFiler 1.44.1 are the consumers that moved. - **`scripts/check_changelog.py --base` now catches a stale number before the merge.** It compared the branch's entry only with the versions in the branch's own copy of the file, plus one rule against the base: not the same number as the base's line 1. A branch that picked the next number, was overtaken by releases on `main` and had not merged `main` since therefore passed - its file simply did not contain the newer entries - which is how this branch's own entry sat at 0.9.16 while `main` was on 0.9.20. The pull-request rule is now "strictly above the base's line 1", so both the shared and the stale shape are refused while the number is still cheap to change. `AGENTS.md` says to fetch `main` first, since the comparison is only as current as the local `origin/main`. #### 2026-09-22 *0.9.22* - **A container no longer scrolls unless it is asked to.** `ContainerStyle::autoShowScrollbars` now defaults to **off**. It defaulted to on, and most containers in this tree are not viewports: they are form rows, button bars, toolbar strips, cards and panes, laid out to fit. For those a scrollbar was never the answer to anything — it appeared because the content came out a pixel or two larger than the box, and then made it worse, because the bar narrows the viewport by its own track size and so fabricates an overflow on the other axis too. The pair was then drawn across the very row it was meant to be laying out. - **The default had already been written off three times in place** — the window's own style (`enableWindowScrolling`), the eBook reader's nested blocks, and the form grid each turn it off with a comment explaining this exact cascade — and a dozen more call sites turn it off by hand before it can happen to them (the toolbar, the album, the filer, the split pane, the audio bars, Texter's rows, UltraCleaner's cards, UltraNetMonitor's bars). UltraFiler's FTP login in 0.9.20 was the same defect once more. - **A real scroll view opts in**, with `CreateScrollableContainer` (unchanged: it sets the flag itself) or `autoShowScrollbars = true`. Three places in the tree are deliberate scroll views and now say so: the eBook reader's chapter pane and UltraMail's message body and its HTML host. The demo's scrolling text block already said so. - The opt-outs left at the call sites are no-ops now rather than load-bearing. They are not removed here: each is one line stating an intent, and a sweep that touches a dozen files to delete lines that do nothing is its own change, not a rider on this one. #### 2026-09-22 *0.9.21* - **NetworkMonitor names.** `NetworkMonitorNames.h`: the name-source plug-in point the proposal asked for (§2.3), and the sources behind it. `INameSource` is what a source implements; `NetworkMonitor_RegisterNameSource` starts it and feeds its observations to one *name table* (`NetworkMonitor_LookupName` / `ListNames` / `ObserveName`), where every address carries the name it was seen under and the `NameSource` it came from - `DnsProxy`, `EtwDnsClient`, `PacketCapture` and `Sni` are *observed* (a source saw the query), `ReverseDns` and `Inferred` are *weak*, and an observed name always beats a weak one, however old. `NetworkMonitor_ListConnections` fills each connection's `remoteName` and `nameSource` from the table when `NetworkMonitorOptions::resolveNames` is set (the default) and hands the peers nobody has named to the sources, so reverse DNS knows what to look up. Names outlive their DNS TTL - at least an hour - since a connection outlives the answer that started it. - **The local DNS proxy** (`NetworkMonitor_CreateDnsProxySource`): listens on 127.0.0.1, forwards every query to the upstream resolver unchanged over UDP or TCP and reads the answers on the way back, following CNAME chains so the address maps to the name the application asked for. Cross-platform, one thread, one `select()` loop; refuses an upstream that is itself. The wire format is `NetworkMonitorDns.h`: pure functions over bytes, every read bounds-checked, compression pointers that do not go backwards refused, tested from fixture bytes. - **Reverse DNS** (`NetworkMonitor_CreateReverseDnsSource`): `getnameinfo` on its own thread with a negative cache, never for loopback, link-local, multicast or (unless asked) private addresses; labelled weak. - **The Windows DNS client's ETW events** (`NetworkMonitor_CreateSystemDnsSource`, `OS/MSWindows/UltraCanvasWindowsNetworkMonitorDns.cpp`): a real-time session on `Microsoft-Windows-DNS-Client`, event 3008, the one source that reports the asking PID; needs an elevated token and says so. Null on Linux and macOS. Compiled on CI, not yet exercised at run time. - **The store records names.** Schema version 2 (a version-1 file migrates in place): flows and daily totals carry `remote_name`, a flow keeps the best name it was seen with, the text filter and the CSV include it, and a `dns_observations` table holds every observation a source reported (`NetworkMonitor_RecordDnsObservation` / `QueryDnsObservations`), one row per address, dropped by retention with the flows. `NetworkMonitor_AddNameListener` is how an app's recorder hears them. - `NetworkMonitorCapabilities::dnsWithProcess` is now true while a source that reports the process is running; `ProcessTrafficSummary` lists the distinct `remoteNames` it saw; `NetworkMonitor_NameSourceName` and `NetworkMonitor_NameIsObserved` name and grade a source. - Tests: the wire format, the table's precedence and lifetime rules, the listener, the reverse DNS filters, the proxy end to end over UDP and TCP against a fake resolver on loopback, and the store's names, the observations, the CSV, the roll-up and the version-1 migration. - The platform-glob exclusion in `UltraCanvas/CMakeLists.txt` now covers every `*NetworkMonitor*.cpp`, so the new Windows source is compiled once, into `NetworkMonitor`, and not into the core DLL. #### 2026-09-22 *0.9.20* - **A form caption is not something you scroll.** Every row of UltraCloud's add-account dialog - the FTP / SFTP login UltraFiler's "+ Drive" opens - was drawn with a scrollbar pair straight across its caption and its field. The dialog built a flex container per row and let the column shrink them: at 420 px it was a couple of pixels shorter than the rows it held, so each 32 px row was squeezed to 30, the 32 px control inside it no longer fitted, and the row (a plain container, auto scrollbars on) raised a vertical scrollbar - which narrowed the viewport by its own width and raised a horizontal one as well. - **The form is a `UltraCanvasFormLayout` grid now**, like every other dialog in the tree: captions share one `auto` column that is as wide as the widest of them (no more `kLabelWidth = 130`, so a longer translation widens the column instead of being cut off), controls share the `1fr` column and start at the same x, and the grid is `flex-shrink: 0`, so a short dialog can no longer squeeze a row below the control in it. The dialog is tall enough for the provider that needs every row, and a spacer holds the buttons at the bottom for the ones that do not. - **`CreateFormCellRow` no longer carries scrollbars either**, and the new `DisableScrollbars(container)` says it in one line for any container that only arranges what is in it. The container default is right for a pane that holds content, not for one that holds a layout. - The cloud file picker's "Account" and "Folder" rows went the same way, so the two captions line up without either carrying a width of its own. - `Tests/CSSLayoutFormGridTest.cpp` now pins the rule the dialog broke: a dialog shorter than its form leaves every row at its own height, where a flex row per field is squeezed below the control inside it. #### 2026-09-21 *0.9.19* - **`package-linux.sh` and `package-win.sh` looked in one place for executables.** Most targets land in the build root; a target that sets `RUNTIME_OUTPUT_DIRECTORY` to `bin/` was silently absent from the package, reported only as `skip (not built)` among the apps that genuinely were not built. Both scripts now look in both places, and a packaged app is no longer decided by which output directory its CMakeLists happened to pick. #### 2026-09-20 *0.9.18* - **Tables can be built and reshaped, not just filled in.** A document could hold a table, and the caret could edit its cells, but the table's own structure was fixed: there was no way to make one, add a row, or merge two cells. `UCRichDocumentEditor` gains `InsertTable`, `InsertTableRow`, `InsertTableColumn`, `DeleteTableRow`, `DeleteTableColumn`, `MergeTableCells` and `SplitTableCell`, and `UltraCanvasRichTextEdit` the caret-relative wrappers a menu calls (`InsertRowBelow`, `DeleteCurrentColumn`, `MergeWithCellRight`, `SplitCurrentCell` and the rest). Each is one undo step. - **The grid stays rectangular across every operation.** A span reaching across an insertion point grows instead of being cut in two - its text lives in one cell and cannot be in two places - and a span reaching into a deleted row or column shrinks. Where a span *started* in the deleted row, the cell moves down into the next one rather than being deleted with it, so what somebody typed in it survives. - **Merging keeps the text of every cell it absorbs**, appended to the surviving cell: a merge is a layout decision, and dropping the contents would be a silent deletion. A merge whose rectangle would cut an existing span in half is refused rather than approximated, because the model cannot store half a cell. - Deleting the last row or the last column deletes the table: one with no cells has nothing to type into and no way back. - **One grid walk, shared.** Cells are stored sparsely - a merged cell is one `RichTableCell` carrying a span, and the slots it covers hold nothing - so a cell's index within its row is not its column. `BuildTableGrid()` resolves which cell occupies each slot, and the element's layout now uses it instead of its own copy of the walk. Two implementations of "which column is this cell in" would drift, and a disagreement between layout and editing is a caret landing in the wrong cell. - **An edit that did not move the caret was not drawn.** Block layouts are cached and the rebuild pass only rebuilds the ones that have been invalidated - which, until now, only moving the caret did. Centring the paragraph the caret was already in changed the document and left the old layout on screen until something else moved the caret; the same was true of any format applied to the caret's own block, and of an undo that restored text without moving anything. The editing core now reports which blocks its last change replaced (`GetLastChangedBlocks`) and the element invalidates exactly those. Pinned by a test that centres a paragraph without touching the caret and reads back where the text actually landed. #### 2026-09-20 *0.9.17* - **UltraDatabase speaks PostgreSQL.** `core/UltraDatabase/ UltraDatabasePostgresDriver.cpp` plus `...PostgresSql.cpp`, registered the same way the SQLite driver is. Optional and soft-failing: without libpq the same source compiles to a stub and a `postgresql` connection reports that the driver is missing, which is a true answer rather than a link error. - **Two new driver hooks, because a transaction is not portable.** `BeginTransactionSql()` is `BEGIN IMMEDIATE` on SQLite and `BEGIN` on PostgreSQL; `RowLockSuffix()` is empty on SQLite and ` FOR UPDATE` on PostgreSQL. The second one exists because SQLite serialises writers and PostgreSQL at READ COMMITTED does not, so a read-then-write counter that is safe on one is a duplicate-key generator on the other - which is what two concurrent clients proved, handing out 40 distinct numbers in 80 draws. - **`datetime('now')` was SQLite-only and sat in the migration bookkeeping**, where every driver has to run it. It is `CURRENT_TIMESTAMP` now. - **The `?` -> `$n` rewriter is its own translation unit**, compiled whether or not libpq was found. It is pure string handling, and gating it on the driver would mean a machine without libpq ships it untested - while the mistakes it guards against (a `?` inside a literal, a comment or a dollar-quoted body) corrupt a statement that then still runs. - **The suite runs, rather than being built.** CI installs libsqlite3-dev and libpq-dev explicitly instead of trusting the runner image, builds `UltraDatabaseTests`, and fails the job if configure reports UltraDatabase without PostgreSQL - a soft-disabled module takes its own tests with it. - TLS defaults to `verify-full`, and the connection password must be a `vault:` key: a literal password in a config file is refused rather than used. #### 2026-09-20 *0.9.16* - **Depth for the vector model: booleans, ClipView, contour, blend, mould, bevel** - phase 5 of `Docs/Research/ArtCreatorVectorCanvasProposal.md` (its first slice); the application half is ArtCreator 0.3.0. - *Geometry* (`DataFormats/UltraCanvasVectorGeometry.h`, core): polygon booleans over paths flattened to polygons - `PolygonBoolean` / `PathBoolean` (union, subtract, intersect, exclude, each input with its own fill rule; a union with nothing normalises a self-crossing path) and `SlicePath` - as a planar-map clipper: every edge is split at every crossing, each piece classified by the winding numbers on its two sides, the separating pieces linked into consistently wound rings. `OffsetPolygons` / `OffsetPath` grow or shrink a set with round, mitre or bevel joins through the same clipper. `FlattenToPolygons`, `PolygonsToPath`, `PolygonSetArea`, `WindingNumber`, `PolygonSetContains`. - *Model*: three container kinds, all `VectorGroup`s (`IsGroupType`): `VectorClipView` (its first `Keyholes` children clip the rest and are not drawn), `VectorBlend` (`Steps` shapes interpolated between each pair of children, a `ColourBlendKind` run - fade, rainbow, alt rainbow, constant - the one-to-one, antialiased and tangential flags and Xara's profiles) and `VectorMould` (`Envelope` or `Perspective`: the children warped from `SourceBounds` into a four-sided `Shape` that starts at the source's top-left corner; `Warp`, `ShapeCorners`, `IdentityShape`). Two effects on `VectorElement::Effects`: `ContourEffect` (`Steps` rings `Width` out - or in, when negative - coloured from the fill to `Colour`) and `BevelEffect` (Xara's fifteen `BevelKind` profiles, `Indent`, `LightAngle`, `Tilt`, `Contrast`, `Outer`). - *Renderer*: a ClipView clips to its keyholes' union; a blend draws each child and the resampled, start-matched intermediates with their colours, strokes and opacity run; a mould warps every outline through a Coons patch or a projective map (text and images move to their moulded anchor); contour rings come from the offsetter (outward behind the object, inward over it; cached with the geometry); the bevel lights a distance transform of the silhouette shaped by the profile, inner or outer, as highlight and shadow masks (cached like the effect rasters; `EffectCacheSize` counts all three caches). - *Editing layer*: `CombineShapes` (Xara's Combine Shapes: `Add`, `Intersect` give one shape with the back shape's style; `Subtract` and `Slice` cut each shape with the front one, which is removed). `UngroupElements` dissolves the new containers too. - *XAR*: the plugin gives the five controllers real container nodes and parses their fields as Xara's own source writes them (the previous reader skipped the controller records, so their contents nested under the preceding object): `TAG_CLIPVIEWCONTROLLER` with the keyholes before the `TAG_CLIPVIEW` marker; `TAG_CONTOURCONTROLLER` (steps, width, blend type with the inset flag, four profile doubles) with its `TAG_CONTOUR` node carrying the contour colour; `TAG_BLENDPROFILES` + `TAG_BLEND` (steps, flags) with `TAG_BLENDER` / `TAG_BLENDERADDITIONAL` between the blended objects; `TAG_MOULD_ENVELOPE` / `_PERSPECTIVE` (threshold) with the `TAG_MOULD_PATH` shape and the `TAG_MOULD_BOUNDS` + `TAG_MOULD_GROUP` sources; the 24-byte `TAG_BEVEL` with its `TAG_BEVELINK` node. The converter reads them into the model (the mould shape re-ordered from Xara's bottom-left start) and writes them back the same way, the moulded results as plain warped paths so any reader shows them; Xara regenerates contour steps, blend steps and bevels from the controllers on load. Not verified against a Designer export: the repo's Xara samples carry none of these records. - *Tests*: `VectorEditTest` checks the booleans (areas, ring counts, containment, a holed square), the three joins, insets and the four combine operations; `VectorModelTest` checks each container and effect in pixels; `XARWriterTest` round-trips one of each through the plugin and the converter. #### 2026-09-20 *0.9.15* - **New: UltraMessage Phase 1 — the message channel is built** (`Masterfile_modules.md` §13, design `Docs/Research/UltraMessageDesignProposal.md`, reference `Docs/Modules/UltraMessage/README.md`). Library target `UltraMessage`, headless like UltraDatabase: `` with the `UltraMsg_*` surface — `Connect` (the first application to find no broker hosts one in-process; a lock file beside the socket decides the election), `Post`, `PostRecorded` (acknowledged by a subscriber or bounced to the sender after the ttl), `Request` / `RequestAsync` / `Reply` / `ReplyError` (exactly one reply or error, including when the target disconnects), `Subscribe` with `mail.*` / `*.message` / `#` patterns, `includeOwn`, `manualAck` and journal replay, and the journal calls `Query`, `Count`, `GetMessage`, `MarkRead` / `MarkUnread`, `Dismiss`, `Delete`, `ListConversations`, `SetRetention`, `Export`. - **The transport** is a Unix domain socket on Linux / macOS / BSD and a named pipe with overlapped I/O on Windows, both carrying the same length-prefixed JSON frames; the broker fills every message's sender from the connection it came on and marks it verified when the operating system's peer credentials agree (`SO_PEERCRED`, `LOCAL_PEERPID`, `GetNamedPipeClientProcessId`). One reader and one bounded writer queue per session, so a slow receiver drops and is told (`overflow`) rather than stalling routing. - **The journal** is an UltraDatabase (SQLite) file per user, written before fan-out for every notice on a persistent topic (`messaging.message`, `mail.message`, `system.notification`, or the `Persistent` flag), with conversations, attachments, read / dismissed state, per-pattern retention (defaults 90 days, 50 000 rows) and JSON-lines export. Only the broker opens it; endpoints reach it over the control RPC. - **Callbacks run on the UI thread** through the dispatcher an UltraCanvas application installs with one call, `UltraMsg_UseUltraCanvasApplication()` (``, header-only, wraps `PostToUIThread`); tools without an event loop drain them with `UltraMsg_ProcessPending`, and a subscription can opt onto the transport thread. `` adds the RAII `Endpoint` / `Subscription`, a `std::future` request, and the typed `MessagingMessage`, `MailMessage` and `SystemNotification` helpers for the well-known topics. - **`ultramsg`** (`Apps/UltraMessageCli`): `post`, `tail`, `query`, `conversations`, `endpoints`, `info`, `mark-read` / `dismiss` / `delete`, `export` — the two-process check of an installation. - **Tests:** `Tests/UltraMessage` (24 cases: codec and patterns, schemas, election and directory, delivery and targeting, recorded notices and bounce, request / reply and every error path, the journal, replay, lifecycle notices, the C++ layer and the helpers), hosting a broker on a private bus path over the real transport. Builds in-tree (`ULTRACANVAS_BUILD_ULTRAMESSAGE_TESTS`, now on in CI) and standalone where the UI library cannot be built. Clean under AddressSanitizer and UBSan. - Not in this phase, listed in the README: `AddFdWatch` event-loop integration, reconnection after the hosting broker exits, FTS5, the attachment spool, the Phase 2 adapters and the Phase 3 command surface. #### 2026-09-20 *0.9.14* - **The Alembic aircraft was half an aeroplane, and its canopy was inside the fuselage.** Two separate defects that looked like one: `media/3D/Alembic/ E-45-Aircraft.abc` was the last of the 2017 exports still missing its mirrored half, and the reader was dropping every Alembic transform. - The hull mesh in the `.abc` stopped dead at X=0 — 937 faces of the unevaluated cage, against the 7366 its siblings carry — because Blender exported it without applying the Mirror modifier, the same way the `.dae`, `.x`, `.fbx` and `.ms3d` were. Nothing available writes Alembic (the framework's writers cover 3DS, OBJ, PLY, STEP, COLLADA and X3D, and Debian's Blender is built without the exporter), so the archive was repaired rather than re-exported: its Ogawa tree was re-serialised with the hull's `P`, `.faceIndices`, `.faceCounts`, `N`, `uv` and `.selfBnds` replaced by the mesh evaluated from `media/3D/Blend/E-45-Aircraft.blend` with the whole modifier stack applied, the face set renumbered and the archive's `.childBnds` recomputed. Every other object, property, metadata string and time sampling is the bytes Blender wrote in 2017, and each new sample carries a real Alembic sample key — MurmurHash3 x64 128 over the payload, which reproduces the digest on every array the file already had. The demo page now reports 8110 faces and an extent of 1.95 × 4.19 × 6.12, the OBJ export's numbers. - `ReadXform` mapped Alembic's matrix into `ModelStorage::Matrix4x4` by reordering its sixteen doubles. Alembic is row-major *and* row-vector, so the translation is its last row; `Matrix4x4` is column-major *and* column-vector, so the translation is its last column. The two disagreements cancel and the correct conversion is a straight copy — the reorder put the translation in the bottom row, where `DecomposeTRS` never looks, so **every transform in every Alembic read by this framework lost its offset**. In the sample that put the glass canopy at the origin, sunk into the hull, instead of 1.53 up it. Documented as the third entry under "things that surprise people" in `UltraCanvasModelFormats.md`. - **The untouched export is now a fixture, like the other four.** `Tests/data/3D/Alembic/E-45-Aircraft.abc` is the 2017 file byte for byte, and `ModelAlembicTest` reads it for the assertions that pin the half hull — not one of which changed. The suite now takes `Tests/data/3D` and `media/3D` as its two arguments and adds `TestTheDemoCopy()`, which holds the repaired asset to the OBJ export's 8110 faces, to symmetry about X, to face-varying normals and UVs one per corner, to a winding that still agrees with the file's own normals, and to the same width, height and length as the OBJ within a percent. `TestSample()` gained the canopy's translation, which is the regression test for the matrix mapping. All 122 tests pass. - **Every AI session's report now ends the same way.** `AGENTS.md` gained a *Reporting back* section: a reply that reports work closes with a `## Next Task` block saying what happens next and who does it, and an `## Other recommendations` block listing defects found outside the change — each with its file and why it was not fixed there. Both are written out even when the answer is "none", because an explicit none is the difference between finished and forgotten, and the second block is explicitly not a place to park work that was asked for. `CLAUDE.md` points at it. - **The repair is reproducible.** `scripts/alembic/` carries the two scripts it took: `ogawa.py`, the Ogawa container — the Python counterpart of `UltraCanvasOgawaFile.cpp`, which reads an archive, verifies its sample keys and writes it back with chosen blocks replaced — and `replace_mesh.py`, which swaps one polygon mesh for a mesh evaluated from a `.blend`, converting Z-up to Alembic's Y-up and reversing every face to Alembic's winding on the way. The shipped `.abc` is now literally that tool's output, run on the fixture; the README gives the command. `ogawa.py dump` also prints any archive's tree, which is how the defect was found in the first place. Re-running it does not reproduce the file byte for byte — Blender's evaluation is not bit-deterministic, and about 1% of the corner normals come back differing by up to 1.2e-7 — and the README says so rather than implying a checksum will match. #### 2026-09-19 *0.9.13* - **NetworkMonitor records.** `NetworkMonitorStore.h`: an activity store over UltraDatabase (SQLite) that turns snapshots into *flows* — one row per connection across the snapshots that saw it, with first and last sighting, its latest state and counters, and the process behind it, deduplicated — and, past a retention window, into per-day, per-process, per-peer totals so the file stays small on a busy desktop. `NetworkMonitor_OpenStore` / `RecordSnapshot` / `QueryFlows` / `QueryDailyTotals` / `RollUp` / `ApplyRetention` / `Purge` / `StoreStats` / `ExportFlowsCsv`, every one returning `NetworkMonitorResult`, parameter binding only, one transaction per snapshot, and a per-store mutex so a recording thread and a reading thread never share the single SQLite connection at once. The same 5-tuple seen again more than two minutes after its last sighting starts a new flow, so a reused ephemeral port is not glued to an earlier conversation. `":memory:"` keeps a session off disk entirely. Without UltraDatabase in the build the store compiles to stubs that report `NotSupported`, and `NetworkMonitor_StoreAvailable()` says so. Tested end to end on an in-memory store: continuation and its cut-off, every filter, the CSV, the roll-up's accumulation onto an existing day, retention, purge. - `NetworkMonitorResultCode` gains `InvalidArgument` and `StorageError`. - The module links `UltraDatabase` from the block that defines that target, since it comes later in the file than NetworkMonitor's own. #### 2026-09-19 *0.9.12* - **Driverless network scanning, on all three platforms, from one file.** eSCL — Apple calls it AirScan, Mopria calls it Mopria Scan — is what a network scanner speaks when nobody has installed a driver for it. It is plain HTTP and XML, which is exactly why it was built before WIA, TWAIN or ICA: each of those is one platform's work for one platform's scanners, while this is one file in `core/` that serves Linux, macOS and Windows alike. It sits alongside SANE rather than replacing it — a USB scanner still needs a driver, a network one needs none, and the manager merges the two enumerators. - **The empty-feeder rule was already right.** eSCL says "no more pages" with a 404 from `NextDocument`; this module says it with `DeviceNotFound` from `DoScanPage()`, which `ScanPages()` reads as the end of a run rather than a failure — and only once a page has arrived, so a 404 on the very first page stays the error it is, because a job that produced nothing was a bad job and not an empty tray. The two were designed apart and agree exactly. - **A job covers a run, not a page**, so one is opened only when none is. A flatbed's job is closed as soon as its single page arrives: left open, the next scan would fetch from a spent job and read its 404 as an empty feeder on a device that has no feeder. - **`ScanCapabilities::Supports()` cannot be used to build a capability list**, and finding that out cost a bug. It answers "would this be accepted", and an empty list means the backend has not enumerated yet — so it says yes to everything. Using it to deduplicate while filling the list drops the first entry, after which the list is still empty and so drops every entry. Worse, the tests written against `Supports()` then pass on an empty list. The parser uses `std::find` on the vector and the tests assert against the vectors. - Two translation units, as the printer path has: the units, the colour-mode names, the capability document and the job URL are pure data and live in `...ESCLProtocol.cpp`, which the tests link without UltraNet or the image stack. eSCL measures in three-hundredths of an inch against this module's hundredths of a millimetre, and the conversion rounds to nearest both ways, because a scan area is derived from a paper size and handed straight back — truncating twice leaves A4 a millimetre short. - The capability XML is namespace-prefixed and the prefix is the vendor's choice: one scanner writes `scan:ColorMode`, another `escl:ColorMode`. tinyxml2 does not strip prefixes, so every lookup matches the local name after the last colon — covered by a test that reparses the same document with every prefix changed. - `Tests/IODeviceScannerESCLTest`: 62 assertions, none needing a scanner. #### 2026-09-19 *0.9.11* - **GutenPrint printing works, and the framework is still MIT.** GutenPrint drives several thousand inkjet and dye-sublimation printers far better than their own generic drivers, which is why the renderer/transport split was built to accommodate it in the first place. The obstacle was never technical: `libgutenprint` is GPL-2.0-or-later, so linking it would make every distributed binary a GPL work. - **So it is run, not linked.** GutenPrint ships its own programs, and between them they are a complete interface: `gutenprint.5.3 list` names the ~3,500 models it drives with each one's IEEE-1284 device id, `gutenprint.5.3 cat` emits a model's PPD, and `rastertogutenprint.5.3` reads a page of CUPS raster and writes the printer's own command language. Running a program is not linking against it. This is the same treatment QEMU and Wine already get here, and it is recorded that way in `Docs/Dependencies.md`, `master_dependencies.yaml` and `THIRD_PARTY_LICENSES.md`. - **One renderer class, no transport change.** What comes back from the filter is a device-native stream, so it goes out as a raw job — the CUPS raw path on Linux and macOS, datatype `RAW` through the Windows spooler. Both already existed. That was the point of separating the renderer from the transport, and this is the first time the claim has been cashed. - **A page is drawn, not converted.** `RasterPageTarget` draws an `IPrintPageSource` onto an off-screen surface, so the same wrapped text, the same fitted image and the same pagination the Windows GDI path uses serve here too. Deciding what a job *contains* moved into `MakePageSourceForJob` as well, so the two renderers cannot drift about which extensions are text. - **`IPrintRenderer::Render()` now receives the printer.** It did not, and a renderer that emits a device's own command language cannot work without knowing the device — GutenPrint has to pick a model before it can produce a byte. Passed rather than remembered from `SupportsPrinter()`, because one renderer is shared between the devices that register it and leftover state would be the wrong printer's. - **A reusable way to run a program and keep what it says**, as `RunProcessCaptured()` beside the existing detached launcher. It takes an argument **list** and executes the program directly — `execvp`, or `CreateProcessW` — so no shell ever sees it and there is nothing to escape. The prototype this module replaces built a command line by pasting a device path into a string and handing it to `popen()`. - **It pumps input and output together, and that is load-bearing.** `poll()` reporting a pipe writable means one byte is free, not 64K, so a blocking write parks in the kernel until the child drains it — and if the child is meanwhile blocked writing output nobody is reading, neither side moves again. Both processes sat in `anon_pipe_write`. The pipe ends are non-blocking now. The bug appears only once the data outgrows a pipe buffer, which is to say on every real page and on no small test. - GutenPrint is handed RGB and left to do its own colour separation: matching an ink set at a resolution is the one thing it is unambiguously better at. The raster is uncompressed (`RaS3`) because it travels down a pipe to a filter that reads it immediately, and a run-length encoder is wrong in ways that surface on one printer at one resolution. - **macOS does not implement `sigtimedwait`.** The SIGPIPE drain used it and broke the macOS build; it uses `sigwait` now, and only when a write has actually reported `EPIPE`. That second part is not tidiness: a SIGPIPE from `write()` is directed at the calling thread, so having seen `EPIPE` proves there is one pending for *this* thread and `sigwait` returns at once. Deciding from `sigpending()` instead would also match a process-directed SIGPIPE meant for another thread, and if that one were consumed elsewhere in between, the wait would never return. - `Tests/ProcessRunnerTest` (POSIX): 16 assertions over the three ways a process runner goes wrong and only at scale - 64 MB written into a closed pipe without dying, 64 MB through a filter reading and writing at once without deadlocking, and shell metacharacters reaching the program as text. - `Tests/IODevicePrinterTest`: 173 assertions, up from 157. Parsing GutenPrint's listing and matching a printer to a model are string work with no tools installed, so they live in a translation unit the tests link and run on every arm of the matrix — including that an R2400 is never handed the R200's driver, and that an unknown printer matches nothing rather than something close. #### 2026-09-19 *0.9.9* - **LaTeX Documents, XAR Images and EPS Images read as fully implemented in the demo tree.** All three carried the blue "partially implemented" icon because each one's own documentation opened with that phrase - but the phrase was about *format coverage*, not about the demo pages or the elements behind them, which are finished and drive their shipped sample corpora (`media/vector/XAR`, `media/vector/EPS`, `media/LaTex`). The tree's icon answers "can I use this?", and for all three the answer is yes. - **The three documents now say the same thing as the tree.** `UltraCanvasXARExamples.md` and `UltraCanvasEPSExamples.md` opened with "XAR support is partially implemented" / "EPS support is partially implemented", and the demo's documentation button on those very pages opens those files - so a reader met a green tick and a "partially implemented" in two clicks. Both overviews now lead with what the plugin does, and every per-format gap is kept, moved to where a reader hits it when it matters: XAR's effect nodes (`XARBlendNode`, `XARMouldNode`, `XARBevelNode`, `XARContourNode`, `XARFeatherNode`, `XARLiveEffectNode`) are parsed but not painted, and EPS keeps its *Known gaps* section untouched. Nothing was promoted that is not implemented; only the leading verdict changed. #### 2026-09-19 *0.9.7* - **Xara-class effects in the vector model, renderer and XAR converter** - phase 4 of `Docs/Research/ArtCreatorVectorCanvasProposal.md`; the application half is ArtCreator 0.2.0. - *Model* (`DataFormats/UltraCanvasVectorStorage.h`): `VectorElement::Effects` carries an optional `ShadowEffect` (wall, floor or glow: offset, penumbra, colour, darkness) and `FeatherEffect` (radius). `VectorStyle::Transparency` is a Xara-style level ramp (flat, linear, radial, conical; level 0 opaque, 1 clear) with a mix (stained glass, bleach, contrast, saturation, darken, lighten, brightness, luminosity, hue) beside the flat `Opacity`. `StrokeData` gains the line gallery: `StartArrow` / `EndArrow` (fourteen kinds: six gallery shapes with the tip on the line's end, and Xara's eight stock arrowheads - straight, angled, rounded, spot, diamond, feather, feather 2, hollow diamond - with Xara's own geometry and placement, taken from its source, where Scale 1 is Xara's default size; `IsXaraArrowhead`), a `WidthProfile` of samples along the path and a vector `Brush` stamped along it. `BuildOutlinePath`, `FlattenPathData`, `PathEndpoints`, `ArrowheadOutline` and `VariableWidthOutline` are the shared geometry (the editing layer's `OutlineOf` delegates; `PathOps::SegsToPathData` is public). - *Renderer*: an element with effects renders through groups. The shadow and the feather come from a raster of the element's silhouette, drawn black offscreen at the device scale, blurred with three box passes and cached per object (`ClearCaches`, `EffectCacheSize`; replaced when the geometry, blur or zoom changes). A shadow paints it as a colour mask at its offset, squashed and sheared for floor shadows; a feather masks the element's group with it; a transparency ramp masks the group with an alpha gradient and paints it with the mix's blend operator. Arrowheads, width bands and brush stamps come from the outline. - *XAR converter*: reads through the XAR plugin's `XARDocument` - the spec-verified parser, compressed files included - translated into the model, replacing the converter's own uncompressed-only reader and the older dead one; without `ULTRACANVAS_PLUGIN_XAR` it only writes. Multistage fills, conical fills, transparency ramps with their mixes, line transparency, shadow controllers and feather attributes round trip; bounding-box gradient units resolve against the object. The line gallery round-trips too: Xara's stock arrowheads are written as `TAG_ARROWHEAD` (the path's start) / `TAG_ARROWTAIL` (its end) line attributes exactly as Xara's own source writes them - an INT32 stock reference (-2 straight .. -9 hollow diamond) and two FIXED16 scales (Xara's arrow size, 3 by default, so the model's Scale times 3) - and read back as the same kinds at the same size; every other arrowhead, a width profile and a brush are baked into plain shapes - the brush as one group per stamped copy, exactly what the renderer draws - under a group that carries a `TAG_USERVALUE` (`UltraCanvas.LineGallery`) describing the stroke, so Xara shows the shapes, keeps the value, and the converter rebuilds the stroke from it on the way back (the brush stamp is the first copy, un-placed). `TAG_DEFINEARROW` is a tag Xara defines but never writes or reads; a positive reference is read as the straight arrow and reported. What the reader cannot represent is counted in one warning. The Vector plugin links the XAR plugin publicly when it is built, and the capability flags say what is written. - *Tests*: `VectorModelTest` renders every effect and checks pixels, the ramp and profile interpolation and the raster cache; `XARWriterTest` round-trips a four-stop gradient with a bleach ramp and a wall shadow, a feathered circle, an arrowed line, a tapered polyline, a bar-tailed line with a doubled native head and a brushed line through the plugin's reader and back through the converter, checking the strokes come back as strokes; `VectorFormatsPluginTest` pins the new flags. - *XAR plugin*: `TAG_USERVALUE` records are parsed (two UTF-16 strings) into `XARNode::userValues` instead of being skipped. Arrowhead records read their full 12 bytes into `XARLineAttribute` - the reference (default 0, none) and the width / height scales (default 3) - and `TAG_ARROWHEAD` now lands on the start of the path and `TAG_ARROWTAIL` on its end, as Xara's `AttrStartArrow` / `AttrEndArrow` write them (they were swapped). #### 2026-09-19 *0.9.3* - **The PDF writer can write a euro sign.** `UltraCanvasPDFVectorConverter` declares `/WinAnsiEncoding` on its base-14 fonts, but its string escaper only passed code points up to U+00FF and replaced everything above with `?`. WinAnsi is CP1252, which agrees with Latin-1 from 0xA0 up but fills 0x80..0x9F - Latin-1's unused C1 control block - with 27 printable characters that live far away in Unicode. **The euro sign is one of them, at 0x80**, and so are the typographic quotes, the en and em dash, the bullet, the ellipsis and the per-mille sign. A German invoice reading `1.234,56 ?` is not an invoice, and nothing in the pipeline complained. - Those 27 code points now map to their WinAnsi bytes; anything genuinely outside the encoding still becomes `?` with the same one-time warning. - The C1 control positions U+0080..U+009F, which WinAnsi leaves undefined, now become `?` as well instead of being emitted as raw bytes with no glyph. - Covered by `Tests/UltraFIBU/UltraFIBUEngineTests.cpp`, which asserts that a produced invoice contains the byte 0x80 rather than a question mark. The plugin's own `PDFVectorWriterTest` could not carry it: that test needs the image raster subsystem and so cannot run on a headless machine. - **`ULTRACANVAS_BUILD_ULTRAFIBU_TESTS=ON` in CI** (`.github/workflows/build.yml`), beside the Net, UltraCloud and EmailCleaner suites that were already there. UltraFIBU's was the one application suite CI never built, so its checks - now 833 of them, including the encoding fix above - ran nowhere. It is a headless suite with no UI dependency, which is why it can simply be switched on. #### 2026-09-19 *0.8.99* - **NetworkMonitor on Windows and macOS, and byte counters on Linux** — the platform half of the proposal's Phase 2. Windows reads the socket tables from IP Helper (`GetExtendedTcpTable` / `GetExtendedUdpTable` with the owner-PID classes, so the PID arrives with the row), the executable from `QueryFullProcessImageNameW` and the user from the process token; macOS enumerates every process's descriptors through libproc (`PROC_PIDLISTFDS` / `PROC_PIDFDSOCKETINFO`), which is how `lsof -i` does it and means a process the monitor may not inspect contributes no sockets at all — the capabilities say so. Linux now asks netlink `sock_diag` first: one round trip per table instead of parsing `/proc/net`, and for TCP the `tcp_info` with `tcpi_bytes_acked` / `tcpi_bytes_received`, which fill `NetworkConnection::bytesSent` / `bytesReceived` and set `perConnectionBytes` in the capabilities; where the kernel refuses (a sandbox, no `udp_diag`) the backend falls back to the file, per table, and says so. The address formatter moved out of the procfs parser into `NetworkMonitorAddress.h` so all three backends print a peer identically. `Tests/NetworkMonitorTests.cpp` now opens a connection across the loopback, moves 64 KiB over it and asserts both ends come back attributed to the test's PID with counters at least that large where the backend has them; the socket code compiles on Winsock too. - **The backends are compiled once, into the module.** The core library's platform glob had been picking up `UltraCanvasNetworkMonitor.cpp` as well, so each backend sat in both `libUltraCanvas` and `libNetworkMonitor`. A static core tolerated that, a shared one on Linux deferred the module's symbols to load time, and the Windows DLL refused to link (`undefined symbol: NetworkMonitorAddress::FormatIPv4`). They are now dropped from the core's sources the way the UltraNet platform files are. #### 2026-09-19 *0.8.98* - **NetworkMonitor: a module that reads the operating system's socket table and names the process behind every connection.** The Phase 1 of `Docs/Modules/NetworkMonitor/NetworkMonitorProposal.md`: `NetworkMonitor_ListConnections` returns every TCP and UDP socket, IPv4 and IPv6, with its endpoints, state, owning UID and — where the monitor may inspect the process — its PID, executable, name and user; `NetworkMonitor_SummarizeByProcess` rolls that up per application. It is deliberately not part of UltraNet, which only ever sees its own process's traffic: this is the view `ss -p` gives, as a library. Linux backend from `/proc/net/*` and the `/proc//fd` walk; the `IFolderWatchBackend` pattern — an interface per platform, a null factory everywhere else — so Windows and macOS are one source each. Where there is no backend every snapshot says `NotSupported`, never an empty table that looks like a quiet machine, and `NetworkMonitor_GetCapabilities()` says whether every process or only this user's can be attributed. The `/proc/net` parser lives in the core, pure, so `Tests/NetworkMonitorTests.cpp` drives it from fixture text on every platform and, on Linux, opens a loopback listener and asserts it comes back attributed to the test's own PID. `Docs/Modules/NetworkMonitor/README.md`; first consumer is UltraNetMonitor (`Apps/UltraNetMonitor`, its own changelog). #### 2026-09-19 *0.8.97* - **`UltraCanvasListView` shows which column its rows are sorted by.** `SetSortIndicator(column, ascending)` draws a small triangle in that column's header cell - apex up for ascending, apex down for descending - and `ClearSortIndicator()` / `GetSortColumn()` / `GetSortAscending()` complete the API. The triangle is geometry (`FillLinePath`) in `headerTextColor`, not a text glyph, so it follows the header theme and stays crisp at any DPI instead of depending on the header font carrying U+25B2/U+25BC; `ListViewStyle::sortIndicatorSize` sets its width. It sits after the title in a left- or centre-aligned column and before it in a right-aligned one, and the title's rect shrinks by the same strip so the two never overlap. The view only shows the order; the rows are sorted by whoever owns the model. - **`UltraCanvasListView::onHeaderClicked(column)`** fires on a press and release in the same header cell, so a table can sort on header click - the usual handler re-orders the model and calls `SetSortIndicator`. A press on a column's resize border still starts a drag and never reads as a click, and a press on the header no longer reaches the row handler as a click on "no row", which used to clear the selection. DemoApp's multi-column list sorts this way now. - **This replaces the view-side sorting API 0.8.96 added**, which had no caller anywhere in the tree: `SetSortingEnabled`, `onSortRequested`, `SetSortProxy` and the `ListSortOrder`-based `SetSortIndicator` are gone, and this entry's API takes their place. 0.8.96 also gated its header-click handling on sorting being enabled, so a header press still cleared the selection with sorting off; `onHeaderClicked` handles the press before row hit-testing and fixes that unconditionally. **`UltraCanvasListSortFilterProxy` is untouched** - it never referenced the view, and its 78 checks still pass. It is now driven from `onHeaderClicked` at the call site instead of by the view itself, which is the wiring its header documents: ```cpp listView->onHeaderClicked = [listView, proxy](int column) { const bool ascending = !(column == listView->GetSortColumn() && listView->GetSortAscending()); proxy->SortByColumn(column, ascending ? ListSortOrder::Ascending : ListSortOrder::Descending); listView->SetSortIndicator(column, ascending); }; ``` #### 2026-09-19 *0.8.96* - **Sorting and filtering for every list in the framework, and a correction.** `UltraCanvasListSortFilterProxy` (`include/UltraCanvasListSortFilterProxy.h`, `core/UltraCanvasListSortFilterProxy.cpp`) is an `IListModel` that wraps another one and presents the same columns with the rows re-ordered and thinned out, so a view is handed the proxy instead of the model and needs no idea that either is happening. Every existing `UltraCanvasListView` caller gains both without changing a line. `Docs/UltraCanvas/UltraCanvasListSortFilterProxy.md`, `Tests/ListSortFilterProxyTests.cpp` (target `ListSortFilterProxyTests`, 78 checks). - **The correction first.** 0.8.94 and 0.8.95 below said `UltraCanvasListView` "has no column API at all" and asked for a new data grid to be built. That was wrong, and wrong in the most avoidable way: it came from grepping the view's header for `AddColumn`/`SetColumns` instead of reading the model beside it. ListView *is* the multi-column, virtualised, model-driven table - `IListModel`, `ListColumnDef`, `UltraCanvasMultiColumnListModel`, painting delegates, selection models, a header band, per-cell tooltips, variable row heights, cell-level callbacks and row culling were all already there. What was missing was sorting and filtering, which is what this release adds instead of a second grid. `UltraCanvasTableView`, which the catalogue named, still does not exist; that row now points at the view that does the job. - **Stable sorting**, so equal rows keep their source order and sorting by one column then another is predictable. Per-column kinds - `Auto`, `Text`, `TextCaseSensitive`, `Number`, `Natural` (`Beleg 2` before `Beleg 10`) - or a comparator of your own, which receives source rows. - **`ListDataRole::SortRole`**: a column showing `1.234,56 EUR` or `17.09.2026` returns the amount or the day number here and sorts by that instead of by its formatting. Absent, the proxy falls back to the displayed text. - **Numbers are read without the C locale**, both conventions alike (`1234.56`, `1.234,56`, `-37,28 EUR`, `(1.234,56)`), and deliberately strictly: a letter anywhere means "not a number". The first version skipped `E`, `U` and `R` so `EUR 89,00` would parse - which made `R-202607010` read as **-202607010** and silently reversed a whole column of document numbers. The tests caught it, and now cover it. - **Filtering** by case-insensitive text, over chosen columns or all of them, plus an arbitrary predicate; a row must pass both. - **Row mapping is explicit**, because a proxy row is not a source row: `MapToSource` / `MapFromSource`, and a selection reported by a view is in proxy rows. Forgetting that is how a sorted table deletes the wrong record, so the header, the doc and the tests all say it. - **Attaching never disconnects anything**: the proxy chains the source's existing change handlers rather than replacing them, and restores them when it is destroyed, so a model that outlives its proxy cannot call into freed memory. - **`UltraCanvasListView` shows which column is sorted and reports header clicks** - `SetSortingEnabled`, `SetSortIndicator`, `onSortRequested`, and `SetSortProxy` for the common case. The view never sorts anything itself, so a model that is already ordered by a database query keeps working unchanged. Clicking a header sorts ascending, clicking the sorted one turns it round, and the indicator triangle is drawn as geometry (`FillLinePath`) so it stays crisp at any DPI and follows the header's text colour. A header click no longer clears the selection - losing what you had selected because you sorted the table is not what anybody asks for - and the keyboard focus row is dropped on a sort rather than left pointing at whatever record landed on that index. #### 2026-09-19 *0.8.95* - **`UltraCanvasMoney`: an amount that is still right after the arithmetic.** `int64_t` minor units plus an ISO 4217 code, header-only (`include/UltraCanvasMoney.h`), free of every other UltraCanvas header - so a headless engine, a test target and the UI all use one definition. The framework's only currency type until now was `CurrencyValue`, a `double` in the spreadsheet types; it stays what it is, a cell value, and nothing that keeps a balance should use it. `Docs/UltraCanvas/UltraCanvasMoney.md`, `Tests/MoneyTests.cpp` (target `MoneyTests`, 118 checks). - **Rates are applied exactly.** Every multiply and divide goes through `MoneyMulDiv`, which forms the full 128-bit product in 32-bit limbs and divides it bitwise with *kaufmaennische Rundung* - half away from zero, so 2,5 becomes 3 and -2,5 becomes -3, which is the rounding German tax arithmetic does. No `__int128`, no intrinsic, identical on every platform, and overflow is reported rather than wrapped. The carry out of bit 63 is handled explicitly, because the shift-and-subtract loop that ignores it is wrong for divisors above 2^63 and right for every divisor anyone tests with. - **The VAT identities hold by construction.** `TaxOnNet`, `GrossFromNet`, `TaxInGross` and `NetFromGross` take the rate in permille (190 is 19 %, 25 is 2,5 %), and `NetFromGross` is defined as the gross minus the contained tax rather than as its own division - which is what keeps `net + tax == gross` true whatever the rounding did. The tests assert both identities across a matrix of rates and amounts, down to one cent at 19 %. - **A split sums to the whole.** `SplitProportionally` distributes by largest remainder, so 100,00 over three positions is 33,34 / 33,33 / 33,33 and a discount spread over invoice lines cannot lose a cent. Negative amounts (credit notes) split with the sign; degenerate input returns nothing rather than something wrong. - **Three text styles, no locale.** German `1.234,56` for the UI, plain `1234.56` for dot-decimal file formats, and `1234,56` for DATEV's comma-decimal CSV columns - chosen by the destination, never inherited from the process. Digits are assembled from the integer, so `LC_NUMERIC` cannot reach them: this is the one numeric type in the tree that cannot acquire the decimal-separator bug the Linux backend's `setlocale(LC_ALL, "")` has already caused twice. Parsing accepts what people and files actually write (grouping, parentheses for negative, a trailing symbol, finer decimals rounded half away from zero), and the two machine styles are deliberately strict - a misplaced grouping separator fails the parse, because an importer that misreads an amount does more damage than one that rejects a line. - Invalidity replaces exceptions and is sticky: a currency mismatch, a failed parse or an overflow yields an invalid amount that propagates through the arithmetic and formats as an empty string, never as `0,00`. One check at the end of a calculation is enough, and a mismatch can never print as a plausible wrong number. - **The UI element catalogue no longer names an element that does not exist.** `Docs/UltraCanvas/UltraCanvasUIElements.md` listed `UltraCanvasTableView` with "matching `*.h`"; there is no such header anywhere in the tree. The row now points at the elements that do the job. (It first pointed at `UltraCanvasColumnsTreeView` on the strength of the mistaken reading corrected in 0.8.96; `UltraCanvasListView` is the multi-column table, and the row says so now.) #### 2026-09-19 *0.8.94* - **New design proposal: UltraFIBU, a German double-entry accounting application** (`Docs/Research/UltraFIBUDesignProposal.md`). DATEV import and export, UStVA/ZM submission to ELSTER, One-Stop-Shop reporting, a *Geschaeftsjahr* whose start date is free (1 April is an ordinary row, not a special case), customer and supplier master data with European VAT numbers, a German UI, and two deployment modes - a local SQLite database and a shared server database several users work on. The investigation checks every requirement against the tree rather than against expectation, and the answers are the interesting part: the CSV layer already speaks the DATEV dialect (CP1252, semicolons, quoted fields), `UCZipPackageWriter` gives the containers DATEV XML, ZUGFeRD and the GoBD Z3 medium need, UltraVault holds the ELSTER PIN and UltraCrypt the journal's hash chain - while four things are missing and each is worth having for its own sake. - **`UltraCanvasTableView` does not exist.** The UI catalogue names it and there is no such header, so the row sends readers after a file that was never written. (The same bullet originally went on to say that `UltraCanvasListView` "has no column API at all" and to ask for a new data grid. That was wrong - see 0.8.96, which corrects it: ListView *is* the multi-column model-driven table, and what it lacked was sorting and filtering, not columns.) - **There is an XML parser and nobody owns it.** Six of this application's formats are XML (XRechnung UBL and CII, ZUGFeRD, the ELSTER data types, CAMT.053, the GoBD `index.xml`). tinyxml2 is already a core dependency - COLLADA, the mind-map IO and `UltraCanvasPropertyList` all use it - but there is no facade over it, so every caller parses its own way and `VersioningInvestigation.md` already records one file being parsed twice into two models. `UltraCanvasXML` wraps what is there, the way `UltraCanvasJSON` wraps yyjson; no new dependency. - **There is no money type.** The one currency value in the tree is a `double` in the spreadsheet types, which a ledger may not use; exact integer minor units with explicit *kaufmaennische Rundung* and allocation helpers belongs in the framework. With it, a German number input - and the note that DATEV CSV is comma-decimal, the exact inverse of the dot-decimal file-format rule the framework has already been bitten by twice. - **Multi-user has one answer and it is a driver.** `core/UltraDatabase/` holds the SQLite driver alone and there is no `Plugins/UltraDatabase/`, so server mode waits on the `libpq` driver the module's own Stage 2 plan promises. A shared SQLite file on a network share or a synced folder is not a deployment mode - it is silent loss of a book that must by law be complete. The rest is regulatory reality, sourced and dated: UStVA goes through ERiC, which cannot be vendored here, needs a manufacturer registration and is re-released twice a year - so an interface, a dynamically loaded backend that soft-fails, and an always-available path that writes the XML for manual upload. OSS has no published machine interface at all, only a CSV transport file uploaded by hand, so the design computes and hands over. The BZSt's VAT-number XML-RPC endpoint went obsolete on 30 November 2025 and is now a REST API. A *Geschaeftsjahr* starting 1 April and a UStVA period that is always a calendar month are two calendars over one journal, which is a schema decision and cheap only while it is early. Three scope decisions taken with the owner are recorded in §1.1 and carried through the plan: the *Jahresabschluss* stays in-house (so *Bilanz*/GuV and E-Bilanz become a named later phase, and every account carries a balance-sheet classification from the first chart import), shared-server mode is wanted from day one (so the libpq driver runs beside the first application phase, and users, roles and attribution ship with the very first schema), and SKR03 with *Soll-Versteuerung* are the defaults - each still a per-client setting, because the hard-coded one is the one that cannot be given to a second company. Documentation only; no code. #### 2026-09-19 *0.8.93* - **A remote drive can be changed, not only read.** `UltraCanvasFilerWidget` gained three more host hooks beside `remoteListing` - **`remoteDelete`**, **`remoteRename`** and **`remoteMakeDirectory`** - so the folder display can delete an entry, rename one in place and create a folder on a drive the host carries for an FTP server or a cloud account. They differ from the listing hook in what they promise: they answer that the request was *accepted*, not that it finished. The host queues the work and refreshes the display when the server has replied, because a delete over a slow link would otherwise hold the UI thread exactly as a blocking listing would. Each entry handed to `remoteDelete` carries its own `isDirectory`, which is what lets a backend pick FTP's `DELE` over `RMD` without a probe per entry, and `remoteRename` takes a bare name - a rename in place, never a move. A remote new folder cannot go straight into rename mode the way a local one does: it does not exist until the server has answered and the refresh has landed. The widget names it from the listing on screen instead, and renaming it afterwards now works. The commands with no hook - duplicate, paste, new file - still refuse on a remote folder rather than reaching `std::filesystem` with a path that resolves to nothing. Copying between the local disk and a drive is a transfer with progress, conflicts and a cancel, so it belongs with the paste machinery rather than in a hook of this shape. - **`CloudService` forwards the change verbs.** `Delete`, `Rename` and `MakeDirectory` were added to `ICloudProvider` in 0.8.80 for the FTP provider, but the app-facing facade had no way to reach them - so an application could hold an account and still not delete a file on it. All three now resolve the account and its credentials and hand the call on with the path normalised, exactly as `List` does. A provider that never implemented them still answers `Unsupported`, and the suite checks both halves of that. #### 2026-09-19 *0.8.91* - **Illustrator artwork rendered as a blank page.** "Since Illustrator 9 a `.ai` file is a PDF" is only half true, and the demo app's AI Artwork page acted on the wrong half: it handed `.ai` straight to the MuPDF viewer. Illustrator's *Create PDF Compatible File* option decides whether the PDF page carries the artwork at all - with it off (and it is off in what CorelDRAW and several other exporters write) the file is a valid PDF whose page content stream draws nothing, and every path lives in the private `/AIPrivateData` streams instead. Both samples in `media/vector/AI` are of that kind: their page content is 47 bytes that set a transform and a graphics state. A PDF engine renders exactly that, so the page was blank - correctly, and unhelpfully. The online `.ai` viewers this was checked against fail the same way. - **New: `UltraCanvas/Plugins/Vector/UltraCanvasAIReader.cpp`** - the import side of `VectorConverter::AIConverter`, which stops being export-only. It finds the `/AIPrivateData` streams in the PDF container, undoes their filter chain (ASCIIHex / ASCII85 / Flate) and interprets Illustrator's art language into a `VectorStorage::VectorDocument`: path construction (`m`, `l`, `c`, `v`, `y`) with closepath on the lowercase paint operators, clipping (`W`), compound paths (`*u`/`*U`) so filled shapes keep their holes, groups, named layers, the graphics state (width, cap, join, miter, dashes, winding rule), every colour operator (grey, CMYK, RGB, spot, and patterns as flat colour) and the AI9 transparency operator `Xy`. Gradients and text are counted and reported through `WarningCallback` rather than dropped silently, as is every operator the parser does not know, so a file that displays wrong says what it needed. - Legacy (v8 and earlier) EPS-based `.ai` files carry the same art language in the open and read through the same parser with no container step. The two coordinate spaces - Illustrator's ruler space, origin top-left with y down as negative numbers, and PostScript's bottom-left origin with y up - both map onto the document's y-down page, chosen from the art's own extent. - **The demo page now names the route it took.** It reads through the Vector plugin and shows the drawing in an `UltraCanvasVectorElement` (drag to pan, wheel to zoom); a `.ai` that really does draw through its PDF page carries no private data, `AIConverter::Import()` declines it with a warning that says so, and the MuPDF view takes over. That is the file `AIConverter` itself writes, so the fallback is the round trip of the plugin's own output. The page is built wherever the Vector plugin is, rather than only where the PDF plugin is. - **`.ai` joins the Vector plugin's readable extensions**, so the reader is not the demo page's alone: `UltraCanvasVectorFormatsPlugin` advertises it, and the vector preview seam it registers means UltraFiler tiles and the media viewer now draw such a file *from the drawing* instead of falling back to whatever bitmap it carries. A PDF-compatible `.ai` is declined as before and keeps its existing route. - **`UltraCanvasVectorElement` zoomed by doing nothing, and Fit threw the drawing off the page.** Found while building the page above, and it affected every user of the element (the DWG / DXF page's zoom buttons included). The element owns a view transform - `zoomLevel` and `panOffset` - but then handed `VectorRenderer` a viewport of `finalBounds / zoomLevel`, and the renderer fits and centres the ViewBox into whatever viewport it is given. Expressed in document units that way, the renderer's scale cancelled `zoomLevel` exactly, so zooming changed nothing at all; and its centring landed on top of the centring already in `panOffset`, so the first `ZoomToFit()` that ran with a real box threw the drawing half a viewport to the right. The renderer now gets no viewport and the element's transform is the only one; `ScreenToDocument()`, hit-testing and wheel-zoom anchoring already assumed exactly that, so they become correct too. Culling goes with the viewport, which costs only time - `Render()` already clips to the element's bounds. - **A document set before the layout ran was fitted to a zero-sized box.** `SetDocument()` fits immediately, but a page builds its widgets before it has been laid out, so `finalBounds` was still empty and the fit settled on `MinZoom`. The renderer's own fit hid this; with that gone the fit is remembered and redone on the first frame that has a real box. - **New: `Tests/AIReaderTest.cpp`** - the two shipped samples must import as real geometry, upright and on the page their header declares (868 and 72 stroked paths, beziers intact), which is the check that would have caught this; plus the art language on a synthetic legacy file, and the PDF-compatible case that must be declined rather than imported empty. #### 2026-09-19 *0.8.90* - **Every 3D model in the demo was drawn standing on its nose.** The viewers' cameras put +Y on screen, but nothing told them which axis a mesh called up, and the formats disagree: STL, STEP, DXF and most CAD are Z-up, glTF and FBX are Y-up. A Z-up mesh handed over unrotated has its length running up the screen, which is why the demo's aeroplanes pointed at the floor. - `Mesh3D` now carries `upAxis` (`MeshUpAxis::YUp` / `ZUp`), and each producer states what it read: `UltraCanvasSTLLoader` sets `ZUp`, the format's universal convention, and `ModelDocumentToMesh3D` takes it from `document.Up`. `Mesh3DToModelDocument` writes it back, so the round trip keeps orientation as well as geometry; a `Mesh3D` built in code keeps the `YUp` default and is unaffected. - `UltraCanvasSTLElement` and `UltraCanvasModelRaster` both rotate a `ZUp` mesh by -90 degrees about X before posing it — the same sense and sign as `ModelDocument::ConvertUpAxis`, and the same in both, so the software still remains the view that was on screen. Only the view rotates: vertex data, bounds and the extents a page reports stay in the file's own frame, so the Model Formats panels still report "Z-up" for a file that says so. - This reached every caller, not just the demo: the media viewer opens `.stl` and the other model formats through the same element, and the Filer's thumbnails go through the same raster. - **The 3D Graphics tree listed one "3D Model Formats" page for seven readers.** A visitor asking whether FBX is supported had to open a page and click through a carousel to find out. Each format is now its own entry beside STL — STEP, MilkShape, FBX, Alembic, COLLADA, 3D Studio and DirectX .x — and `CreateModelFormatsExamples(extension)` filters the same page to that reader. A single-sample format shows no Prev/Next buttons rather than two that do nothing. - **Four of the aircraft samples were incomplete exports, which read as an importer dropping geometry and was not.** Both meshes in `media/3D/Blend/E-45-Aircraft.blend` carry a Mirror modifier about X=0, and the .dae, .x and binary .fbx had been exported without applying modifiers, so the files held half an aeroplane; the .ms3d held only the glass canopy and no hull at all. Parsing each file directly settles which side the defect was on: OBJ, PLY, 3DS, X3D, VRML, DXF and the *ASCII* FBX all span X −0.973…+0.973, while those four stopped at 0.000 — two exports of the same model from the same scene disagreeing is not something a reader can cause. - Regenerated from that .blend with the modifier applied: the .fbx by Blender's own exporter, the .dae and .x by mirroring each file's geometry in place so the exporter's scene graph, materials and templates survive, and the .ms3d written afresh with both meshes as two groups. All four now span the full 1.946 and match the formats that were already complete. - The .dae needed a second fix: its two mesh nodes hang off the armature's JOINT chain with no skinning controller, so the joint rest transforms were applied on top of placements that already included them — the hull landed 3.5 m out and the canopy 5 m behind it, detached. Recomputing both local transforms against the .blend's world matrices brings the document extent to 1.95 × 6.17 × 4.21, the same aeroplane the other formats describe. - The exports as they came out of Blender are kept in `Tests/data/3D/`, because their defects are what four test suites pin. `ModelColladaTest`, `ModelXFileTest`, `ModelMS3DTest` and `ModelFbxTest` assert the half hull and the canopy-only MilkShape deliberately — "a property of the file rather than of the reader … asserting it stops a later change *fixing* the reader to match the others" — and cross-check the files against each other: the MilkShape canopy is "exactly twice the Alembic canopy's 744 faces", and the two FBX exports are one scene "on opposite sides of the mirror-modifier split". Completing the media copies in place would have deleted that net, and the re-exported .fbx carries neither the stacked DiffuseColor textures nor the transparent canopy material the original pins. So `media/3D/` now holds the demo's showcase assets and `Tests/data/3D/` the fixtures, with the four tests pointed at the latter and `Tests/data/3D/README.md` saying which is which. - Still outstanding: `media/3D/Alembic/E-45-Aircraft.abc` is a narrower mesh than its siblings (3297 faces against 3990, X span 1.53 against 1.95). Nothing here writes Alembic — not the framework, whose writers cover 3DS, OBJ, PLY, STEP, COLLADA and X3D, nor Debian's Blender, which ships without the Alembic and COLLADA exporters — so that one is left as found. - **The DWG and DXF samples are now filed by what they hold rather than by format.** CAD covers both, so the folders held a mix: `media/vector/DXF` carried `E-45-Aircraft.dxf`'s sibling drawings while the 3D DXF sat in `media/3D`, and nothing said which was which. Counting entities settles each one — the Millennium Falcon is 1015 LWPOLYLINEs and 507 LINEs, the figure study 74 NURBS SPLINEs, the Audi and the hostel plans 2D blocks and hatches, every Z at zero; `E-45-Aircraft.dxf` is 8110 3DFACEs and `bagno_3d_1.dwg` polyface meshes. - The four flat drawings live in `media/vector/{DWG,DXF}` and stay on the "DWG / DXF Drawings" page under Vector Graphics. The two that carry geometry live in `media/3D/{DWG,DXF}`. - New **"DXF 3D Models"** entry under 3D Graphics reads the E-45 DXF through the Models plugin as a mesh — 16220 triangles, extent 1.95 × 6.14 × 4.19, the same aeroplane 3DS describes. A DXF of only 2D entities is refused there with an explanation, which `ModelDXFTest` asserts. - The 3D DWG is still drawn on the drawings page, projected to plan view, because that is what a CAD reader does with polyface meshes — the page now names the file's root per tile rather than assuming one folder. #### 2026-09-19 *0.8.88* - **CI builds and runs the UltraCloud test suite.** It has existed since the module did, and no continuous build had ever compiled it: the option that brings it in (`ULTRACANVAS_BUILD_ULTRACLOUD_TESTS`) defaults to OFF and nothing turned it on, so sixty tests over eleven files - the account store on UltraDatabase, both secret stores, and every provider from WebDAV to the FTP one added in 0.8.80 - were only ever run by hand. A suite nothing runs is a suite that rots: the green tick on a pull request said the module *compiled*, never that it still worked. Both configure steps now pass `-DULTRACANVAS_BUILD_ULTRACLOUD_TESTS=ON`, next to the UltraNet and EmailCleaner suites that were already asked for, so the binary is built on every platform and ctest runs it on Linux with the rest. Nothing about the tests themselves changed, and nothing needed to: the whole suite passes as it stands. They are headless by construction - every provider is driven through an injected fake rather than a server - so turning them on adds no network dependency to the build and no flakiness to it either. #### 2026-09-19 *0.8.87* - **The Linux CI legs build with a compiler that implements the standard the tree is written in.** UltraCanvas is built as C++20 and uses P1091 - capturing a structured binding in a lambda - which Clang implements from 16. Ubuntu 22.04 has nothing new enough: its archives stop at `clang-14` and the runner image preinstalls 13, so `apt-get install clang` produced a compiler that rejects the tree. It rejected it, moreover, with ``` error: 'path' in capture list does not name a variable ``` which points at the lambda and never mentions the compiler, so each instance read as a bug in the code rather than as one missing language feature. Three of them reached `main` (0.8.86) because GCC had accepted them all along and the failure only appeared when the default compiler changed in 0.8.83. - **CI installs Clang from LLVM's own apt repository**, through their maintained `llvm.sh` so the suite name for the distribution is chosen upstream rather than hard-coded in the workflow. The version is one place, `ULTRACANVAS_CLANG_VERSION`, and the step fails immediately with a named reason if that version is not available for the runner's distribution or architecture - rather than twenty minutes later, inside a compile. - **The runner stays on ubuntu-22.04.** Moving to 24.04 would have supplied Clang 16 for free, but this leg is pinned to 22.04 for glibc 2.35, which is what makes the portable Linux bundle portable; 24.04 would raise that floor to 2.39 for everyone who installs it. - **`ULTRACANVAS_MIN_CLANG_MAJOR` makes the requirement explicit.** The root `CMakeLists.txt` now skips a Clang below the floor while choosing a default - so a machine whose `clang` is 14 but which also has `clang-18` configures with the latter instead of failing on the first structured binding - and refuses an explicitly chosen one with a message that names the feature, the compiler it found and the command to fix it. Nothing about the build output changes: this decides which compiler runs, not what it produces. #### 2026-09-19 *0.8.86* - **The Linux build is green again under Clang, and a missing MuPDF no longer stops a build.** Two independent breakages, both from the move to Clang (0.8.83's "Migrate from GCC to Clang"), and both of which left `main` red. - **Three files captured a structured binding in a lambda.** Legal only since C++20's P1091, and **Clang 14 - which is what `apt install clang` gives on the `ubuntu-22.04` runner - does not implement it**: ``` UltraCanvasBreadcrumb.cpp:1722: error: 'path' in capture list does not name a variable UltraCanvasBreadcrumb.cpp:1723: error: reference to local binding 'path' declared in enclosing function ``` GCC accepted all three, so they only surfaced when the compiler changed. Each now binds the pair to a named variable before the lambda, which every compiler accepts at every standard: `UltraCanvasBreadcrumb.cpp` (the sub-folder menu's navigate callback), `UltraCanvasRequirementDiagramLayout.cpp` (the A* heuristic's goal cell) and `UltraCanvasWordCloudDiagram.cpp` (the bigram reducer). Only the first was visible on CI: the build stops at the first error, so fixing it alone would have turned the next file red on the following run. All three were found by building the tree with Clang 14 locally. - **A missing MuPDF is now a skipped plugin, not a configure error.** `MUPDF_FOUND` was computed and then ignored - the sources, the include directory and `${MUPDF_LIBRARY}` were wired in whether or not MuPDF was there - so a machine without it stopped at configure time with `MUPDF_LIBRARY ... NOTFOUND` and no way forward but installing it. That is what turned an upstream package disappearing (MSYS2 dropped `mingw-w64-x86_64-mupdf` on 2026-09-17, taking every Windows build in the repository with it) into an unfixable outage rather than a build without PDF previews. Everything downstream was already guarded by `ULTRACANVAS_PLUGIN_PDF`: `UltraCanvasPDFView.cpp` and `UltraCanvasPDF_MuPDF.cpp` compile to nothing without it, and the filer's PDF thumbnails fall back to the type glyph. So the plugin now simply reports itself disabled and the build continues. Verified by hiding the MuPDF headers and building the framework through to a linked library. `-DULTRACANVAS_PLUGIN_PDF=OFF` remains the way to ask for this deliberately, and is unchanged. Both halves were validated against the CI compiler rather than the local one: Clang 14 installed alongside, pointed at libstdc++ 12, and the whole framework built with it. The original error reproduces byte for byte on Clang 14 and compiles under Clang 14, Clang 18 and GCC after the fix. #### 2026-09-17 *0.8.84* - **A file display can now draw the icons the host desktop draws.** The filer widget has always painted its own: the folder shape and the category-coloured sheet with the extension on it. They look the same everywhere and need nothing installed, but they also look like nothing else on the machine - a `.pdf` in an UltraCanvas file display and the same `.pdf` in Explorer, Finder or Files were two different pictures. `Display > File icons` now chooses: ```cpp filer->SetFileIconStyle(FilerFileIconStyle::HostOperatingSystem); ``` - **New module `UltraCanvasHostFileIcons`** (`include/UltraCanvasHostFileIcons.h`, `core/UltraCanvasHostFileIcons.cpp` plus one backend per platform) answers "what does THIS system draw for a file of this KIND?". It is the type-wide counterpart of `UltraCanvasNativeFileIcons`, which answers the other question - what icon a file carries INSIDE itself - and the two are deliberately separate: a program is drawn as itself on every platform because its picture is in the file, while a `.txt` is drawn as this desktop draws a text file, and looks different on another one. - **The key is the design.** `HostFileIconKey()` says what an answer depends on, and files of one kind share it: a folder of four thousand `.txt` files resolves ONE icon and holds ONE pixmap. A compound suffix keys as itself (`archive.tar.gz` is a tarball, not a gzip file), an extension-less name keys by its name (the freedesktop database knows `makefile`), and a file that carries its own icon keys per file so two programs can never share one. - **Linux / BSD** resolve the file's MIME type through `UltraCanvasFileAssociations` - the shared-mime-info globs the "Open with" menu is already built from, so the framework still has exactly one reader of that database - and then the icon-naming-specification names through `UltraCanvasDesktopEntry`'s theme resolver. Two new calls carry what those rules need: `FileAssociations::GetMimeType()` (the MIME name of a file, matched by name, never by reading it) and `FileAssociations::GetMimeGenericIcon()` (the generic icon the type database names for a type - `application/pdf` is drawn as `x-office-document`, a tarball as `package-x-generic`). Both are empty on Windows and macOS, which associate by extension and by UTI and keep no MIME database to ask. Without the second one every archive and every office document falls back to "some file", which is not what the desktop shows. - **Windows** takes the icon from the shell's system image list - the list Explorer itself draws from - indexed by `SHGetFileInfoW` with `SHGFI_USEFILEATTRIBUTES`, so the type is decided from the file NAME and the shell answers without opening, or even finding, the file. A transparent border is trimmed off what comes back: the jumbo list is a 256x256 canvas and a type whose icon exists only at 48 sits in the middle of it with empty space all round, which drawn into a tile would be a postage stamp. - **macOS** asks `NSWorkspace` for the content type the extension names rather than for the file, so one lookup serves every file of a kind. WebAssembly and Android report unavailable, and a caller keeps its own icons. - **Nothing waits for it.** The widget resolves on one background thread and draws its simple icons until an answer lands - and keeps drawing them for a type this system has no icon for, so a machine with no icon theme installed loses nothing and a lookup never reaches the frame. - **What the setting does NOT change**: a file that thumbnails as its own content still shows the thumbnail, and a program, shortcut or bundle still shows the icon inside it. Explorer, Finder and the Linux file managers all prefer those too; the type icon is what they fall back to. A folder with an icon from `folderIconProvider` also still wins. Folder previews are drawn INTO the built-in folder shape, so with host icons on there is no shape to draw them into and a folder is simply the system's folder icon. - `AreHostFileIconsAvailable()` reports whether there is a desktop to ask, so a settings page can say so instead of offering a switch that changes nothing; `RefreshHostIcons()` drops what was resolved, for a host that notices the user changing theme. Switching fires `onDisplayFormatsChanged` like the other Display switches. Default is unchanged - `FilerFileIconStyle::Simple` is what every earlier release drew. - New `Tests/FilerHostIconsTest`; docs in `Docs/UltraCanvas/UltraCanvasHostFileIcons.md`, with the widget's side in `UltraCanvasFilerWidget.md` and the two new association calls in `UltraCanvasFileAssociations.md`. #### 2026-09-17 *0.8.83* - **A format plugin read nothing until an application named it.** Registration was per-application boilerplate, so UltraFiler registered none and every format outside core sat compiled into the binary and unusable; each new application had to learn the same list, and each new plugin had to be added to every application that wanted it. - **New: `Plugins/UltraCanvasAllFormats.{h,cpp}` and the `UltraCanvasAllFormats` object library.** The list lives in the framework once and the build fills it in: CMake defines `ULTRACANVAS_HAS_` per plugin target that was actually built, and a registrar calls what those defines admit exists - before `main()`. An application links it and gets every format its build can read, writing nothing; a plugin added to the framework reaches every application that links it. - OBJECT rather than STATIC, and that is the whole trick: a linker keeps only the object files of a static library that something references, and nothing references a registrar, so in a `.a` it would be dropped and the registration would silently never happen. - Registration order is ownership order. The dedicated viewer plugins (CDR, XAR, EPS) go after the Vector plugin, because the registry lets the last registration win a shared extension and for those they are the better reader. The Vector plugin keeps what only it reads and stays the only writer, since save dispatch matches on `GetSaveExtensions`. - `RegisterAllFormatPlugins()` and `AutoRegisteredFormatPlugins()` are exposed for the cases that want to look rather than to register. - **`UltraCanvasGraphicsPluginRegistry`'s storage is function-local now** (`Plugins()`, `ExtensionMap()`, `Initialized()`) instead of three namespace-scope statics. A plugin registering from a static initialiser can run before another translation unit's globals are alive, and a registry whose vector had not been constructed yet would have taken those registrations into a dead object. Whoever touches it first now builds it. - **The preview tests ask the graphics registry too.** The Filer's thumbnail test and the media viewer's `IsVectorDocumentFile` consulted the vector preview seam and the embedded-preview probe, never the registry - so a format only a registered plugin could draw stayed greyed however many plugins were loaded. Both now also accept `IsVectorGraphicsPath()`: - the media viewer hosts the plugin's **own element** for such a file (a new per-file `pluginView`, rebuilt on each load and dropped on the next), rather than a bitmap of it - the same choice the 3D and PDF views make; - the Filer's thumbnail worker falls back to `RasterizeVectorFile()`, inside the mutex that already serializes vector drawing. - `GraphicsFormatDetector` files `ccx` and `cdt` as `Vector`. Missing from that table they were `Unknown`, which is what the vector rasterizer tests before it will touch a file - so a `.ccx` the CDR plugin draws perfectly well was refused before the plugin was ever asked. - **`ULTRACANVAS_PLUGIN_VECTOR` defaults ON**, like every other format plugin. It needs libvips, zlib and tinyxml2, which are core's own dependencies, so it costs no new one - and off by default it took DXF, the DWG family, EMF and WMF out of every build made the ordinary way, CI's included, so a file manager could not read a drawing however it registered its plugins. The Android phase-1 block still forces it off: libvips is not in that sysroot, and this plugin needs it. - **Opening a CorelDRAW file could crash a shared Linux build, and the CDR file had nothing to do with it.** MuPDF does not use stock Little-CMS: it bundles the **lcms2mt** fork, whose every entry point takes an extra leading context argument. Those objects come out of `libmupdf-third.a` with default ELF visibility, so a shared `libUltraCanvas.so` re-exported `cmsCreateTransform` and friends - and being earlier in the global lookup scope than `liblcms2.so.2`, it silently captured every Little-CMS call made by anything else in the process. libcdr makes one while parsing: its six-argument `cmsCreateTransform(hInput, ...)` landed on the fork's seven-argument one, every argument shifted by one, and `cmsGetColorSpace` dereferenced what was meant to be a pixel-format integer. The MuPDF archives are now linked with `--exclude-libs`, which localizes what they define and leaves our own symbols (and `uc-yyjson`, `VirtualFS`) exported. Same class of bug as the libjpeg version clash already noted in the PDF plugin's linkage, and not fixable the same way, because MuPDF does not support building against a system Little-CMS. - `CDRWriterTest` is what found it. Defaulting `ULTRACANVAS_PLUGIN_VECTOR` to ON is what makes this test run in CI at all (it needs both that plugin and the CDR one), and its first run segfaulted on both ubuntu-22.04 runners while passing on 24.04. It now flushes each line as it prints, marks the libcdr parse and the render, and null- and bounds-checks the cairo pixel probes instead of dereferencing what `cairo_image_surface_get_data()` returns on trust. It also installs a SIGSEGV/SIGABRT/SIGBUS handler that prints a backtrace and is built with exported symbols - which is what named `cmsGetColorSpace` inside `libUltraCanvas.so` as the faulting frame, with `libcdr` two frames below it, and turned a platform-specific segfault into a one-line linker fix. - `AutoFormatRegistrationTest` (new) calls no `Register*Plugin()` and checks every built plugin is in the registry anyway, that the Vector plugin's preview seam came with it, and that registering again is a no-op. #### 2026-09-17 *0.8.82* - **The file display can show a folder that is not on this machine.** `UltraCanvasFilerWidget` gained two host hooks, **`isRemotePath`** and **`remoteListing`**, so an application can carry a drive for an FTP server or a cloud account and have the widget browse it. The widget takes on no network dependency: it only asks, the way it already asks VirtualFS to list the inside of an archive, and the new branch sits beside that one in the folder scan. `isRemotePath` is asked *before* the local filesystem is consulted, which is the whole point of having it. Handing such a path to `std::filesystem` would at best fail, and at worst - for a path that looks like a dead network mount - block the UI thread until the OS times out. `remoteListing` runs on the UI thread inside the scan, so a host that has to go to the network answers from what it already holds and calls `Refresh()` when the rest arrives; an empty listing with no error means "nothing yet". A listing it refuses is reported like any other listing error. The widget fills in each entry's extension and type information itself, so a remote file gets the same icon and category as a local one of the same name, and `listingIsRealDirectory` stays false - which turns off the two features that read the local filesystem per entry, the folder previews and the in-use column. Writing into such a folder is refused rather than attempted: delete, duplicate, rename, paste, new folder and new file now answer with a message saying the drive can be browsed but not changed. Without that each would reach `std::filesystem` with a path that resolves to nothing and fail with an error about a missing file instead of an answer about where it was pointed. See `Docs/UltraCanvas/UltraCanvasFilerWidget.md`. - **UltraCloud's add-account dialog can be narrowed to one kind of provider.** `ShowAddAccountDialog` takes an optional provider filter and an optional title, for a host that offers adding an FTP server and adding a cloud account as two separate commands - the two are configured so differently (a host and a password against a browser sign-in) that one combined list explains neither. Both are defaulted, so the existing callers are unchanged; a filter that would leave nothing to choose is ignored rather than producing an empty form. #### 2026-09-17 *0.8.81* - **A folder of libraries looked exactly like a folder of programs.** `.exe`, `.dll`, `.so`, `.deb` and `.appimage` were one category with one colour and one noun, so `core.dll` read as a *Library Program* in the same blue-grey as `Setup.exe` — one step from the grey that source files already had. `FilerFileCategory::Library` now stands beside `Executable`: things you launch against things a program loads, apart in colour, apart in the Type column, apart in a sort by type. `Apps/UltraFiler` drops the private list of program extensions it kept because the category could not be trusted. - **The file-type palette is rebuilt around three channels**, one fact each — `EntryColorOf()` in `UltraCanvasFilerWidget.cpp`, documented with the full table in `Docs/UltraCanvas/UltraCanvasFilerWidget.md`. - **Hue is the family**: blue images, green video, yellow-to-orange audio, cyan vector, teal models, purple documents, violet spreadsheets, grey text and code, dark red applications, steel grey libraries, magenta archives, sepia fonts. Media hues saturated, working files muted. - **Brightness is efficiency**: the modern format takes the brightest rung of its family and the legacy one the darkest — AVIF `#2D86EA` over JPEG `#0F4F98` over GIF `#0C3F7A`, Opus over MP3, WebM over AVI, `.exe` over the `.deb` you meet once a year. Formats sharing a compressor share a rung: zip, jar, tgz and gz are all deflate, and colouring them apart would invent a difference the bytes do not have. - **A hue tilt separates lossless from lossy** at the same chroma instead of dulling it — indigo beside azure, pure yellow beside orange — so lossless is a sibling family rather than a washed-out version of its neighbour. - The rungs are not free-hand colours: each is a fixed contrast step against the white glyph sheet, so rung 1 of the blues and rung 1 of the greens are equally deep. `Tests/FilerFormatColorTest.cpp` holds the ladders to it. - **The TreeMap drew every caption in white**, which the lightened audio and text shades would have made unreadable. `EntryCaptionInkOf()` takes the ink from the entry's family: white on the dark families, near-black on the light ones. It is a family property, never a per-file one — a GIF does not get black text for being the palest blue — so no ramp switches ink halfway down itself, and the change of ink between families is itself the signal that you have crossed into the light half of the palette. - **Formats the table was missing**, now named and ranked rather than falling through to "some file": `.msi`, `.dylib`, `.a`, `.lib`, `.aiff` / `.aif` and `.lzma`. #### 2026-09-17 *0.8.80* - **An FTP server can be a cloud account: UltraCloud's `ftp` provider.** FTP, FTPS and SFTP now sit behind `ICloudProvider` like Nextcloud or Dropbox, so an app carries a server the way it carries any other account - one record in the account store, the password in UltraVault, the same add-account dialog - instead of keeping a host, a user and a password of its own beside everything else. The transfers are still UltraNet's (`UltraNetFtp.h`); `FtpProvider` is the account-shaped surface over them. It is the first provider that can change what is on the server as well as read it, so `ICloudProvider` gained two optional verbs, **`Delete`** and **`Rename`**, and `ProviderCapabilities` a **`modify`** flag to say so. Both default to `Unsupported`, so the providers that only ferry files out are untouched and answer honestly rather than appearing to succeed. `Rename` is a rename in place - it refuses a name containing '/' rather than quietly moving the entry - and `Delete` takes the `isDirectory` the caller already knows, which is what picks `DELE` over `RMD` without paying for a probe. The scheme chooses the transport, because the scheme a user types is not always what goes on the wire: `ftp://` plain, `ftps://` with TLS from the first byte, `ftpes://` for TLS negotiated on the control channel (sent as an ordinary `ftp://` URL), `sftp://` for SSH. Share links answer `Unsupported` - no FTP request mints one - and SFTP authenticates with a password only, since UltraNet sets no SSH key options yet. Like the HTTP providers, the FTP entry points are an injectable seam (`FtpOps`), so the twenty tests in `Tests/UltraCloud/test_ftp.cpp` run headless and contact nothing. What they mostly pin down is the trailing slash: libcurl lists a URL that ends in '/' and retrieves one that does not, while the commands that reach an entry through its parent - `DELE`, `RMD`, `RNFR`/`RNTO`, `MKD` - derive that parent by cutting at the last '/' and fail outright on a URL that ends in one. `FtpUrl(account, path, directory)` is the one place that decides it. #### 2026-09-17 *0.8.79* - **The 3D demo pages clipped their own text.** Every information panel in the 3D Graphics section - "What the reader found", "What the format can carry", the "how it works" strip, and the notes beside the OpenGL canvases - was a plain `UltraCanvasLabel` in a fixed rectangle, filled with text whose length depends on the file being described. A COLLADA scene says far more than a STEP solid, and a build with no converter for an extension replaces the capability grid with a paragraph. Labels are vertically centred by default, so text that outgrew its panel lost its FIRST line off the top as well as its last off the bottom - the FBX sample's "Read yes / Write read-only" row was cut in half - and nothing on screen said anything was missing. - **New `Apps/DemoApp/UltraCanvasDemoScrollText.h`**: the text now lives in an `UltraCanvasContainer` sized to the space available, with an auto-sized `UltraCanvasLabel` as its only child. The layout engine measures the label against the whole text, the container sees a child taller than its viewport and shows its vertical scrollbar, and the wheel and the bar reach the rest. Text that fits is drawn exactly as before - no bar appears. The block is top-aligned, so a panel starts at its first line rather than centring short text in a tall box, and `SetText()` returns to the top so switching samples does not open the next one halfway down. - **Lines too long for a panel wrap instead of being cut short.** The same panels ellipsized anything wider than the box, which is how "Autodesk FBX 6.x and 7.x, binary and ASCII" became "Autodesk FBX 6.x and 7.x, bina...", the generator string lost its version and the unit scale lost its number - and the box cannot get any wider. The aligned columns these panels are built from are far shorter than the box and look exactly as before. - Used by **3D Model Formats** (stats, capabilities, how-it-works), **STL 3D Models** (stats, support list, how-it-works) and the three **OpenGL 3D support** tabs (the model, shader and Zarch notes). The Models tab's note was 26 lines in a 360px box and had been losing its last lines outright. - `Tests/DemoScrollTextTest.cpp` runs the real layout engine over the block against an offscreen render context: overflowing text is measured in full and raises the scrollbar, fitting text raises none, and both start at the top. #### 2026-09-17 *0.8.78* - **New design proposal: UltraMessage, the cross-platform message channel** (`Docs/Research/UltraMessageDesignProposal.md`, registered as `Masterfile_modules.md` §13). One API for app-to-app messages with RISC OS Wimp semantics (post, recorded post with bounce, request/reply, topic subscriptions), a journaled feed of well-known topics (`messaging.message`, `mail.message`, `system.notification`) so the ULTRA OS desktop can show every messenger's and mail client's messages in one structured view, and a command surface (`RegisterCommand` / `Invoke`) that is the Apple-Events half of an AppleScript-class automation story, kept to register, list, invoke and consent. - **New registry entry: UltraScript** (`Masterfile_modules.md` §14), the scripting language, filed as `Docs/Research/UltraScriptSpecification.md` (recorder, SDEF-compatible dictionary, parser, executor, Script Editor). Its new §17 specifies cross-application scripting on UltraMessage: the dictionary doubles as the command manifest, `ui.*` verbs expose the object model, the executor routes `tell` blocks aimed at another process, recording can span applications, and message triggers plus schedules cover repeating tasks. The two modules meet on one primitive and UltraScript links UltraMessage, never the reverse. The proposal surveys what each OS offers (D-Bus, `WM_COPYDATA`, Apple Events, notification listeners), settles on one broker and one wire protocol per user session with platform buses as adapters, and lays out the data model, the `UltraMsg_*` API, the broker, the per-platform adapters, security and a four-phase delivery plan. Documentation only; no code. #### 2026-09-17 *0.8.77* - **A copy, a move or a delete that takes more than two seconds now says so.** `UltraCanvasFilerWidget` ran all three straight through on the UI thread: a folder of holiday photos dragged onto another drive froze the window for as long as the copy took, with nothing on screen to say whether anything was happening, how far along it was, or how to stop it. Packing and unpacking archives had had a progress window since 0.3.63; the everyday operations had none. They now run on a background worker, and if one is still going **two seconds later** it gets the same window packing gets - the ring with the percentage, the file being handled and **Cancel** (`UltraCanvasProgressDialog`). Anything quicker passes without a window at all: a file manager that flashes a dialog for every copied text file is worse than one that shows none. Every route in shares it - Ctrl+V, the context menu, `Delete`, a drag & drop between panes, `Duplicate`, `PasteFilesInto()`, `DeletePaths()` - because they all go through the same two queues. - **What the ring shows.** Each entry of the queue is worth an equal slice of it, and the bytes copied (or entries removed) inside an entry move the ring within its slice, so one large file fills it smoothly and a thousand small ones fill it a step at a time. An entry is measured just before it is worked on, never the whole queue up front: for a move, where each entry is one instant rename, walking every tree first would take longer than the move. - **Files over 8 MB are copied in 1 MB chunks**, so the ring moves *inside* a single big file and Cancel does not have to wait for it. Smaller files still go through `std::filesystem::copy_file` in one call, which lets the platform hand the copy to the filesystem itself. - **Cancel stops at the next file.** What was already copied, moved or deleted stays; the entry the cancel interrupted does not - a half-written file or folder is removed rather than left in the listing. The one step that is never interrupted is the second half of a cross-volume move: once the copy is safely across, the original is removed to the end, because stopping there would leave the entry half in both places. - **The conflict and problem dialogs are unchanged**, and still belong to the UI thread: the worker walks the queue until it reaches an entry that needs an answer and hands the queue back. The progress window steps aside while such a dialog is up and returns when the work resumes, without a second two-second wait - the delay is measured from the start of the operation, not of the current step. - `DeletePaths(paths, onDone)` is new: a delete with no confirmation of the widget's own, for a host that has already asked. `DuplicateSelection()` is now the paste queue aimed at the folder the entries already live in, which is what it always was by hand - it just could not be cancelled or watched. - Without an application timer (a headless host, a test) there is nothing to collect a worker with, so the queues run on the calling thread exactly as they did before. #### 2026-09-17 *0.8.76* - **A picture can sit inside a line of text.** Every image in a loaded document became a paragraph of its own, because `RichTextRun` had no way to hold one: a logo mid-sentence, an icon in a heading or a signature in a sign-off was pulled out of its line and dropped below it, re-flowing the text around it. - `RichTextRun` gains `mediaIndex`, `imageWidthPt`, `imageHeightPt` and `imageAltText`. A run with `mediaIndex >= 0` *is* a picture, and its `text` is a single U+FFFC OBJECT REPLACEMENT CHARACTER - the standard placeholder for an inline attachment. It gives the picture one character's worth of the block's text, so the caret steps over it, a selection covers it and Backspace deletes it, with no position needing to know it is not a letter. - Two pictures never coalesce into one run and a picture never merges with the text beside it: `HasSameFormatting` refuses, because the run is what carries which picture it is. - The DOCX reader tells `` from ``, the ODT reader tells `text:anchor-type="as-char"` from the floating anchorings, and both writers emit a picture run back in the line it came from. - **A picture alone in a paragraph stays a block.** Both formats anchor a standalone image in the text as well - a picture on its own line really is "inline, with nothing beside it" - so the markup cannot separate the two cases and what else the paragraph holds decides it. Without that rule this change turned every existing block image into a run, which the format tests caught. - The element reserves a box for each picture through `TextAttributeFactory::CreateShape` over its placeholder, so the line grows to hold it and the following text flows along, then draws the picture at the box's position. - `ToPlainText`, `ToMarkdown` and `ToHTML` render a picture run as its alt text, a markdown image reference and an embedded `` respectively. The placeholder never reaches a reader. - `UCRichDocumentEditor::InsertInlineImage` and the element's `InsertInlineImageFromFile`/`FromMemory` put a picture in the line at the caret. - Covered by 28 new checks in `Tests/RichTextEditorTest.cpp` (304 total), 12 in `Tests/RichTextEditElementTest.cpp` (96 total) and an inline round trip in `Tests/WordFormatsTest.cpp`. #### 2026-09-17 *0.8.74* - **`UltraCanvasElevatedFileOperations` — "Delete as administrator", the retry Explorer offers when a delete answers "You need permission to perform this action".** A standard user's process cannot raise its own rights, so the retry starts a second copy of the host executable through the shell's `runas` verb: Windows shows its consent prompt, the elevated copy deletes what the user named (read-only attributes lifted first, absolute paths only, nothing read from anywhere but its command line) and exits, and what it still could not delete comes back with the system's reason per entry through a report file the caller created. Consent is asked every time and nothing else is elevated. Host side: `RunHelperIfRequested(argc, argv, exitCode)` first in `main()` — it turns the relaunch into the helper and is what makes `IsAvailable()` true, so an application that never calls it never offers the retry. `IsPermissionFailure(ec)` tells the "Access is denied" the retry resolves from the sharing violation it cannot. Windows backend; everywhere else `Unavailable`. `Tests/ElevatedFileOperationsTest.cpp` covers the encodings, the helper's delete and the no-backend answers on every platform. See `Docs/UltraCanvas/UltraCanvasElevatedFileOperations.md`. - **`UltraCanvasFilerWidget`: a delete refused with "Access is denied" now offers the administrator retry** wherever the host wired the helper. The problem dialog becomes *Administrator Permission Needed* with **Delete as administrator** (preselected) / **Try again** / **Skip**, plus the usual "do this for all remaining items" switch. Entries handed to the administrator are collected while the queue runs and go to the helper in one run at the end — one consent prompt for the whole delete, as Explorer asks once — behind a "Deleting as Administrator" progress window, waited for off the UI thread. Failures the helper reports come back in a *Cannot Delete* dialog; a declined prompt goes to `onError`. Before this, the dialog's only offers for such an entry were "Try again" and "Skip", neither of which could ever succeed. The problem dialog helper is now `ShowProblemChoiceDialog` (any number of exclusive choices); `ShowProceedSkipDialog` remains as its two-choice form. #### 2026-09-16 *0.8.73* - **A drawing the framework could read showed nothing in the preview pane.** Core owns the vector document model and the renderer that draws one, but not a single reader - SVG, XAR, EMF, WMF, DXF and the DWG family all live in the Vector plugin, which links *against* core. So the media viewer and the Filer had exactly two ways to show a vector file: rasterized by libvips (svg/svgz, and eps/ps where that build has a PostScript loader), or as the preview bitmap some formats store inside themselves. A DXF or a DWG is neither, so selecting one produced an empty pane and a plain type glyph - in a build whose Vector plugin had just read the same drawing for the FileLoader. - **New: `UltraCanvasVectorPreview.h` / `core/UltraCanvasVectorPreview.cpp`** - the same seam `UltraCanvasModelPreview.h` is for 3D formats, for drawings. Core declares what it wants ("turn this path into a `VectorDocument`", "is this one you read") and `RegisterVectorFormatsPlugin()` installs an implementation on the way in, so the dependency still runs plugin -> core. `CanPreviewVectorExtension()`, `PreviewableVectorExtensions()`, `LoadVectorPreviewDocument()`, plus the drawing half every caller shares: `RenderVectorDocumentPixmap()` / `RenderVectorPreviewPixmap()`, a document fitted into an offscreen render context and read back. A provider may also claim a file by content (`ClaimsFile`), which is what a `.bak` holding a drawing needs. - **The media viewer opens drawings in `UltraCanvasVectorElement`**, a display view of its own next to the PDF, model, book and font views: the document itself, sharp at any zoom, rather than a bitmap of it. A format with no reader in this build still falls back to the embedded preview bitmap and still says so when there is not even that. - **The Filer thumbnails them too**, rendered from the document at the tile's size. Serialized behind one mutex, unlike every other preview producer here: a PDF worker owns its engine context and a font specimen its FreeType library, but drawing a document goes through a render context and the process-wide font machinery, and one at a time costs nothing worth having for a file kind that is not photographs. - `GetPreviewableFormats()` asks the same seam, so **Display > Thumbnails** and **Display > Detail view** stop greying out formats the build can show. With the Vector and Models plugins registered, 23 formats change from greyed to live: dxf, dwg, dwt, dws, sv$, emf, wmf, and sixteen 3D formats (3ds, obj, ply, dae, fbx, x, ms3d, blend, abc, step/stp/p21, x3d/x3dv, wrl/vrml). - **Disabled controls were drawn heavier than live ones.** `Colors::LightGray` (192) was the disabled face of checkboxes, radios and segmented controls, and it is *darker* than `Colors::ButtonFace` (225) - so on a settings page listing one switch per file format, the unsupported formats were the strongest thing on the page. The border made it worse: it stayed at `ButtonShadow` whatever the state. New `Colors::ControlDisabled` (238) and `Colors::ControlDisabledBorder` (202), both lighter than their live counterparts, are now the default for checkbox, radio, segmented-control and button faces, and the checkbox and radio borders grey with them. #### 2026-09-16 *0.8.72* - **A DWG drawing was only recognised when it was called `.dwg`.** AutoCAD writes the *same* drawing database — same `AC10xx` header, same object map — to four suffixes and copies it verbatim to a fifth, and every layer that decided "this is a drawing" compared against the single string `"dwg"`. A template, a standards file or an automatic save opened nowhere: the Vector plugin's dispatch returned null before reading a byte, `GraphicsFormatDetector` filed them as Unknown, and the Filer gave them a generic glyph and no type name. The native decoder in the tree could read all of them perfectly well. - **`.dwt` (template), `.dws` (drawing standards) and `.sv$` (automatic save) are now first-class drawing extensions.** `DWGConverter::GetFileExtensions()` lists them, `UltraCanvasVectorFormatsPlugin::GetSupportedExtensions()` reports them as loadable (so they reach `UltraCanvasSupportedFormats`, the FileLoader inventory and file-dialog filters), `GraphicsFormatDetector` files them as `Vector` (so `GraphicsFileInfo::IsValid()` and the vector rasterizer accept them), and the Filer names them AutoCAD Template / Standards / Autosave in the Vector category. They go through the same native R13–R2018 decoder as a `.dwg`, so they open, preview and rasterize identically. - **`.bak` is recognised by its header, not its name.** AutoCAD's backup is a drawing, but the suffix belongs to no format — editors, package managers and databases all write `.bak` — so claiming every one of them as CAD would be wrong. `DWGConverter::IsAmbiguousDrawingExtension()` marks it, and the plugin takes it only when the file's first six bytes carry the `AC10xx` magic that `ValidateFile()` already checked for. It is deliberately not an advertised extension. - `UltraCanvasGraphicsPluginRegistry::FindPluginForFile()` no longer stops at the extension map: a suffix nothing advertises now falls through to the plugins' own `CanHandle()`, which is the contract for deciding by content. Without it a format recognised from its header was reachable through the converter API but not through `LoadGraphicsFile`. Every plugin's `CanHandle` is an extension comparison plus at most a header peek, so the fallback costs what the missed map lookup did. - `UltraCanvasVectorFormatsPlugin::CanHandle(const GraphicsFileInfo&)` passes the file's path instead of rebuilding `"." + extension` from it: an extension alone cannot answer for a format decided by content, and this overload has to agree with what `LoadGraphics()` will do with the same file. - `DWGReaderTest` loads the R2000 fixture's own bytes under each of `.dwg`, `.dwt`, `.dws`, `.sv$` and `.bak` and checks that a `.bak` holding anything else is left alone; `VectorFormatsPluginTest` checks the registry listing, the converter dispatch and the format inventory for the new extensions, and that `bak` is advertised nowhere yet still recognised from its header. - Docs: `Docs/UltraCanvas/UltraCanvasVectorConverters.md` gains **The DWG family** (the five suffixes, which are settled by name and which by content); the Filer, vector-raster, UI-element and FileLoader format tables list the new extensions. #### 2026-09-17 *0.8.72* - **Merged table cells survive a save, and are drawn where they belong.** Two separate defects, found while looking at what tables still could not do. - **Row spans were silently dropped on every save.** The ODT reader has always recovered `table:number-rows-spanned` into `RichTableCell::rowSpan`, but neither writer ever emitted a row merge — so opening a document with vertically merged cells and saving it quietly un-merged them. The ODT writer now emits `table:number-rows-spanned` with the `` placeholders the covered positions need, the DOCX writer emits `` plus the continuation cells Word requires, and the DOCX reader turns those continuations back into a `rowSpan`, which it previously ignored entirely. A round-trip test in `WordFormatsTest` pins both formats; it failed before the fix, which is how the loss was found. - **`UltraCanvasRichTextEdit` ignored spans when laying tables out.** A cell was positioned by its index within its row and drawn one column wide, so a single merged cell misaligned every cell after it and any cell below a row-spanned one sat in the wrong column. Layout now walks the grid: a cell covering several columns is drawn that wide, a cell covering several rows stretches down over them, and the positions the cells beside them get shift accordingly. - A cell is still addressed as `{row, index-within-row}`. The grid column is geometry alone, so honouring spans moved no caret and changed no position. - Covered by 11 new checks in `Tests/RichTextEditElementTest.cpp` (85 total) and a merged-cell round trip in `Tests/WordFormatsTest.cpp`. #### 2026-09-16 *0.8.71* - **The demo's Vector Editing page had no drawing area.** The page places its widgets at fixed coordinates and built the canvas with the sizeless factory, then called `SetBounds(10, 130, 720, 600)`. `SetBounds()` writes `finalBounds` and nothing else — no CSS `size.width/height`, no `AbsoluteUI` position — so the next layout pass re-flowed the canvas as an in-flow child with auto height, which is zero. The toolbar, the fill panel and the status bar (all built with explicit bounds, hence absolutely placed) drew where they belonged, and the canvas between them was six hundred pixels of nothing. - `UltraCanvasVectorCanvas` now has the `(id, x, y, w, h)` constructor every other element has, with a matching `CreateVectorCanvas(id, x, y, w, h)` overload; the sizeless pair stays for layout-driven hosts such as ArtCreator, which gives the canvas `flex-grow` instead. `Docs/UltraCanvas/UltraCanvasVectorCanvas.md` documents both and says why `SetBounds()` is not a substitute for either. - **The page's toolbar reads as icons now, not clipped words.** Fourteen text buttons in 980 pixels left every label truncated to "Sel…", "Recta…", "Duplic…". It now uses ArtCreator's icon set (`media/icons/artcreator/`, `media/icons/texter/`) with the command and its shortcut in the tooltip, the way ArtCreator's own toolbar does. - Four icons the set was missing were added in the same 24 × 24, 1.8-stroke style: `delete.svg`, `duplicate.svg`, `grid.svg` and `snap.svg`. - `to-front.svg` and `to-back.svg` were redrawn, because on a toolbar they were the same picture. The toolbar draws button icons as a single-colour mask (`ButtonStyle::useIconAsMask`), which flattens a two-tone icon: both were a `#333` square overlapping a `#fff` one, and once the white square masked to black the pair merged into one identical blob — in ArtCreator's toolbar too. They are now a square with an up or down arrow, which reads the same under a mask as it does in colour. - **Vector Editing moved from *Vector Graphics* to *Widgets*.** `UltraCanvasVectorCanvas` is a widget an application drops into a window; the Vector Graphics category is about the file formats the vector readers produce (SVG, CDR, XAR, EPS, DWG, AI). #### 2026-09-16 *0.8.70* - **The demo application had no WYSIWYG page, and its tree told four lies about what is implemented.** Both are the same defect: the tree's status icon is the only thing a visitor has to go on, and it was describing the tree rather than the framework. - **New page: WYSIWYG Editor** (Document support), the demo's first showing of `UltraCanvasRichTextEdit` — `Apps/DemoApp/UltraCanvasWYSIWYGExamples.cpp`. Three toolbars built from real elements (`UltraCanvasToolbar`, `UltraCanvasButton`, `UltraCanvasDropdown`), because the element deliberately draws no chrome, all driven from `GetFormatState()` so a selection spanning bold and plain text reads as *mixed* instead of picking a side. Document open/save go through `UltraCanvasFileLoader::LoadTextDocument` and `UCWordDocumentIO::Save`, since the element performs no file I/O of its own. - The sample document is assembled as a `UCRichDocument` rather than parsed from Markdown, on purpose: its "14 pt Georgia in red" run, its centred heading and its table are precisely the formatting a Markdown buffer cannot spell, which is the reason the element exists next to `UltraCanvasTextArea`. - Toolbar buttons are `SetAcceptsFocus(false)`, or pressing Bold and carrying on typing would type into the button. The toolbars re-sync after every toolbar action as well as on the element's change callbacks, because arming a format at a collapsed caret mutates nothing and so raises neither. - Listed as *partially implemented*, which is its own documentation's verdict: tables render without being editable in place, images are not resized interactively, math runs show their LaTeX source, there is no spell checking yet, and cross-application rich paste still needs clipboard MIME flavours the backend does not carry. - **`UltraCanvasRichTextEdit.md`'s toolbar example did not compile.** It called `CreateToolbar(...)` (no such factory for `UltraCanvasToolbar`), passed seven arguments to a six-argument `CreateButton`, called a `SetToggled()` that exists nowhere in the tree, and gave `onSelectionChanged` a one-argument signature where the dropdown's is `(int, const DropdownItem&)`. Rewritten against the real API — `AddToggleButton` / `SetPressed`, `SetSelectedIndex(index, false)` so writing the box back does not re-apply what was just read out of the document — plus the two things that only show up once it is wired for real: sync after a toolbar action too, and point one direction of the wiring with raw pointers so the callbacks do not close an ownership cycle. - **Demo tree statuses corrected against the tree, not against memory.** All 155 registered items were checked; 14 claimed less than "fully implemented" and four of those were wrong. - *Heat map* (Info Graphics) was a "not ready yet" placeholder while the heat map has been implemented and registered under Charts as `heatmapchart` for some time — interactive heatmap, STFT spectrogram, calendar and hexbin variants. The duplicate placeholder is removed, the way the "matrix" placeholder was when the matrix diagram landed. - *Drawing Surface* (Bitmap Elements) pointed at a `CreateVectorExamples()` whose body was entirely commented out, around an `UltraCanvasDrawingSurface` that was never built: the page rendered an empty container with a title. The item and the dead function are removed; the surface that does exist is `UltraCanvasVectorCanvas`, already registered under Vector Graphics as "Vector Editing". - *Waves info graphic* and *Performance Matrix* have no element, no plugin and no renderer anywhere in the tree, so "partially implemented" overstated them. Both are now *planned*, matching what the specification-only modules (IODeviceManager, Smart Home, VideoFX) already say. - The remaining ten are accurate and were left alone: XAR and EPS say "partially implemented" in their own docs (effect nodes parsed but not rendered; a PostScript subset with diagnosed approximations), LaTeX documents stop at Phase 3 of the engine proposal, and OCR / Vectorizer / Pixel FX / PDF are the `#else` branch of a plugin that this build did not include. #### 2026-09-16 *0.8.69* - **A pull request based on another pull request's branch got no CI at all.** Every workflow triggered on `pull_request: branches: [main]`, and for a `pull_request` that filter matches the *base* branch — the branch the pull request targets, not the branch it comes from. A stacked pull request targets the parent's `claude/**` branch, so it matched no trigger in any of the four workflows: no 3-OS matrix, no `llms-txt`, no `ui-reuse`, no `changelog` check. #455 reached 1059 changed lines across the caret and position model of `UltraCanvasRichTextEdit` without a single job ever running against it; the test results in its description were all from its author's own machine. `main` and `'claude/**'` are both listed now, in all four. - `push` deliberately stays `branches: [main]`. Adding the glob there would build every commit on every `claude/**` branch whether or not a pull request exists — the double-building the note at the top of `build.yml` describes, at 10x the Linux rate on macOS and 2x on Windows. Widening only `pull_request` costs a matrix exactly for the stacked pull requests that have one, which is the point. - What this does not buy: a stacked pull request is built on top of its unmerged parent, so a green result does not show the change is sound against `main`. It is retargeted and rebuilt when the parent merges, and that run is the one that proves it. Green-on-parent is still far better than the nothing that came before. #### 2026-09-16 *0.8.68* - **The caret goes inside table cells.** `UltraCanvasRichTextEdit` rendered tables from the start but treated each one as a single indivisible block, so the text inside was readable and nothing more. Cells are now editable in place: click into one, type, select, format, and Tab or Shift+Tab to walk them in reading order. - A position is now `{blockIndex, cellRow, cellColumn, byteOffset}` and addresses one **text container** — a block's own runs, or one table cell. `cellRow`/`cellColumn` default to -1, so every position outside a table, and every existing `{block, offset}` construction, keeps its old meaning. - Everything that edits or measures text goes through `RunsAt` / `TextAt` rather than reaching into a block directly, which is what let the caret, selection, deletion, formatting, word motion and undo follow into a cell without each one growing its own table special case. - **Find and replace reach into cells**, which they could not before: search walks containers (`AllContainers()`) rather than blocks. - Three behaviours that keep cell editing honest rather than merely possible: Enter inside a cell adds a line to the cell instead of splitting the table's block; Backspace at the start of a cell steps to the previous cell and deletes nothing, because cells cannot be merged by deleting text between them; and a selection is held inside one container, because a range spanning cells would describe an edit no table can honour. - Undo is unchanged in kind: a cell edit records the table block, which is the block span an undo step already replaces. - In the element: clicks hit-test into the nearest cell, the caret takes its geometry from that cell's layout, scrolling follows the line inside the cell rather than jumping to the top of a tall table, and the selection highlight is applied to the cell's own layout. - Covered by 80 new checks in `Tests/RichTextEditorTest.cpp` (269 total) and 21 new ones in `Tests/RichTextEditElementTest.cpp` (74 total). #### 2026-09-16 *0.8.67* - **`UltraCanvasRichTextEdit` can search and spell check.** Both were named as limits when the element landed in 0.8.50; they were also the two things a word-processing tab in UltraTexter lost by moving off the Markdown detour, so they come first. - Search lives in `UCRichDocumentEditor`, so it is UI-free and testable without a display: `Find` (forwards or backwards, wrapping), `FindAll`, and `ReplaceAll`. Matches are found in block text and never span a block, which is what makes each one safe to replace independently. Case folding is ASCII, as `UltraCanvasTextArea`'s search already was. - `ReplaceAll` is **one undo step** for the whole replace, not one per match: it runs every replacement inside a single `EditScope` over the document. - Replaced text **keeps the formatting of the text it replaced**. Deleting a range leaves the caret at the end of whatever preceded it, so a plain insert would silently adopt that run's formatting and replacing a bold word would leave plain text behind; the format is now sampled from inside the match before it is deleted and reapplied afterwards. `ReplaceRange` gained the same behaviour, since it is the same question. - The element adds `FindNext`/`FindPrevious`/`ReplaceCurrent`/`ReplaceAll` and `CountMatches` over that, selecting each match and scrolling it into view. `ReplaceCurrent` only replaces when the selection *is* a match, so pressing Replace before Find finds rather than overwrites. - Spell checking uses the shared `UltraCanvasSpellChecker` worker exactly as the text area does, over one string for the document with blocks joined by `\n` — one job per document rather than one per block — and maps the result's byte offsets back onto `{blockIndex, byteOffset}`. Squiggles are drawn per visual line through the existing `SpellCheckRendering` helpers, only for blocks the viewport has laid out. - The element gained `onContextMenu`, fired before its own suggestion popup, so a host can put the suggestions inside its own menu instead of a competing one — the same contract `UltraCanvasTextArea` offers. - `RichDocRange::Contains` and a shared `ReplaceRangeInternal` / `ApplyCharFormatToRangeInternal` split so that a caller already inside an `EditScope` does not commit a second undo step. - Covered by 50 new checks in `Tests/RichTextEditorTest.cpp` (189 total) and 27 new ones in `Tests/RichTextEditElementTest.cpp` (53 total). #### 2026-09-16 *0.8.66* - **A changelog-only pull request rebuilt 621 of the build's 1136 objects, and three open ones kept invalidating each other over a file none of them had changed any code in.** `ULTRACANVAS_VERSION` was a `PUBLIC` compile definition on the core library, so it sat on the compile command line of every source in the library and of every app and test that links it — `FontFileTest` included. The macro changes whenever anyone adds a changelog entry, because it is read from the first line of `Docs/UltraCanvas/CHANGELOG.md`, so each entry changed the command line of all 621 and every one of them recompiled. Exactly two sources read it (`UltraCanvasUtils.cpp`, `UltraCanvasElementPlugins.cpp`, both with a fallback for its absence), so it is attached to those two with `set_source_files_properties` and is no longer part of the library's interface. A changelog edit now recompiles two objects; the number of targets carrying the definition went from 70 to 1. - **The collision that made those changelog diffs impossible to settle.** Line one of a changelog *is* the product's version — cmake reads it with `file(STRINGS … LIMIT_COUNT 1)` — which makes it the most contended line in the repository. Two open pull requests collide there in one of two ways, and both had happened: - *Stale.* A branch picks the next number, `main` releases further versions while it waits for review, and it merges carrying a number lower than versions already released below it, so the product's version goes backwards. 0.8.53 landed this way over a `main` that had reached 0.8.60. - *Shared.* Two branches write the same `#### *x.y.z*` header. Git sees an identical context line, merges both bullet lists under the one header without a conflict, and two releases share a number while the version never increments. This is also why such a branch's changelog diff never goes away however often `main` is merged into it: its bullets are not in `main`'s copy of that entry, so they are still an addition, and the two branches keep rewriting the same lines. - **`scripts/check_changelog.py` refuses both**, over every changelog `cmake/UltraCanvasVersion.cmake` declares (it parses that file, so the two cannot drift). The top entry must be on line 1, must be unique in its file, and must be strictly greater than every other version in it; with `--base origin/main` a changelog this branch modified must also not still claim the base's version. `.github/workflows/changelog.yml` runs it on pull requests and on pushes to `main`. - **CI now keeps a ccache between runs.** A hosted runner has no incremental state, so every leg compiled all ~1136 objects from scratch even when a pull request changed one file — six legs, macOS billed at 10x and Windows at 2x. `actions/cache` now carries ccache's objects across runs, keyed per `os`/`build_type` with a prefix `restore-keys` so a run with no exact match still starts from the most recent cache for that leg. ccache is installed on all three platforms (apt, brew, and the MSYS2 `mingw-w64-*-ccache`), wired in with `CMAKE_{C,CXX}_COMPILER_LAUNCHER` and `CMAKE_OBJCXX_COMPILER_LAUNCHER` for the macOS Objective-C++ backends, and `ccache -s` is printed after every build so a misconfigured leg shows as a 0% hit rate rather than as a mystery 40-minute run. - `CCACHE_MAXSIZE` is 500 MB, not the 5 GB default: GitHub allows 10 GB of cache per *repository* and evicts least-recently-used, so six legs at the default would evict each other — and the llms-txt and ui-reuse caches — on every push. - `CCACHE_COMPILERCHECK=content`, because the runner image reinstalls the toolchain each run and the default mtime check would call an identical compiler a different one and miss on every object. - The two ccache steps deliberately carry no `shell:`, so they inherit the job default — the MSYS2 shell on the Windows legs. `shell: bash` there is Git Bash, which has no mingw ccache on its PATH. - This is why the `ULTRACANVAS_VERSION` change above matters beyond local builds: a definition on 621 objects' command lines is 621 guaranteed cache misses, since the command line is part of ccache's hash. Measured on a 413-object subset, a changelog-only edit with a wiped build directory hits 411/413 — the two misses being exactly `UltraCanvasUtils.cpp` and `UltraCanvasElementPlugins.cpp`, the only sources that read the macro. - Not covered: the Rust `vtracer` staticlib, which cargo builds and ccache does not see. - History below line 1 is deliberately not policed. The framework changelog carries sixteen duplicated version numbers from before this check existed, some months old and long since released — renumbering a published release would be a lie, so they stay and only new top entries have to be well-formed. `0.8.51`/`0.8.53` sitting out of order near the top are two of them. #### 2026-09-16 *0.8.65* - **`UltraCanvasListView` shows the tooltips its model has always held.** `ListItem::tooltip` and `MultiColumnListItem::tooltip` fed `ToolTipRole`, and nothing ever read it: the view never called `UltraCanvasTooltipManager`, so every list in the framework — the DemoApp ListView page included, whose descriptions promise them — silently dropped its tooltips. The view now tracks the hovered *cell* and shows that cell's `ToolTipRole` text, refreshing it when the pointer moves sideways across a row and hiding it when the cell has none, when the pointer leaves, or when the wheel scrolls rows out from under it. `SetShowItemTooltips(false)` opts out; `tooltipProvider(row, column)` supplies computed text; and `GetTooltipTextAt()` returns what would be shown. - **Column headers and single cells can carry their own tooltip.** `ListColumnDef::tooltip` (a 4th constructor argument) is shown when the pointer rests on that column's header cell, and `MultiColumnListItem::SetCellTooltip(column, text)` gives one column of one row its own text, with `MultiColumnListItem::tooltip` as the row-wide fallback. `GetHeaderColumnAt(x, y)` exposes the header hit test the tooltip uses. - **A scrolled list no longer reports a row for a point inside its header.** `GetRowAtY` added the scroll offset before testing against the rows viewport, so with the list scrolled down, header hits mapped to whichever row the offset landed on — a wrong hover row, and a header click that selected. - **DemoApp `--component ` lands on the component it names.** It called `DisplayDemoItem`, which only swaps the page: the tree kept its startup selection and the header kept naming it, so `--component listview` showed the ListView page under the title "Various menu types and styles" with Menus highlighted in the sidebar. `SelectDemoItem` now takes the same path a click on the tree takes — display, selection, header and status line together — and an unknown id says so instead of silently doing nothing. - **The DemoApp ListView page demonstrates all three.** Its four lists now carry tooltips (fruit descriptions, colour hex values, language descriptions, per-column file details), the file table's headers explain their columns, and clicking a cell reports which column it was and the tooltip behind it. #### 2026-09-16 *0.8.64* - **Every image export is written the safe way now, not just a paint document's save.** 0.8.63 gave `UCRasterDocument::SaveToFile` a staged write - encode beside the target, move it into place once it is whole - but the framework's own export path still handed each libvips saver the caller's file. Every one of them opens truncating, so an export that failed after that point destroyed the picture that was already there. Measured: exporting an image wider than JPEG can represent (libjpeg stops at 65500 pixels) over an existing file leaves it 0 bytes long, because the encoder opens and empties the destination before it checks the dimensions. That reached users through `UCImageRaster::Save` and the image export dialog. `WriteFileAtomically()` in `UltraCanvasFileError.h` now holds the one copy of that logic - staged path in the target's own folder, the target's permissions carried across, a symlink written through rather than replaced, the staged file removed on every failure path including an exception, and the staged name replaced by the caller's own in whatever an encoder says went wrong. `ExportVImage` wraps its encoding half in it, so every caller of the export path gets the guarantee; `SaveToFile` uses it for the one write that does not go that way and hands `ExportVImage` the real path, so a save is staged once rather than twice. `RasterEditingTest` covers both paths with a failure that happens *inside* the encoder, with the destination already open - which is the case that actually destroys a file, and which passes whether or not the write is staged if the test only uses a format nothing can encode. #### 2026-09-16 *0.8.63* - **An image saves over the file it was opened from again, and a save that fails no longer costs the user the file that was there.** Reported from UltraPaint on Windows: opening a JPEG, editing it and pressing Save put up `unable to open for write / system error: Invalid argument`, followed by a write error, `VipsJpeg: unable to write to target` and two `wbuffer_write: write failed` lines - none of which named a cause a user could act on, and all of which were about the user's own file, in their own Downloads folder, which nothing else was holding. Three separate defects, one symptom: - **The editor never let go of the file it had read.** libvips keeps finished operations in a cache, so the loader of an image read minutes ago is still alive - and for a JPEG it keeps the source file *memory-mapped* (measured: the mapping is still in `/proc/self/maps` after the document has copied every pixel into its own buffer and dropped the image; PNG and TIFF do not map). Windows will not truncate a file that has a mapping open in the process: the open fails with `ERROR_USER_MAPPED_FILE`, which the C runtime reports as `EINVAL` - the "Invalid argument" in the dialog. Linux allows the same truncate, which is why this never showed up here. `PixelFX::ReleaseCachedFiles()` now drops those cached operations, and `UCRasterDocument` calls it as soon as a load has been materialised and again before every save. It trims the cache to nothing and lets it grow again rather than calling `vips_cache_drop_all()`, which reads like the call for this and instead frees the cache table, so that the next libvips operation dereferences freed memory and crashes (reproduced on 8.15). - **The save truncated the target before the first byte was encoded.** `write_to_file` opens with `O_TRUNC`, so any failure after that point - no space, a codec error, a destination that is refusing the write - left the user with the remains of the write instead of the image they had. (Writing an image over a file libvips is still reading does it in one step: on Linux the same case truncates the source under the mapping and the process takes a `SIGBUS` mid-encode.) `UCRasterDocument::SaveToFile` now encodes into a temporary file in the target's own folder and renames it over the target - same volume, so the replacement is atomic - carrying the target's permissions across so a private image does not quietly widen to the default mode, removing the temporary file on every failure path, and putting the caller's own file name back into any message an encoder wrote about the temporary one. - **The reason was reported as a transcript.** libvips appends to a process-wide error buffer and returns the whole of it, so `PixelFX::FileIO::Save` / `SaveWithOptions` and `PFXImage::FromFile` reported this failure together with everything left over from earlier operations - the stack of contradictory lines in the dialog. They clear the buffer first now, as `UCImageRaster::Save` and `ExportVImage` already did. Letting go of the cached loader also fixes a second symptom of the same cause: libvips keys a cached load on the file *name*, and nothing in it notices that the file has since been rewritten, so re-opening an image that was saved earlier in the session handed back the image from before the save. Measured: a black JPEG overwritten with a white one still reads back as black until the cache lets go, and reads white afterwards. That is what the new test catches on Linux, where the truncate itself is allowed. `RasterEditingTest` covers the round trip over the file the document was opened from, that the temporary file leaves no trace, and that a save which cannot be encoded leaves the existing file byte-for-byte intact. #### 2026-09-15 *0.8.62* - **The shared image cache could wedge itself permanently full, and then nothing was cached at all.** `UCCache` (`UltraCanvasUtils.h`) keeps a running total of the bytes it holds and asked each entry for its size *again* when it evicted one. For several payloads that answer grows after the entry is stored — `UCImageRaster::GetDataSize()` counts an animation decoded lazily, `UCSvgDocument::GetMemoryBytes()` counts pages rasterized on demand — so an eviction gave back more than was ever charged. The total is a `size_t`: it wrapped. Every insert after that found itself over budget, and the loop that makes room emptied the entire cache to fit one entry, for the rest of the session. The four caches built on it (pixmaps, images, SVG documents, text layouts) then held a single item each, so every picture was decoded again on every use — which is what "the thumbnails stopped showing" looked like in UltraFiler, on a folder that had merely been browsed for long enough. The size is now asked **once, when the entry is stored**, and exactly that is returned on every path out of it; storing the same key twice (four thumbnail workers missing on one picture at the same moment) returns the old entry's bytes before charging the new one, and the subtraction is floored so no future accounting slip can wrap the counter again. `Tests/ImageCacheAccountingTest` covers both, and fails twelve ways against the old code. - **Thumbnails now survive the process that made them.** The Filer's thumbnail cache was memory only, so a folder of photos, videos or documents was decoded again on every launch — and again after any browsing wide enough to push it out of the 96 MB budget. Finished **content previews** are now also written to a per-user cache directory (`%LOCALAPPDATA%\UltraCanvas\thumbnails`, `~/Library/Caches/UltraCanvas/thumbnails`, `$XDG_CACHE_HOME/UltraCanvas/thumbnails`) as QOI blobs — the same compression the in-memory "compressed thumbnails" option uses, so the blob is made once and serves both — and asked for before any decode is queued. Application icons are deliberately not stored: the shell extracts one faster than this could read a file, and an upgraded program must not show yesterday's icon. Staleness is the source file's to decide, not a timer's: each entry records the size and modification time it was made from, and a mismatch deletes the entry and re-decodes, so editing a picture shows the edit. `UltraCanvasThumbnailDiskCache.h`; `SetThumbnailDiskCacheEnabled()`, `GetThumbnailDiskCacheUsage()` and `ClearThumbnailDiskCache()` on the widget switch, measure and empty it. - **The thumbnail cache can now be shown and emptied from an application.** `GetThumbnailCacheStats()` reports the three memory ceilings beside what is used, and counts the application icons separately from the previews - a settings page showing "x of y" should not carry its own copy of y, which is how such a page comes to claim a budget the widget stopped using. `ClearThumbnailMemoryCache()` drops the retained pictures on demand, and `SetThumbnailDiskCacheEnabled()`, `GetThumbnailDiskCacheDirectory()`, `GetThumbnailDiskCacheUsage()` and `ClearThumbnailDiskCache()` do the same for the disk half. `GetThumbnailDiskCacheDirectory()` answers whether or not the cache is switched on: switching it off does not move the files, and a page that reported "nowhere to write" for a cache the user had simply turned off would be describing a machine that does not exist. UltraFiler 1.35.0 is the first caller. - **Used files are touched, unused ones are deleted after two weeks.** A cache keyed by where its content came from is orphaned by every move, rename, upgrade and delete the user makes, and nothing tells it — so without an expiry it grows for the life of the account. Serving an entry stamps it with the day (at most one write per file per day, so scrolling a folder of a thousand pictures costs no disk writes after the first) and entries not served for two weeks are swept by the first thumbnail worker to start — off the UI thread, because it walks a directory. A folder the user keeps visiting keeps its thumbnails indefinitely. - **That retention policy now has one implementation, not two.** `UltraCanvasDiskCache` (`core/UltraCanvasDiskCache.cpp`) holds the per-user cache root, the throttled `Touch()` and the `Sweep()`, and both on-disk caches use it: `FileAssociationsBackend::StampIconCacheFile` / `SweepIconCache` are now three-line wrappers over it and `kIconCacheMaxAge` is the shared default. The icon cache grew this policy first (0.3.98); a second hand-written copy for thumbnails is how two caches orphaned by the same events end up expiring on two different rules. `Tests/ThumbnailDiskCacheTest` covers storing and serving, an edited source, a missing source, the stamp and its throttle, the sweep, a clock that was set back, and that nothing outside the cache's own extensions is ever deleted. #### 2026-09-15 *0.8.61* - **A text field is UTF-8 all the way through now.** Typing the name `Fröhling` into a field — UltraMail's "Add email account" wizard is where it was reported — put invalid UTF-8 into the buffer, and the debug log filled with Pango's `Invalid UTF-8 string passed to pango_layout_set_text()` while the text stopped drawing. `UltraCanvasTextInput` addresses its buffer by byte offset, and five places moved that offset by a *byte*, which cuts every multi-byte character in half: - Backspace and Delete erased one byte, so backspacing over `ö` left the lead byte `C3` behind; - the Left and Right arrows stepped one byte, parking the caret inside a character — and every prefix measured from there (caret x, selection width) was invalid UTF-8 even when the buffer was fine; - the click/drag hit test binary-searched the raw bytes, so it both measured half characters and returned an offset in the middle of one; - `SetCaretPosition()` / `SetSelection()` stored whatever offset a caller passed; - `SetMaxLength()` truncated at a byte count. All of them move by whole characters now (`utf8_prev_boundary` / `utf8_next_boundary` / `utf8_align_boundary` / `utf8_boundaries` / `utf8_bytes_for_chars`, new in `UltraCanvasUtilsUtf8.h`), a click snaps to the nearer boundary of the character under the pointer, and the length limit — like the `MinLength` / `MaxLength` validation rules, whose messages say "characters" — counts characters: `Fröhling` is eight of them in nine bytes. Covered by `Tests/TextInputUtf8Test.cpp`, which runs headless. - **A password field masks one `*` per character, not per byte.** A passphrase with an umlaut in it drew more stars than the user had typed and lost two per Backspace. The mask and the text are different lengths now, so every measurement crosses between them through `ToRenderOffset()` / `FromRenderOffset()`. - **Text entering a widget from outside is repaired rather than trusted.** `SetText()` and `InsertText()` (so: a paste too) pass through the new `utf8_make_valid()`, which replaces malformed bytes with U+FFFD — a clipboard, a database column or a file is not guaranteed to hold UTF-8, and a single stray byte made the whole string unrenderable. - **X11 key events that came from `XLookupString` are re-encoded.** The fallback path, used when a window has no input context, answers in Latin-1, as the X11 API specifies — so an `ö` arrived as the single byte `F6` and every widget that received it was handed something that was not UTF-8. It is converted at the backend now (`UltraCanvasLinuxApplication.cpp`), where the encoding is known. - **Pango is never handed invalid UTF-8.** `UCTextLayout::SetText()` (and the literal-text fallback in `SetMarkup()`) repair the string first: Pango otherwise logs a warning and lays out *nothing*, which turns one bad byte anywhere upstream into a blank field. The widget-level fixes above mean it should never fire; it is there so that the failure mode is a visible U+FFFD rather than silently missing text. - **`From:`, `To:` and `Cc:` carry a non-ASCII display name legally.** `UltraNet_MimeBuild` encoded the `Subject:` as an RFC 2047 encoded-word but wrote address headers raw, so a message from `Fröhling ` went out with a raw 8-bit byte in a header field. The new `UltraNet_MimeEncodeAddress()` encodes the display name and leaves the angle-addr alone — encoding that would make the address undeliverable — and the builder runs all three headers through it. #### 2026-09-15 *0.8.53* - **SVG was read and written through the user's locale, so on a German, French, Russian or Brazilian desktop `opacity="0.25"` meant invisible.** The Linux backend calls `setlocale(LC_ALL, "")` before opening the display, because XIM needs `LC_CTYPE` to accept UTF-8 input — and that also sets `LC_NUMERIC`. Every `std::stof` / `strtod` in the SVG importer (`Plugins/Vector/UltraCanvasSVGConverter.cpp`) then stopped at the `.`: an opacity of `0.25` became 0 and the shape vanished, a `stroke-width` of `1.5` drew at 1, `stop-opacity="0.8"` made a gradient stop fully transparent, and `x="10.5"` put the shape half a pixel-column to the left. SVG numbers are dot-decimal by specification; they are not a property of whoever is running the program. - The writer had the mirror defect, and the worse half of it: `snprintf("%.6g")` renders through `LC_NUMERIC` too, so the exporter wrote `stroke-width="1,5"` — and because a comma separates coordinates in path data, `M 1,5` read back as "move to (1, 5)" rather than "move to 1.5". A different picture, not a file another tool would reject. - `ParseFloatClassic()` — written for `core/HTMLReader/CSSStyleSheet.cpp` in 0.8.47, where the same bug turned every `rgba()` alpha to 0 — moves to `UltraCanvasTextUtils` so there is one copy rather than one per format, and gains a `double` overload and `TryParseFloat()`, the drop-in for a `std::stof` call: it skips leading whitespace, accepts the leading `+` that SVG path data writes, ignores a trailing unit, and neither throws nor consults the locale. `CSSStyleSheet.cpp` now calls the shared one. - `Tests/SVGLocaleTest.cpp` runs the reader, the writer and a save-and-reopen round trip twice — under `C` and under whichever comma-decimal locale the machine has — and requires the two runs to agree. Where no such locale is installed it says so and skips that half rather than reporting coverage it did not get; `sudo localedef -i de_DE -f UTF-8 de_DE.UTF-8` supplies one. - `Plugins/SVG/UltraCanvasSVGPlugin.cpp` carried the same defect in its own `ParseFloatAttribute`, `ParseFromStyle`, `ParseNumbers` and `ParseLength`, and is fixed to match — but it is dead code: its entry in `UltraCanvas/CMakeLists.txt` is commented out, so no target compiles it and nothing here changes at runtime. It is fixed rather than left alone so that re-enabling the file does not reintroduce the bug; whether it should be revived or deleted is a separate question, and its header is still included by `Apps/DemoApp/UltraCanvasSVGExamples.cpp`. Its `stroke-width` and `opacity` parses inside `style="..."` were also unguarded `std::stof` calls, so `style="opacity:inherit"` would have thrown `std::invalid_argument` out of the style parser; `TryParseFloat` not throwing closes that too. - Not fixed here, and the reason the test names the locale it ran under: around 110 further `atof` / `std::stod` / `strtod` calls elsewhere in the tree have the same defect — chart CSV data, OBJ vertices, XLSX font sizes, the CDR plugin. `UltraCanvasTextUtils.h` is where their fix goes. #### 2026-09-15 *0.8.60* - **The OS print dialog now prints what the user chose.** It has shipped for a while and Texter and UltraFiler both call it, but every platform printed by itself and discarded the answers. Linux built a real GTK print dialog, read `GtkPrintSettings` and `GtkPageSetup` out of it, used **neither**, and ran `lpr -P ""` through `system()`. Windows showed no print dialog at all — it wrote a temp file and invoked the shell's `print` verb, so the dialog the user saw was Notepad's, its settings never came back, and the file was left behind on purpose. macOS wrote the text to a fixed path in the temp directory, the same path on every call, handed it to `NSWorkspace` and returned `true` without waiting for anything. So on all three the user picked a printer, copies, collation, paper size, orientation, duplex and a page range, and all of it was dropped. - **The fix is less printing code, not more.** `PrinterDevice` already honours every one of those settings, so the dialog's job is narrowed to *asking*: `RequestPrintSettings()` returns a `NativePrintResult`, and the job goes through `PrinterDevice::Print()` once, for every platform. `ShowPrintDialog()` keeps its signature, so both applications keep compiling and start honouring the dialog. `PrintTextWithDialog()` is the same thing with a real result, so a caller can tell "the user changed their mind" from "the printer was not there" — which a `bool` cannot. - **A settings struct that is not a second vocabulary.** `NativePrintResult` is the printer's name plus an `IOPrintOptions` and a page range. A print dialog exists to produce a print job and `IOPrintOptions` is what `Print()` takes, so any other shape would be a type to convert rather than a type to use. - **Page ranges reach the queue.** `IOPrintJob::pageRange` had been declared since this module was written and was read by nothing: the payload had nowhere to carry it, and a transport only ever sees the payload. It now reaches `IOPrintPayload`, and from there the IPP `page-ranges` attribute under CUPS and the page loop on the GDI path — applied after pagination, because that is the first moment "pages 2-4" can be checked against a document that has pages. - **One rule for what a sheet of paper is.** GTK quotes paper in millimetres, Win32 in tenths through a `DEVMODE`, AppKit in points; all three now convert to hundredths and go to the same `IOPaperSizeFromDimensions()` the CUPS backend already used, promoted out of that file. On Windows the `DMPAPER_*` code is looked up in the driver's own table through `DeviceCapabilities` rather than mapped by hand, because the codes do not all line up — `DMPAPER_B4` is JIS B4 at 257x364 mm while ISO B4 is 250x353. A size with no name in the table is carried by its measurements rather than substituted for A4. - **macOS printing is wired up rather than absent**, through `NSPrintPanel` and the CUPS backend that was already built for it. Duplex is left at its default: it is not on `NSPrintInfo` at all, it lives in the `PMPrintSettings` underneath, and claiming a value would be inventing one. - **"Print to File" is refused by name, not silently ignored.** It comes back in the result, and printing to a queue the user did not choose is the worse of the two wrong answers. - `Tests/IODevicePrinterTest`: 157 assertions, up from 117. Matching a dialog's printer name to a device and building a job from a dialog answer are ordinary functions over data, so they live in a translation unit that names no dialog and are covered on every arm of the matrix. #### 2026-09-15 *0.8.59* - **Native printing now works on Windows.** `Native` was the one renderer `GetAvailableRenderers()` would not offer there: the spooler takes device-ready data and nothing else, so a PDF or a PNG had nowhere to go, and a Windows caller had no native print path at all. It does now, by the route Windows actually provides — `CreateDC` on the printer, `StartDoc`, and per page `StartPage`, GDI calls, `EndPage`, with the driver turning those calls into the device's own commands. - **A print payload gained a third shape, because GDI is not a byte stream.** It was either the printer's own command language (GutenPrint) or a document for the platform's driver (CUPS hands a PDF to its filter chain). A GDI job is neither: it is a drawing session, and there is no intermediate buffer to put anywhere. Forcing it into `data` would have meant inventing a multi-page EMF container that exists only to be unwrapped three calls later. Instead `IOPrintPayload` carries an optional `IPrintPageSource`, and the renderer and transport declare that shape the same way they already declare the other two — `ProducesPageSource()` against `SupportsPageSource()`, symmetric with `ProducesRawStream()`/`SupportsRaw()`. `WindowsPrintTransport::SupportsDocument()` stays `false`, and that is no longer a gap: it is what Windows is. - **Pagination happens against the device, not before it.** `IPrintPageSource` is prepared with the real target before its page count is asked for, because the same document is a different number of pages on A4 at 600 dpi than on Letter at 300. A source that answered earlier would be guessing. - **The layout is platform-neutral, so it is tested everywhere.** Fitting, wrapping and pagination live in `core/` and reach the device only through the abstract `IPrintPageTarget`, so `Tests/IODevicePrinterTest` drives them against a fake target with a synthetic font — 117 assertions, up from 62, running on every arm of the matrix rather than only the one that needs them. Left in `OS/MSWindows` is what genuinely needs Win32: the DC, the `DEVMODE`, the font handles and `StretchDIBits`. - Line breaking reuses `TextWrapping::WrapGreedy` rather than a second implementation of it. It already takes a measure callable for exactly this reason, and already handles UTF-8 boundaries and over-long words; what the printer adds is paragraphs, and settings that suit page flow instead of a truncated caption. - Options reach the driver through a `DEVMODE` built from its own current defaults and handed back for validation, so paper size, orientation, copies, collation, colour mode, duplex and quality are applied — and a request the hardware cannot meet is dropped by the driver rather than silently producing output the caller believes is duplex. - Images are composited onto white rather than alpha-blended: paper is opaque, and blending against an uninitialised page is undefined in practice. - What it does not do yet, refused by name rather than half-printed: PDF and other paginated documents (`NotSupported`, needs the PDF plugin), and images supplied as bytes rather than a path. `Docs/Modules/IODeviceManager/Gaps.md` tracks both. - The page target's method is `DrawTextLine`, not `DrawText`, because `` defines `DrawText` as a macro: a virtual by that name is renamed by the preprocessor wherever windows.h is included, so the override stops overriding and the class turns abstract — with the compiler blaming the override rather than the macro. Found by cross-compiling, as `GetLastError()` was before it. #### 2026-09-14 *0.8.58* - **IODeviceManager: hot-plug watching.** `SetDeviceChangeCallback` existed but only enumeration ever fired it, so a device plugged in after a scan went unnoticed until something rescanned. `StartMonitoring()` closes that for all three categories at once. - **A watcher reports which category changed, not which device.** The manager re-enumerates that category and the merge `EnumerateDevices()` already performs works out what appeared or disappeared - the diffing was there for rescans, so nothing else was needed. It also keeps each platform's watcher small: udev, the Windows device broadcast and IOKit report kernel-level arrivals in their own vocabulary and none of them knows what a `ScannerDevice` is, so translating "a video4linux node appeared" into "some camera changed" is all they do. - **The locking is the difficulty, and it is the part under test.** `StopMonitoring()` joins the watcher's thread while that thread is calling `EnumerateDevices()`, which takes the registry lock; holding either lock across the join deadlocks. Monitoring state therefore lives under its own mutex and both the stop and the join happen with no lock held, and `Shutdown()` stops monitoring before touching the registry. The test's fake watcher reports from another thread on purpose, because the deadlock only appears when the callback arrives from somewhere other than the caller's, and the suite is clean under ThreadSanitizer. - The udev watcher filters `video4linux`, `usb` and `sound` in the kernel - unfiltered, every uevent on the machine wakes the thread to be discarded - and counts only `add` and `remove`, since a `change` action means a device reported a property rather than appeared, and re-enumerating on those makes a busy machine rescan constantly. Events are coalesced over a 250 ms quiet period, because plugging in one webcam produces a burst of them. A `usb` arrival maps to Scanner, Printer and Camera together: the kernel says a USB device appeared, not what it is, and re-enumerating three categories is cheap next to guessing wrong. The poll waits on an eventfd alongside the udev descriptor, so a stop takes as long as the work rather than as long as the poll interval. - Where no watcher is compiled in, `StartMonitoring()` reports `BackendUnavailable`, so a caller can tell "this platform cannot watch" from "nothing has been plugged in yet". #### 2026-09-14 *0.8.57* - **IODeviceManager: scanners, and the SANE backend.** `ScannerDevice` completes the three categories the module's README advertises as production ready. Scanner support was previously described as finished across five protocols with no scanner source in the repository at all. - **An empty feeder ends a run; it does not fail it.** A backend signals a spent tray the only way it can - by not producing a page - which is also how it signals a failure, and conflating the two discards every page already scanned. `DoScanPage()` returns `DeviceNotFound` for an empty feeder specifically, and `ScanPages()` treats that as a normal end once a page has come through. The page count rides back in `backendCode` even on a cancelled or failed run, so a caller always knows what it got. - Colour mode and paper source are refused when unsupported, because a scanner either has them or does not. Resolution is **snapped** to the nearest offered instead, since scanners expose arbitrary values and refusing 301 dpi on a device that does 300 helps nobody. The nearest is chosen at or *below* the request: scanning higher costs time and memory quadratically, which is not a substitution to make silently. - `CancelScan()` takes no lock by design - the scanning thread holds `deviceMutex` for the whole run, so a cancel that waited for it could never arrive in time to cancel anything. - The SANE backend enumerates with `local_only` false so `net`, `escl` and `airscan` are included, since a driverless network scanner is now the common case. Options are walked by name rather than index because backends order them freely, and sources are matched by substring - "ADF Duplex", "Duplex ADF" and "Automatic Document Feeder" all mean the same thing. `sane_init` and `sane_exit` are process-global and not reference-counted by the library, so the count is kept in the backend: a second scanner opening must not re-init, and the first closing must not tear the library out from under the others. - Writing the test found two defects worth naming. `ScannedImage::channels` defaulted to 1, so "the backend did not say" and "genuinely one channel" were the same value - the mistake `IOSupport` exists to avoid elsewhere in this module; it now defaults to 0 and `ScannerDevice` fills it from the colour mode. And the page height was being derived inside the SANE backend, where every future backend would have had to repeat it; a scanner often cannot say how long a page is until the sheet has fed through, so the height falls out of how much data arrived and that arithmetic now lives once in `ScannerDevice`. #### 2026-09-14 *0.8.56* - **IODeviceManager: cameras, and the V4L2 backend.** `CameraDevice` joins `PrinterDevice` as a category class, with the V4L2 webcam backend behind it - the backend the module's documentation has described as finished for some time and which had never been written. - **Streaming has two rules, and both are asserted rather than assumed.** No frame may reach a callback after `StopStream()` returns, because by then the caller has usually destroyed whatever the callback writes into: `StopStream()` clears the streaming flag first so a capture loop winds down, then joins the thread. And a backend stops its own thread in its own destructor, because `~CameraDevice()` calls no virtuals - the derived object is already gone, so a thread still calling `DeliverFrame()` would be reading freed memory. `DeliverFrame()` takes no lock by design: `StopStream()` holds `deviceMutex` while joining, so locking there would deadlock. - **Camera controls are enumerated, not a struct of booleans.** `CameraCapabilities::controls` lists only the controls a camera actually reports, so iterating it enumerates them; a field per control has to guess the union of every camera in advance and still cannot say whether a given one has it. `SetControl` clamps to the control's range and snaps to its step, so a caller can pass a slider position - `V4L2_CID_EXPOSURE_ABSOLUTE` with minimum 3 and step 4 takes 3, 7, 11, not 0, 4, 8. - `SetConfiguration` refuses a format and resolution the camera does not offer instead of capturing something else, which a caller would notice only by inspecting frames; `ResolveConfiguration` fills in what was left unset, preferring an uncompressed format so pixels are readable without a decoder, and says what it chose. - The V4L2 backend skips `/dev/video*` nodes without `V4L2_CAP_VIDEO_CAPTURE`: modern kernels give one camera several nodes, and the metadata ones would otherwise be offered as cameras that never yield a frame. It opens non-blocking with `poll()` for the timeout so a stalled camera cannot wedge the caller, retries every ioctl on `EINTR` because a signal is not a device error, and copies `bytesused` rather than the buffer length - for MJPEG those differ by however much the frame compressed, and the difference would be appended to every frame as garbage. - `IOSupport` moved from the printer vocabulary to the device-generic types: cameras need the same "not reported is not the same as not supported" distinction. - `Tests/IODeviceCameraTest` drives all of it through a fake camera, so it runs with no `/dev/video*` present. It is clean under ThreadSanitizer, which is the check that means something for a threaded capture path. #### 2026-09-14 *0.8.55* - **IODeviceManager: the Windows printer backend, and with it GutenPrint on all three platforms.** Spooler enumeration, capabilities from `DeviceCapabilitiesW`, printer and job status, job cancellation, and the transport that hands a device-native stream to `StartDocPrinter` with datatype `RAW` - the path a GutenPrint-rendered page takes. With the CUPS transport already carrying raw jobs, the renderer can now be added as one class with no change to any platform's transport, which is what separating renderer from transport was for. - **A transport now declares what it can carry, not just whether it takes raw jobs.** CUPS has a filter chain, so a PDF can be handed over as-is and the native renderer is a pass-through. The Windows spooler has no equivalent: it takes device-ready data, or EMF/XPS produced by drawing to a printer DC. So `IPrintTransport::SupportsDocument()` joins `SupportsRaw()`, and `PrinterDevice` offers only the renderer/transport pairings that match. On Windows that means the `Native` renderer is withheld until the GDI renderer exists, and a caller reads that from `GetAvailableRenderers()` instead of from a job that disappears. - **Renamed `IODevice::GetLastError()` to `GetLastDeviceError()`.** Win32 has a global `GetLastError()`, and a member of that name shadows it inside every device class deriving from `IODevice` - so each Windows backend would have had to remember to write `::GetLastError()` for the API it meant, and would have compiled either way. Found by cross-compiling the new backend rather than by reading it. - Paper sizes on Windows are recognised the same way as under CUPS: by measurement rather than by the name a driver gives them. `DC_PAPERSIZE` reports tenths of a millimetre against `IOPaperDimensions`' hundredths, so each measure is scaled rather than renamed. - Supply levels report nothing on Windows rather than inventing a number: the spooler has no supply-level API at all, only a `PRINTER_STATUS_NO_TONER` status bit. Reading real levels there needs SNMP or a vendor SDK. #### 2026-09-14 *0.8.54* - **IODeviceManager: printers, and the switch between GutenPrint and the platform driver.** `PrinterDevice` lands with the renderer/transport split that makes that switch possible on Windows as well as Linux and macOS, plus a CUPS backend behind it. - The split is the point. GutenPrint is two things: `libgutenprint`, which is portable C that turns a page into the printer's own command stream, and `rastertogutenprint`, a CUPS filter. Only the second is Unix-only. Treating them as one thing is what confines a printing layer to Linux and macOS, so here the **renderer** (`Native`, `GutenPrint`, `IPP`) is the application's choice and the **transport** that carries its output is the platform's: a CUPS raw job under Unix, `StartDocPrinter` with datatype `RAW` under Windows. One rendering path, a short transport shim per platform. - `PrinterDevice::GetAvailableRenderers()` answers per printer, not per platform: a renderer is offered only when it is compiled in, its library is present, it recognises that model, and — for one emitting a device-native stream — the transport can carry a raw job. `SetRenderer()` refuses a renderer that is not available instead of accepting it and falling back at print time. - `ResolvePrintOptions()` folds a requested option set down to what the printer will accept, in GutenPrint's priority order - media, then resolution, cartridge, inkset, duplex - because the parameters constrain each other: 2880 dpi on plain paper yields High, photo black ink on plain paper becomes matte black, a colour inkset is dropped for monochrome. Every substitution comes back in words a print dialog can show ("A3 is not supported, using A4") rather than being applied silently. - An unreported capability is not a refusal. An empty capability list means the backend did not say, and the three-valued `IOSupport` (`Unknown`/`No`/`Yes`) carries the same distinction for the flags, because a plain bool cannot tell "this printer has no duplex unit" from "we could not read this printer's capabilities" - and conflating them strips options from a printer that would have accepted them. - The CUPS backend covers enumeration, capabilities, status, supply levels and the job queue, and its transport carries both driver documents and raw streams, so GutenPrint needs no further transport work on Unix. It reads capabilities through the dest-info API rather than PPD files, so paper sizes are recognised by their dimensions - CUPS reports hundredths of a millimetre, as `IOPaperDimensions` does - instead of by the name a printer gives them. Supply levels keep CUPS's -1 for "the printer did not say", which is not the same as empty. It is one file in `core/` rather than a copy under each platform directory, because CUPS is the same library with the same API on Linux and macOS and two copies only drift. - `Tests/IODevicePrinterTest` drives renderer selection and the resolver through a fake transport and a fake raw-emitting renderer, so the seam GutenPrint will plug into is asserted without libgutenprint or a printer being present. It found a real defect while being written: capability booleans could not express "not reported", which is what prompted `IOSupport`. - Whether GutenPrint is linked or run as a subprocess is still open, and is a licensing question rather than a technical one - libgutenprint is GPL-2.0 or later, UltraCanvas is MIT. `Docs/Modules/IODeviceManager/Architecture.md` carries the trade-off. Adding the renderer once that is settled is a renderer class and nothing else. #### 2026-09-14 *0.8.53* - **IODeviceManager: the foundation layer.** The module had documentation but no code; this lands the base every device category will derive from, so the scanner, camera and printer work has something to build against. - `IODevice` (`include/IODeviceManager/UltraCanvasIODevice.h`) owns the device lifecycle. Callers use `Connect()`/`Disconnect()`; backends implement `DoConnect()`/`DoDisconnect()` and the base keeps the state machine, the error slot and the locking in one place. The base destructor calls no virtuals - by the time it runs the derived object is gone, so a virtual call from there dispatches into a dead object - and `Disconnect()` always reaches the backend, even after a `Connect()` that failed halfway and may still hold handles. - `IODeviceManager` (`UltraCanvasIODeviceManager.h`) is the registry. Backends attach as **enumerators**, one per (category, backend) pair, rather than as one `EnumerateCameras()`-style method per category: a category is routinely served by two backends on one platform - V4L2 webcams and gphoto2 DSLRs are both cameras - and a method per category forces those two to define the same symbol, so they collide at link time and only one ever runs. `EnumerateDevices` merges every enumerator's results, keeps the existing object for a device that is still present so an open session survives a rescan, drops the ones that went away, and contains a throwing backend instead of losing the devices the others found. - `IODeviceResult` carries `Ok`/`Error(code, msg)` factories, an explicit `operator bool`, a typed `IODeviceResultCode` and the backend's own status in `backendCode` for the log - the shape `UltraNetResult` and `UltraDbResult` already use. - Backends register through `Internal::RegisterCompiledBackends()` rather than from static initialisers, because a static-library build drops the static initialisers of object files nothing else references, which would silently leave a platform with no devices at all. - `Tests/IODeviceManagerTest` drives all of it through a fake backend, so it runs on a CI machine with no hardware attached. The foundation depends on nothing but the standard library, so the test also builds where the rendering dependencies are absent. - `Docs/Modules/IODeviceManager/Architecture.md` is the API contract, and carries the printer design: renderer (`Native`/`GutenPrint`/`IPP`) separated from transport (CUPS raw job, or `StartDocPrinter` with datatype `RAW`), which is what makes GutenPrint selectable on Windows as well as Linux and macOS. libgutenprint is portable C and needs no CUPS; only its *CUPS driver* is Unix-only, and conflating the two is what made the earlier prototype Linux/macOS-only. The GPL-vs-MIT question that decides whether it is linked or run as a subprocess is written up there, unanswered - it is a product decision. #### 2026-09-15 *0.8.52* - **`UltraCanvasPaintSurface::SetPanMode` never turned permanent panning on.** The parameter was named after the member it was meant to set, so the body assigned the parameter to itself and `alwaysPan` stayed false; only Space+drag and the middle button ever panned, and the Hand cursor the call set was the one visible sign anything had happened. The parameter is `enabled` now and the member is written, so a Pan tool's every drag scrolls the view as `Docs/UltraCanvas/UltraCanvasPaintSurface.md` has always said. `SetToolCursor` already deferred to the member. #### 2026-09-15 *0.8.51* - **The vector document model is part of the core library now.** `VectorStorage::VectorDocument`, its `IRenderContext` renderer, the path normalisation in `PathOps` and the `UltraCanvasVectorElement` viewer moved from the Vector plugin to `include/DataFormats/` (+ `include/`) and `core/DataFormats/` (+ `core/`), where `ModelStorage` already lives for 3D. The plugin (`ULTRACANVAS_PLUGIN_VECTOR`, still off by default) is the format converters only. Reason: the model is what a vector *editor* edits, and an editing element in core cannot depend on a plugin that CI does not build — see `Docs/Research/ArtCreatorVectorCanvasProposal.md`, the investigation for the ArtCreator application and a public `UltraCanvasVectorCanvas` element. Includes are `DataFormats/UltraCanvasVectorStorage.h` (was `UltraCanvasVectorStorage.h` from the plugin's directory); nothing in the API changed. - **The vector renderer draws what the model holds.** Paths go through `PathOps::NormalizePath`, so SVG arcs are curves (they were drawn as a straight chord to the end point), smooth quadratics are drawn (they were dropped), and relative / H / V forms all agree with the writers. Object-bounding-box gradients resolve against the shape's own outline (they resolved against a fixed 100 x 100 box, so a gradient on any other shape was mostly one end colour). Fill-opacity and stroke-opacity are folded into the paint's alpha (they were ignored). Text spans are set in their own font and advanced by their measured width, and the text anchor is honoured. A `ClipPath` reference on a style clips to the definition's outlines. A conical gradient paints the average of its stops until the render context has a conic pattern. `VectorModelTest` renders offscreen and samples pixels for the arc, the gradient bounds, the opacity and the clip. - **The render context can composite, hit-test and outline text.** What a vector editor needs and nothing surfaced, although Cairo had it all: `SetBlendMode` (the 16 PDF / CSS blend modes; Xara's transparency mixes map onto them); groups — `BeginGroup` / `EndGroup(opacity)` composite everything between them once, so overlapping children of a 50 % layer no longer stack, `EndGroupAsPattern` hands the group back as a paint source, `EndGroupMasked` paints it through a mask, `PaintPattern` paints any pattern; `IsPointInFill` / `IsPointInStroke` / `GetStrokeExtents` against the current path with the current stroke; `GetTransform` / `UserToDevice` / `DeviceToUser` / `DeviceToUserDistance`, so a handle or hairline can be sized in device pixels without a parallel view matrix; `CreateConicGradientPattern` (a fan of mesh patches - Cairo has no conic), `CreateMeshGradientPattern` (Coons patches: Xara's diamond and three / four-colour fills, SVG 2 meshes), `CreatePixmapPattern` (an image already in memory as paint); `IPaintPattern::SetMatrix` / `SetExtend` (SVG's gradientTransform and spread, pattern tiling); `SetAntialias`; and `AppendTextPath` / `AppendTextLayoutPath`, text as geometry to fill, stroke, clip to or convert to curves. Every method has a base default so other backends stay valid. New doc `Docs/UltraCanvas/UltraCanvasRenderContext.md`; new CTest `RenderContextTest` samples every feature back from an offscreen surface. - **A vector drawing can be edited now: the editing layer and `UltraCanvasVectorCanvas`.** Phase 2 of the ArtCreator proposal, all in the core library and all without a window: - `DataFormats/UltraCanvasVectorEdit.h` — `VectorSelection` (ordered elements, document-space bounds, listeners, re-binding by Id after an undo), `VectorHistory` (labelled undo / redo by document snapshots restored into the live document object, coalescing for nudges and drags, `CancelEdit`, a memory budget), `VectorHitTester` (fill and stroke hit testing with a tolerance through every ancestor transform, locked layers skipped, rectangle queries), and the operations an editor's commands are made of: transform / scale / rotate / skew about a pivot with the result composed into the element's own transform (`T' = P⁻¹·M·P·T`), bake a transform into geometry, z-order, group / ungroup / reparent with placement preserved, delete, duplicate, align, distribute, convert to path. - `UltraCanvasBezierPath.h` — the node model of the Bézier editor proposal, built on `VectorStorage::PathData` now that the model is in core: anchors owning two handles, Corner / Smooth / Symmetric rules, de Casteljau splitting, outline and node hit tests, a lossless round trip to `PathData` (every command kind in, M / L / C / Z out), and `FromPolyline` (Douglas-Peucker + Catmull-Rom) for a freehand tool. - `UltraCanvasVectorCanvas` — the element: pasteboard, page, grid, rulers in any unit, guides pulled out of the rulers, snapping to guides / page / objects / grid, the selection's scale or rotate / skew handles with a movable centre, and the same tool-hook shape as `UltraCanvasPaintSurface` with pointer events in document coordinates (raw and snapped). It never edits the document. - `UltraCanvasGradientEditor` — the stops of a gradient on a strip, the colour-ramp sibling of `UltraCanvasCurveEditor`. - `VectorGroup`, `VectorLayer` and `VectorSymbol` clones re-parent their cloned children; they pointed at the original group, so `GetGlobalTransform` on a copy walked the wrong tree - which every history snapshot would have hit. - Docs `UltraCanvasVectorCanvas.md` and `UltraCanvasGradientEditor.md`, catalogue rows, `Masterfile_modules.md` entries; new CTest `VectorEditTest`. - Demo: a **Vector Editing** page (`UltraCanvasVectorCanvasExamples.cpp`) with a selector, rectangle, ellipse and freehand tool, undo / redo, grouping and a gradient ramp bound to the selected shape - about 250 lines of tool code over the layer, the reference for what an application adds. - The first application on the layer is **ArtCreator** (`Apps/ArtCreator`, its own `Docs/ArtCreator/CHANGELOG.md`); phase 3 of the proposal. - **`FormatCapabilities` tell the truth.** `SVGConverter` no longer claims clipping; `XARConverter` no longer claims text on a path, embedded fonts, conical fills, patterns, variable-width strokes, blend modes, filters, clipping, masking, drop shadows, symbols, pages or live effects - the live reader and writer implement none of them - and reports `MaxGradientStops = 2`. `VectorFormatsPluginTest` pins the flags. - `VectorModelTest` builds against the core library alone and so runs in CI, which never enabled the plugin; its DXF round-trip section runs when the plugin is present and says so when it is not. - `UltraCanvas/CMakeLists.txt` declared `option(ULTRACANVAS_PLUGIN_VECTOR, ...)` — the comma was part of the option's name. Fixed; the top-level declaration had been masking it. - New: `Docs/Research/ArtCreatorVectorCanvasProposal.md` — what a Xara Designer-class editor needs, what the framework has, and the proposed core model / history / canvas-element / application split, with the render-context and model additions each phase needs. - `Masterfile_modules.md` gains the `UltraCanvasVectorStorage` entry; the element catalogue lists `UltraCanvasVectorElement`. - **The macOS Intel build is green again.** `HTMLReader/CSSStyleSheet.cpp` parsed CSS numbers with `std::from_chars`, which 0.8.47 introduced to get away from `strtof` - that one honours `LC_NUMERIC`, so a comma-decimal locale read every `rgba()` alpha and every length as `0`. Apple's libc++ implements only the *integral* `from_chars` overloads, and the `bool` one it does declare is `= delete`, so on the Xcode 16.4 SDK the float call resolved to the deleted overload and the file did not compile at all - `build (macos-15-intel, Release)` failed on every push, `main` included, while the Linux and Windows legs were fine. The number is now scanned by hand and converted through `std::locale::classic()`, which keeps the locale independence without ``. Scanning first also matters on its own account: converting the whole string in one go reads the `e` of `1.5em` as the start of an exponent and then fails outright, losing the commonest unit in CSS. Checked against `std::from_chars` over 25 inputs - value and end position agree on each - and `HTMLReaderTest` passes under a comma-decimal locale as well as under C. The code itself reached `main` ahead of this note, ported into the 0.8.49 release to unblock the branches the red leg was holding up; this entry is the release record it went in without. #### 2026-09-15 *0.8.50* - **A WYSIWYG editing element: `UltraCanvasRichTextEdit`.** The caret sits in rendered text and bold is a state of the selection, not two asterisks in a buffer. It edits a `UCRichDocument` - the same block/run model the ODT, DOCX, legacy `.doc` and LaTeX readers and writers already produce - so a 14 pt Georgia run in red survives a round trip through `.odt`, which is exactly what the Markdown detour could never carry. This closes the "Phase 5 interactive styled-run editor" that `ODT-DOCX-Support-Proposal.md` had deferred for its own design round; that round is `Docs/UltraCanvas/WYSIWYGElementInvestigation.md` and the element's documentation is `Docs/UltraCanvas/UltraCanvasRichTextEdit.md`. - Three layers, each testable on its own: `UCRichDocument` (model), `UCRichDocumentEditor` (positions, editing commands, formatting, undo - UI-free, no framework headers) and the element (block layouts, rendering, input, caret, scrolling, clipboard). - Positions are `{blockIndex, byteOffset}` into a block's concatenated run text, never `{run, offset}`: applying a format splits and merges runs constantly and the caret must not move when the run structure changes underneath it. That same string is what the element hands to `ITextLayout`, so hit testing and caret geometry need no translation layer. - Every `RichTextRun` attribute maps onto an existing `TextAttributeFactory` call, so the whole editor is cross-platform through the one Cairo/Pango `ITextLayout` implementation. - Undo records the blocks an edit replaced rather than the document, so its cost is the edit and the embedded media is never copied; consecutive keystrokes coalesce into one step. - Character formatting (bold/italic/underline/strike/code/sub/superscript, font, size, colour, link), paragraph formatting (headings, alignment, bullet and numbered lists with nesting, quotes, code blocks), rules, page breaks and image insertion; `GetFormatState()` reports each attribute as on, off or *mixed* so a toolbar can show a mixed selection honestly. Pressing Bold at a collapsed caret arms the format for what is typed next. - Block layouts are built only for blocks near the viewport; the rest carry an estimated height until they scroll in. - Toolbars are not drawn by the element - build them from `UltraCanvasToolbar`, `UltraCanvasDropdown`, `UltraCanvasButton` and `UltraCanvasColorPicker`, per the framework's UI-reuse rule. - Known limits, documented rather than hidden: tables render but are not edited in place, images are not resized interactively, math runs render as their LaTeX source, there is no spell checking yet, and rich paste between applications still needs the clipboard MIME flavours `UltraCanvasClipboardBackend` does not carry (copy/paste *inside* an application does keep formatting). - **`UCRichDocument` moved from `Plugins/Documents/Word/` into `core/`** (`include/UltraCanvasRichDocument.h`, `core/UltraCanvasRichDocument.cpp`). A framework element cannot depend upward on a plugin; the model was already framework-free and compiled unconditionally, and the ODT/DOCX/DOC readers and writers stay where they are and now depend downward. Only include paths changed. - Tests: `Tests/RichTextEditorTest.cpp` covers the editing rules without a display (positions, run splitting and coalescing, formatting, structure, clipboard ranges, undo/redo); `Tests/RichTextEditElementTest.cpp` covers layout geometry, hit testing and typed input against a real render context, skipping itself when there is no display. #### 2026-09-14 *0.8.49* - **The demo's LaTeX page showed the math engine and almost nothing else.** Of the 24 documents it listed, 23 were single formulas, so the document reader — sections, tables, figures, code, macros, theorems, references — was represented by one file. Six more `article-*.tex` documents now stand beside it in `media/LaTex`, one cluster of the subset each: code listings, data tables, figures and images, structure and cross-references, text and characters, macros and theorems. They are listed in `Docs/UltraCanvas/UltraCanvasLaTeXDocumentReader.md`, appear in the page automatically (it scans the folder), and every one of them imports with no diagnostics — the corpus test requires it. Fragments for `\input` live in `media/LaTex/parts/`, which the scan does not descend into. - **The MicroTeX oracle now compares the corpus's formulas, not its articles.** `MathEngineTest` typeset every `.tex` in `media/LaTex` as one formula in both engines and compared the boxes, which held while the folder held only formulas; an article is prose, so the comparison measured the two engines' *text* fallbacks against each other and the mean height deviation went from around 7% to 19%. It now takes only the single-formula documents, by the same rule the demo uses to choose a file's rendering path, and says how many articles it skipped. The tolerances are untouched: the corpus compares at 5.9% / 7.2% over 63 formulas. - Three reader fixes the new samples turned up, each with its own test in `Tests/LaTeXDocumentTest.cpp`: - **`\captionof{table}` numbered its caption with the figures.** The kind argument was read and thrown away, so the one construction that exists to caption a table outside a float — a `longtable`, a `tabular` in a `minipage` — got "Figure n", and every `\ref` to it followed. - **A `\newenvironment` that wraps another environment did not close.** `\newenvironment{aside}{\begin{quote}\itshape}{\end{quote}}` is the idiomatic form; its `\end{aside}` met the inner `quote` on the stack and reported both a mismatch and an unclosed environment. The end body now runs first, closing the inner environment, and the user frame closes behind it, as in TeX. - **A spliced body ending in a control word glued onto the next letter.** A begin body ending `\itshape` in front of `Set aside.` re-scanned as `\itshapeSet`, an unknown command that swallowed the word. TeX never merges the two, since the body was tokenised when it was defined; the terminating space its scanner would have consumed is added back. #### 2026-09-14 *0.8.48* - **The top of a tall formula was cropped away on the demo's LaTeX Documents page**, with the pane's vertical scrollbar already at the top and no way to bring it back. The rendered-output pane centres its content, and centring an item that does not fit gave it a *negative* offset: half the overflow landed above the pane's content origin. A container clips its children to its content box and its scrollbar travels from that edge, so everything above it was unreachable — and the scrollbar's range covered only the other half of the overflow, which is why scrolling to the bottom did not reveal the missing lines either. - **Flex alignment is now safe** (CSS Box Alignment's `safe` fallback): `align-items` / `align-self` place an item that does not fit its line at the **start** of it instead of at a negative offset, so the overflow falls at the end, where the scrollbar reaches it. Anything that does fit is centred (or end-aligned) exactly as before, and `justify-content` was already safe — it distributes only non-negative free space. The grid engine needs no such fallback: `ArrangeGrid` sizes a non-stretch item to `min(track, natural)`, so a grid item is never larger than the area it is aligned in. - Where this changes an existing layout, it changes one that was already losing content: an oversized item is now cut only at its end rather than at both ends, which is what a clipping container can actually show. - `Tests/CSSLayoutSafeAlignTest.cpp` pins it on the LaTeX page's own shape — a growing centred pane inside a flex column — and checks the scroll range `UltraCanvasContainer::UpdateScrollability` derives from the result, so the whole overflow is reachable. Documented in `Docs/CSSLayout.md`. #### 2026-09-14 *0.8.47* - **The demo's XAR page now says where the format comes from.** A third panel under the feature and sample panels explains that Xara is the successor of ArtWorks, the vector graphics editor for RISC OS - the first OS that ran on ARM CPUs - known for its ultra-fast CPU-based vector rendering and its user-friendly interface, and ported to Windows as Xara. The two Wikipedia references are clickable labels that hand the URL to the system browser through `OpenURL`, the same way the PDF page links MuPDF. The page grew from 780 to 1080 points tall to hold it, so the status line moved down with it. `Docs/UltraCanvas/UltraCanvasXARExamples.md` carries the same note. - **The macOS Intel build is green again**, through `main`'s own `ParseFloatClassic()` in `core/HTMLReader/CSSStyleSheet.cpp` (the #440 port). This branch carried a second fix for the same break - Apple's libc++ has the integral `std::from_chars` overloads only, so `from_chars(..., float&)` resolved to the deleted `bool` overload and the file did not compile, which had every PR red on the `macos-15-intel` runner - and that version is gone in favour of `main`'s, which is the same scan-then-convert approach without a `__cpp_lib_to_chars` branch. - What is kept is the test coverage: `Tests/HTMLReaderTest.cpp` now pins the number shapes such a hand-written scanner has to get right, because they are where it drifts from `from_chars`. `"1.5em"` is the one that matters - an `e` that turns out to be a unit rather than an exponent, which a stream handed the whole string consumes before failing outright; then `.5em`, `1.5e2px`, `-3px`, `1em`, a bare `1e`, and `1e999px`, which is consumed but leaves the value alone, exactly as the out-of-range `from_chars` did. - Fixes in HTML rendering #### 2026-09-14 *0.8.46* - **A folder display can leave out names that are not hidden at all.** `UltraCanvasFilerWidget::SetIgnoredNamePatterns(patterns, onlyInFolder)` takes glob patterns - `*` any run, `?` one character - matched against each entry's name, case-insensitively, folders included; `onlyInFolder` confines them to a single folder, empty applies them everywhere. It reaches what no hidden-file filter can: clutter a system drops into a folder under a perfectly ordinary, unhidden name. `Sti_Trace.log` is the case that prompted it - the Windows Still Image (WIA) subsystem writes its trace log into the working directory of whatever process last talked to a scanner or camera, which for a desktop app is the user's profile, and it carries no hidden attribute for anything to catch. The cross-platform case is the same in reverse: a Windows share browsed from Linux or macOS shows `Thumbs.db` and `desktop.ini` with their hidden attribute invisible, so the name is all there is to filter on. Nothing is moved or deleted - the entries are only left out of the display, a path still navigates to them, and a file-list display (a search) is exempt, since a search is a question the user asked. Show-hidden-files suspends the patterns like every other filter, and what they drop counts into `GetHiddenItemCount()` and the new `GetIgnoredItemCount()`. - **The hidden-items notice grew a middle setting**, because "announce everything the listing leaves out" is right for a profile folder and noise in every folder that holds a dot name. `SetHiddenItemsNoticeEnabled(bool)` is now `SetHiddenItemsNotice(FilerHiddenNotice)`: `NoNotice` (the default), `WhenIgnored` - only while the ignore patterns dropped something, i.e. while a *setting* is holding something back - and `WhenAnyHidden`, the old `true`. The enumerators are spelled out because `None` and `Always` are X11 macros, the same reason `FilerExtensionBadge::NoneBadge` is. #### 2026-09-13 *0.8.45* - **Fixed the text caret blinking through an open menu.** A menu (or any popup) opened over the text cursor - Texter's *Edit* menu over the editing position is the case this was found in - had the caret blinking on top of the menu's items, because window composition drew popups first and the shared caret last, above everything. - The caret now belongs to the layer of the widget that owns it: `UltraCanvasCaret::GetPopupLayer()` reports the popup its owner lives in (nullptr for the window's own content), and `UltraCanvasWindowBase::UpdateAndRender` composites the caret directly above that layer. A caret in the window content goes under every popup; a caret inside a popup - an editable dropdown, the autocomplete field - still sits above that popup and under any popup opened on top of it, and tooltips stay above all of it. - The blink-only fast path (restore the pixels under the caret, re-blend the caret, render nothing) used to be abandoned whenever *any* popup was visible. It is now given up only when an overlay stacked above the caret's layer actually touches the caret rectangle, so typing next to an open menu or a dropdown elsewhere in the window no longer re-composites the whole window twice a second. When the caret lives in a popup, those pixels are restored from the popup's own surface instead of the window content, which is what makes that path safe there at all. - New `UltraCanvasUIElement::IsPopupElement()` exposes the existing `isPopup` flag that the compositor needs to resolve the stacking. - `UltraCanvasCaret::Hide()` now asks for a full composition rather than a caret-area one. The shortcut restores the caret's pixels from the layer it belongs to, and Hide is the moment the caret stops having one - so its own erase could not go through it. Nothing showed a stale caret today because every caller repaints its widget as well; the caret no longer depends on that. - `Tests/CaretStackingTest` is the regression test: it opens a real window under Xvfb, puts a menu over the caret and reads the composited pixels back. It skips itself where there is no display. #### 2026-09-13 *0.8.43* - **A folder display can say what it is holding back.** A file display that drops entries without a word is how a user comes to believe a folder is empty - and deletes it. `UltraCanvasFilerWidget::SetHiddenItemsNoticeEnabled` (off by default) turns on a strip across the foot of the display that reads "3 items are hidden here" whenever the current listing leaves something out, with a **Show hidden files** button doing what the Display > Hidden files context-menu entry does - for that display only. `GetHiddenItemCount()` reads the same number: the hidden entries the scan skipped plus, in a curated home folder (`SetCuratedHomeFolder`), the subfolders the curation keeps back. The count is taken while the listing is built, since afterwards the dropped entries are gone, and `ScanRealDirectory` grew an optional out-parameter for it so the hidden entries are still skipped before the stat they would otherwise cost. The strip takes its height out of the file area the way the selection info bar does - it sits directly above it - so no entry is ever drawn under it; it stays out of the whole-area views (GourceTree, View3D) and of a pane too short to hold both files and strip. Its button is a real `UltraCanvasButton` child, placed and drawn by the self-rendered view like the name filter's "no matches" action. - **Wrapped text in a flex column was drawn one line tall.** A column flex container took each auto-height item's published `intrinsic.maxContentHeight` as its flex base size. That is the height the content takes with *unbounded* width - one line, for text - so every wrapped `UltraCanvasLabel` in a flex column got a one-line box and drew its text clipped through it, top and bottom (the label centres its text vertically). Visible on every UltraFiler settings page: the line under each page title and the notes block at its foot both showed a couple of half-cut lines. The shortcut is right for a row, where the published max-content *width* really is the base size, and is kept there; a column now measures the item, and the block path inside that measure resolves the content width first and asks the widget for its height at that width. Anything whose height depends on its width - wrapped labels above all - now sizes correctly in a flex column. #### 2026-09-13 *0.8.42* - **`media/` root tidied: a stray file deleted, two sample assets filed under `media/docs/`.** - `media/audio` is gone. It was a 1-byte regular file containing a single newline, unrelated to the `media/audios/` directory beside it, and nothing read it. - `media/Logo_Texter.png` and `media/MarkdownExample.md` move into `media/docs/`, beside the `document.odt` and `spreadsheet.ods` samples. All four live references follow them: the demo's Markdown page (`UltraCanvasDemo.cpp`) and its commented-out sibling in `UltraCanvasDemoExamples.cpp`, Texter's splash logo (`UltraCanvasTextEditor.cpp`), `Tests/FontFileTest`'s "a PNG is not a font" fixture, and the `\includegraphics` path in `media/LaTex/article-quadratic-note.tex`. The packaging scripts copy `media/` whole, so nothing changes for a packaged build. #### 2026-09-13 *0.8.41* - **`media/vector/EPS/Apple5.eps` and `Midget.eps` are deleted.** Neither was ever an EPS: both are RISC OS TopDraw documents (`Top!` / `TopDraw` magic, no `%!PS` header and no PostScript body), which is why the EPS page never tiled them and `Tests/EPSProbeTest` reported them as load failures. With the files gone the notes explaining their absence go too, from `UltraCanvasEPSExamples.cpp` and `UltraCanvasEPSExamples.md`; the page still shows `demo.eps` and `gears.eps`, which is now every `.eps` in the folder. The same two drawings remain available in Xara form as `media/vector/XAR/Apple5.xar` and `Midget.xar`. #### 2026-09-13 *0.8.40* - **Demo: the SVG and CDR pages show every sample in their folder, and the CDR folder loses its placeholders.** - `media/vector/CDR/`: `demo.cdr`, `demo1.cdr`, `logo.cdr` and the `demo.jpg` reference render are gone. Three of the page's five hand-written tiles pointed at them, so the page is rebuilt around one tile helper over the three drawings that remain - `detailed.cdr`, `door-panel.cdr`, `dubai-atlantis.cdr` - each with page navigation, zoom / fit and "Save as...". A second panel gives each drawing's real shape, read out of the parsed `CDRDocument`: 67 draw calls but 10 gradients and 6 embedded bitmaps (detailed), 400 flat-filled draw calls (door-panel), 725 (dubai-atlantis); all three are single-page. - SVG: the page showed 4 of the 8 shipped drawings through four copy-pasted blocks that also handed three elements the same id (`SVGContainer`, `DemoSVG2`). Replaced by one tile helper over all eight - `demo`, `demo1`, `demo2`, `svg-test`, `robot`, `astronaut`, `photo-camera`, `Logo_Texter` - four to a row, each with its file name, a shared status line and unique ids. All eight verified to decode. - EPS: `Apple5.eps` and `Midget.eps` stay off the page on purpose. Despite the extension neither is PostScript - both are RISC OS TopDraw documents (`Top!` / `TopDraw` magic, no `%!PS` header, no PostScript body) - so `Tests/EPSProbeTest` reports them as load failures and tiles for them would render nothing. Said so in the source and in `UltraCanvasEPSExamples.md` rather than leaving it to be rediscovered. #### 2026-09-13 *0.8.39* - **Demo: the vector pages cover every sample the repo actually ships.** - `media/vector/XAR/`: `demo.xar` and the lower-case `backside.xar` are gone; the three Xara Designer Pro X19 drawings that remain - `Midget.xar`, `Apple5.xar` and `Backside.xar` - are what the demo shows. The XAR page now has a tile per drawing (it showed two) and a second panel describing what each one exercises: 524 paths over multi-stage linear and elliptical fills plus 7 text stories (Midget), 691 filled-and-stroked paths in 3 groups (Apple5), and 259 paths, 175 QuickShape polygons, 46 soft shadows and 26 text stories (Backside). Counts are `Tests/XARProbeTest`'s, which now defaults to those three files rather than the two that were deleted. - New **AI Artwork** page (`DemoApp/UltraCanvasAIExamples.cpp`, Vector Elements) for `media/vector/AI/`, the one vector folder with no demo. Since Illustrator 9 an `.ai` file *is* a PDF - both samples are `%PDF-1.5`, one page, with Illustrator's `AIPrivateData` stream attached - so the page reads them with the MuPDF-backed `UltraCanvasPDFView`: a sample dropdown, page navigation, zoom / fit-page / fit-width, a live zoom read-out and a panel spelling out that reading is the PDF engine's job and writing is the Vector plugin's export-only `AIConverter`. `UltraCanvasPDFView::LoadFromPath()` names the format (`"application/pdf"`) rather than guessing it from the extension, and MuPDF's own detection reads the `%PDF-` header, so neither load path needs an `.ai` special case. Documented in `Docs/UltraCanvas/UltraCanvasAIExamples.md`. - The stray `info.txt` placeholders under `media/` (seven of them, six empty) are deleted; no code or doc referenced them. #### 2026-09-13 *0.8.44* - **A toolbar is as thick as the items in it.** The height a horizontal `UltraCanvasToolbar` is constructed with - the width of a vertical one - is now a floor rather than a fixed size, so it grows to fit its buttons instead of cutting them off. Every host had to guess that number (38 in UltraPaint, 40 in the media viewer, 30 for a row of layer buttons) against metrics only the toolbar knows: a 32 px button inside 5 px of padding and a 1 px border needs 44 px, and anything less silently clipped the bottom of every icon. A host that asks for a *taller* bar still gets exactly that. `Tests/ToolbarThicknessTest` (CTest) pins both halves. - **A flex container now honours its own min/max size.** `boxConstraints` on a flex container were read for its *items* and ignored for its own box, so `minHeight` on anything laid out with `display:flex` did nothing at all - which is why the floor above needed the engine fixed first. It is clamped against the border box, on both axes, like the block path has always done (CSS Sizing §4). An item's constraints are unchanged: still main-axis only. Doc: [CSSLayout](../CSSLayout.md). #### 2026-09-13 *0.8.43* - **A 3D model can become a bitmap, from a view the user chose.** `UltraCanvasModelRaster.h` grew the whole path from a model file to an editable layer, the 3D counterpart of `UltraCanvasVectorRaster`: `IsModelGraphicsPath` / `GetModelRasterExtensions` (a runtime answer - STL from core, the rest once `RegisterModelFormatsPlugin()` has installed the preview provider), `InspectModelFile` (triangles, vertices, bounds, without rendering) and `RasterizeModelFile` / `RasterizeMesh` → `UCRasterLayer`, with size, background, model colour and a pixel cap in `ModelRasterOptions`. Doc: [UltraCanvasModelRaster](UltraCanvasModelRaster.md). - A drawing is missing only a size; a model is missing a **view** as well, so `ModelViewPose` (yaw, pitch, camera distance in model radii) moved into `UltraCanvas3DTypes.h` where both the viewer and the rasterizer can use it, and `RenderMeshPixmap(mesh, w, h, pose, colour)` sits next to the fixed-pose `RenderMeshPreviewPixmap` the Filer thumbnails with. The camera matches the GL viewer's to the letter - unit-radius normalise, yaw then pitch, eye at `(0, 0, distance)`, 45° field of view - which is what makes the saved bitmap the view that was on screen rather than an approximation of it. - **`UltraCanvasSTLElement` orbits without GL too.** The software variant kept a fixed three-quarter still; it now drags to turn and wheels to dolly like the GL one, drawing through `RenderMeshPixmap` with its own pose. Both variants expose `GetViewPose` / `SetViewPose`, and the colour is settable. - **The media viewer hands its 3D view out.** `GetModelViewPose`, `SetModelViewPose` and `GetModelMesh` (all false / null unless a model is shown) let a host embed the viewer as a view *picker* and then render what the user framed without parsing the model a second time. - **New `UltraCanvasModelViewDialog`** (`ShowModelViewDialog()`): "turn this 3D model into a bitmap", asking which view. The 3D pane is the media viewer with its top bars off, so orbiting, zooming and every model format the build reads come for free; the dialog adds the raster size (linked spinners), the background, the triangle count and *Reset view*. The accept buttons are the caller's (`ModelViewAction`), and `Rasterize()` renders from the mesh the viewer already holds. Doc: [UltraCanvasModelViewDialog](UltraCanvasModelViewDialog.md). - `Tests/ModelRasterTest` gained the file half: a binary STL written by the test, the geometry `InspectModelFile` reports, an exactly delivered size, the pose deciding the picture (a quarter turn narrows a box twice as wide as it is deep, more distance shrinks it), the background composited under the model, the caller's colour, and absurd or unreadable requests refused with a reason. `Tests/DesktopEntryTest` now also reads the launcher this repository ships, so a typo in it fails a test rather than a desktop silently refusing a drop. #### 2026-09-13 *0.8.42* - **Dialog captions line up, and survive translation.** New `UltraCanvasFormLayout.h` (`CreateFormGrid` / `AddFormRow` / `AddFormWideRow` / `CreateFormCellRow`): the "caption: control" form as one grid of `[auto, 1fr]` instead of a flex row per field. The caption column is exactly as wide as the widest caption in the whole form, in any language, and every control starts where that column ends. Doc: [UltraCanvasFormLayout](UltraCanvasFormLayout.md). - The old shape is what a hard-coded `CreateLabel(id, 0, 0, 80, 24)` buys: a caption that fits "Compress:" and cuts off "Komprimierung:", and one column width per section so the fields of one section line up with nothing else. The image export dialog had both. - **Grid: a full-width row no longer drags the `auto` column out with it.** CSS Grid §12.5 - an item whose span crosses a flexible track contributes nothing to the base size of the intrinsic tracks it also spans - was missing from `GridLayout.cpp`, so one wide spanning row (a note, a checkbox, a heading) made every `auto` column in the grid as wide as that row and pushed the controls across the dialog. A grid with no `fr` track still distributes a spanning item over its intrinsic tracks, as before. `CSSLayoutFormGridTest` (CTest) pins both, plus the column sharing and the hidden-row behaviour. - **The save-image dialog was rebuilt on it.** Every captioned row - the common ones and each format's own - is now a row of one grid, so the controls line up from Name down to Metadata; the format options are shown a set at a time in that same grid (hidden rows are `display:none` and cost no space). Sections carry a heading and a rule, the chosen format explains itself in a line under the picker, Save reads as the primary button, buttons size to their own text with a floor, and the dialog resizes. - The dialog's width/height inputs kept their re-entry guard in a local captured **by reference** from a function that had long returned; every keystroke tested a dangling bool. It is a member now. - **Metadata can be read and shown.** `PixelFX::Header::HasMetadata` / `ReadMetadata` / `MetadataToText` turn what a file carries (EXIF, IPTC, XMP, ICC, the image's own geometry) into `{group, key, value}` entries or into one string - Markdown (a heading and a table per group) or plain text. Tag names lose their `exif-ifd0-` prefix, values lose the encoding libvips appends (keeping its reading of a numeric tag: `65535 (Uncalibrated)`), binary blocks are reported by size, and Markdown specials are escaped so `VIPS_CODING_NONE` is not italicised with its underscores eaten. `Header::GetFields` - declared since 1.1.0 but commented out of the implementation - is implemented. - New `UltraCanvasMetadataDialog` (`ShowMetadataDialog()`): a read-only popup around an `UltraCanvasTextArea`, Markdown or plain, with Copy. It takes text rather than fields, so a document, font or audio reader can use the same popup. The save dialog offers a **Show...** button and an entry count only when the image actually carries metadata. Doc: [UltraCanvasMetadataDialog](UltraCanvasMetadataDialog.md). #### 2026-09-12 *0.8.41* - **Vector artwork can become pixels.** New `UltraCanvasVectorRaster.h` (`IsVectorGraphicsPath` / `GetVectorRasterExtensions` / `InspectVectorFile` / `RasterizeVectorFile`): it reports what a drawing asks to be drawn at and how many pages it has, then renders it into an editable `UCRasterLayer` at whatever pixel size the caller wants. Doc: [UltraCanvasVectorRaster](UltraCanvasVectorRaster.md). - The size is rendered, not scaled up: SVG goes through librsvg at the scale that lands on the requested pixels, a PDF page through `pdfload` at the matching dpi. One dimension alone keeps the aspect ratio, neither gives the natural size, and a size past `maxPixels` (256 Mpx) is an error rather than an allocation. - Two rasterizers behind the one call. The libvips pipeline covers SVG/SVGZ, PDF/AI and (where the build has a PostScript delegate) EPS/PS; everything else goes to whichever registered `IGraphicsPlugin` claims the extension as a vector format - its element is rendered into an offscreen render context and read back - so the Vector plugin's DXF, DWG, EMF, WMF and XAR rasterize as soon as an application registers it. Both halves answer at runtime, and `GetVectorRasterExtensions()` is what a file filter should list. - The background is composited *under* the drawing, so an SVG with no backdrop keeps its alpha by default and an opaque background flattens it without touching what is drawn over it. - Tested by `VectorRasterTest` (CTest), which skips itself on a build with no SVG rasterizer rather than failing. #### 2026-09-13 *0.8.41* - **3DS, COLLADA and X3D/VRML now write as well as read.** Six of the plugin's formats are writable where three were: OBJ, PLY and STEP are joined by `.3ds`, `.dae`, and X3D in **both** of its text encodings - `.x3d` emits XML and `.x3dv`/`.wrl`/`.vrml` emit Classic VRML, chosen from the extension, because they are two spellings of one node set rather than two formats. - **Each writer reports what the format cannot carry rather than truncating in silence.** 3DS caps vertices and faces at 65 535 because the counts are `uint16`, so a primitive over the limit is skipped with a warning instead of wrapping into garbage; names truncate to 12 characters and are de-duplicated so two long names do not collapse into one material reference. - **Up axis is the one thing a writer changes about the numbers, and it says so.** 3DS is always Z-up and X3D always Y-up, with no field in either to declare otherwise, so a document in the other convention is rotated and warned about. COLLADA declares the document's own axis in `` and rotates nothing. Reading a file and writing it back in its own format is therefore identity. - **The X3D up-axis correction is an enclosing `Transform`, not a field on each root.** Writing it per-root looked right until a root with a rotation of its own met it: `Transform` has exactly one rotation field, so the correction replaced the node's and moved the geometry. The round-trip test's bounding-box comparison caught it. - **COLLADA keeps what `profile_COMMON` cannot.** The metallic/roughness pair has nowhere to go in a fixed-function profile, so it is written into ``; this converter reads it back and another will ignore it, which beats dropping it on the floor. - **`SupportedSaveExtensions()` gained `p21`**, which routes to the STEP converter and was writable all along without being advertised. - **`Tests/ModelWriterTest.cpp`** (new) round-trips each writer - write, read back with this framework's own reader, compare - over a hand-built document and ten real samples. Counts alone would pass a writer that emitted every triangle at the origin, so the geometry is compared through its bounding box as well, with the format's own up-axis conversion applied so the deliberate rotation passes and a wrong one fails. It also asserts that `SupportedSaveExtensions()` and the converters' `CanExport()` agree **in both directions**, which is the drift that would otherwise advertise a format nothing writes. - **`media/models` is now `media/3D`, and `media/vector/STL` moved into it.** STL is a 3D format that was sitting in the vector corpus; every sample the 3D readers use is now under one directory. All 65 references across the tests, the demo pages, the build and the docs were updated - the STL demo page reads `media/3D/STL` - and the changelog's historical entries were deliberately left naming the old paths, because they describe where the files were at the time. #### 2026-09-13 *0.8.40* - **The demo app's 3D section now shows the formats the framework reads.** It had two entries - STL, and an OpenGL tab whose meshes came from the demo's own Wavefront OBJ parser - so 3DS, COLLADA, FBX, Alembic, DirectX .x, MilkShape and STEP were reachable through FileLoader and the Filer but appeared nowhere a visitor would look for 3D support. - **New page, 3D Graphics > 3D Model Formats** (`Apps/DemoApp/UltraCanvasModelFormatsExamples.cpp`). Each sample is read by `LoadModelDocument` into a `ModelStorage::ModelDocument` and the page reports what that document turned out to hold - scenes, nodes, meshes, materials, images, animations, cameras, lights, B-rep solids, declared unit, up-axis and handedness - beside the converter's own `FormatCapabilities`. The point is where the formats *differ*: the same aircraft arrives in centimetres from FBX, in metres from COLLADA and unitless from MilkShape, with node counts from 1 to 7, and all three are correct. - **STEP is on the page precisely because it has no triangles.** A .step holds trimmed NURBS and analytic surfaces; the mesh shown is tessellated on import, and the panel says so with a non-zero B-rep solid count beside it. - **Import warnings are shown rather than logged.** Every fallback and dropped feature a converter reports through `WarningCallback` reaches the status bar, so the 3DS sample's truncated 12-character texture names and the FBX sample's layered diffuse textures are visible instead of silently absorbed. - **Samples are the small ones on purpose** - every file is under 600 kB, from a 4 kB STEP sheet to a 548 kB DirectX .x - and the page names the large ones it skips (the 18 MB .blend, the 6.9 MB VRML, the 3.9 MB PLY) rather than leaving them looking unsupported. Parsing is on demand and cached, so opening the page costs one file. - **The OpenGL "3D Models" tab loads through the framework now.** It asked its own `LoadOBJ` and so was OBJ-only; it now asks `LoadModelPreviewMesh` - the same seam the Filer and the media viewer use - and gained a STEP pin plus the aircraft in MilkShape, COLLADA and 3D Studio. Entries are added only where `CanPreviewModelExtension` says this build can read them, so a dropdown entry never promises a format and then shows the fallback sphere. `LoadOBJ` stays underneath for a build with `ULTRACANVAS_PLUGIN_MODELS=OFF`. - **`Docs/UltraCanvas/UltraCanvasModelFormats.md`** (new) documents the dispatch, the capability table as the converters actually report it - three formats write, OBJ, PLY and STEP, and the rest are read-only - and the three things that surprise people: a STEP file contains no triangles, readers never rescale geometry, and writing a *mesh* to STEP yields a faceted b-rep (one planar face per triangle, at the mesh's accuracy rather than a model's) while a document of exact bodies is written as those bodies. Documentation only - no converter is touched by this change. - Both new demo sources are guarded by `ULTRACANVAS_HAS_MODELS_PLUGIN` and compiled only when the plugin is built; neither is inside the GL guard, since `UltraCanvasSTLElement` draws a shaded software still without OpenGL. #### 2026-09-13 *0.8.39* - **`UltraCanvasFilerWidget` folder icons show the first pictures inside the folder**, peeking out of the folder shape the way Explorer's do: up to two cards stand in the open folder, their upper part above the front flap, each the ordinary thumbnail of one of the folder's first pictures by name. On by default; *Display > Folder previews* in the context menu turns it off, and `SetFolderPreviewsEnabled` / `AreFolderPreviewsEnabled` are the API (the switch fires `onDisplayFormatsChanged` so a host can persist it). Only the tile-sized icons carry them - the four thumbnail grids, and any icon box of 32 px or more; the icon column of the Details and List rows keeps the plain shape. A folder the host gave an icon through `folderIconProvider` keeps it, a bundle keeps its own, and an archive interior is never listed for it. - Which files a folder shows takes a directory listing, which the paint path may not make, so the folder is queued for the background workers that decode the thumbnails: one listing per folder on screen, no file opened and no metadata call (the kind comes from the name, file-or-folder from the listing itself), keeping the first eight previewable files - bitmaps, vector graphics, videos, PDFs, 3D models, fonts - and giving up after 4096 entries. Viewport-driven like the decodes: only the folders the frame draws (plus the prefetch band) are listed, a pending listing that scrolls out of range is dropped, the finished ones are dropped with the thumbnail cache on a rescan and capped at 4096. - The pictures go through the same thumbnail cache and budget as the tiles, requested at the card size, so the Display > Thumbnails switches govern them exactly as they govern the file's own tile, and a picture inside a folder is decoded once per size however many folders and views show it. Until the listing lands the folder is the plain shape; a folder with nothing to show stays that way, and a card whose decode is still on its way is a blank sheet, so a folder never pops from open back to closed. - `Docs/UltraCanvas/UltraCanvasFilerWidget.md`: new *Folder previews* section. `Tests/FilerFolderPreviewTest.cpp` pins the card geometry. - **A cut and paste now tells the host about both ends of the move.** `onFolderModified` named the folder the entries landed in and nothing else, so the folder they were taken *out* of was never reported - and a host that keeps a folder tree (UltraFiler) had no way to learn that a folder had left it. Cutting a folder with `Ctrl+X` and pasting it elsewhere left its row, and its whole subtree, sitting under the folder it had moved away from until the application was restarted. - The paste machinery now remembers the parent folder of every source a move really renamed away (`PendingPaste::vacatedFolders`) and reports each of them once, after the destination has been dealt with. A cut pasted back into the folder it came from is still a no-op and reports nothing, and a copy reports only the destination, as before. - A drop onto a subfolder no longer reports the folder the files came from by hand - the same mechanism names it. It names it by path rather than as "the folder I show", so it is now reported in a file-list display too, where an unnamed folder is dropped. #### 2026-09-12 *0.8.38* - **LaTeX: the module is actually shipped in the Windows and macOS packages, and is found there.** The demo's "LaTeX Documents" page in the Windows package (`UCDemo-Windows-*`) reported "LaTeX module (UltraCanvasLaTeX.dll) not found ... LoadLibrary failed (code 126)" for every candidate: the module is a dlopen()ed CMake MODULE, which CMake emits to `build/lib` rather than the build root, so `package-win.sh`'s `cp ./build/*.dll` never picked it up - and a MinGW build named it `libUltraCanvasLaTeX.dll`, a name the loader never asked for. `package-macos.sh` did not bundle it either. So none of the LaTeX work was visible in a packaged demo. - `UltraCanvas/CMakeLists.txt`: the module gets `PREFIX ""` on Windows, matching the loader's `UltraCanvasLaTeX.dll` (as the chart element modules already do), and `SUFFIX ".dylib"` on macOS - CMake's default for a MODULE there is `.so`, so the file had never carried the name the loader and the bundle script look for. - `package-win.sh` ships the module as `lib/UltraCanvasLaTeX.dll`, runs it through the PE header check, and warns loudly when the build has none; the transitive DLL pass already walks subdirectories. - `package-macos.sh` bundles it as `Contents/PlugIns/libUltraCanvasLaTeX.dylib`, collects and rewrites its Homebrew dependencies like the executable's, and signs it with the frameworks. - `core/UltraCanvasLaTeXModuleLoader.cpp`: probes both DLL names on Windows, both `.dylib` and `.so` names plus `/../PlugIns/` on macOS; opens absolute paths with `LOAD_WITH_ALTERED_SEARCH_PATH` so a module in `lib/` resolves the core DLL beside the executable; spells out codes 126 and 193 in the error. - Demo: the LaTeX Documents menu entry no longer credits MicroTeX, and the page no longer has a reference-image path. A pre-rendered `.png` / `.gif` beside a `.tex` demonstrated nothing of the framework; every file now goes through a live path - the LaTeX view for a formula-only document, the document reader for everything else, which reports what it cannot typeset (a TikZ picture, an unknown package) as a diagnostic in the header while the rest of the document still renders. `media/LaTex` holds `.tex` sources only. - `Docs/UltraCanvas/UltraCanvasLaTeXView.md`: search order updated. #### 2026-09-12 *0.8.37* - **A double-click that opens nothing now says so on macOS too.** The default open spawned `/usr/bin/open` detached - and a detached spawn never sees the exit code of what it started, so every launch was reported as successful. A file type Launch Services has no application for came back "launched", nothing appeared, and neither the file display nor the user was told anything: the same silent double-click Windows had, from the opposite cause (there the launch really failed and the reason was thrown away; here the failure was never visible in the first place). - `FileAssociations::OpenWithDefaultApplication` now asks Launch Services which application Finder would use (`URLForApplicationToOpenURL:`) before launching anything, and hands the files to it with `openURLs:withApplicationAtURL:` - the call the "Open with >" launches already used. A file with no application is named in the error; the rest of the selection still opens, grouped by application, so a mixed selection produces one window per application rather than one per file. The `open` tool stays as the fallback for a system older than the API. - Paths handed to the macOS backend are made absolute first, so a relative name cannot be resolved against this process's working directory instead of the folder on screen - and cannot begin with a "-" that the `open` tool would read as an option. #### 2026-09-12 *0.8.36* - **A file just pasted into the folder gets its thumbnail.** The first decode of a file that has only this moment been written routinely cannot read it - on Windows the copy's own handle, the search indexer and the virus scanner each hold a new file for a moment - and the widget took that for the answer: "this file has no preview", slot retired, tile left with its type glyph for the life of the listing. Only a rescan brought the picture in, which is why the fix for it was to leave the folder and come back. - A decode that produces nothing is now asked WHY. The file was read and holds no preview (a document saved without one, a format no decoder here handles): unchanged, the slot is retired at once and the worker logs it. The file could not be read: up to four tries, 300 ms apart and growing, before giving up - and a file written within the last ten seconds gets the same benefit of the doubt even where it reads fine by the time the question is asked, since the holder may have let go in the microseconds in between. The retry waits on the decode worker (`wait_until`), so nothing spins and no repaint is needed to drive it. - The **text-content previews** (Text, Docs, Spreadsheets) follow the same rule, from the same cause: their reader already reports whether it could read the file, and an unreadable file is now retried rather than recorded as having no text. #### 2026-09-12 *0.8.35* - **The padlock badge on a held file is smaller, sits on the left, and says who is holding the file.** It was drawn at 38 % of the icon's edge in the bottom-right corner with no size cap, so on a thumbnail tile it read as a second icon rather than a mark on the first, and it covered the part of a picture that is usually its subject. It is now a quarter of the edge, capped at 22 px, in the bottom-left corner - the corner overlay badges live in - and on a shortcut, whose arrow badge already has that corner, it stacks directly above the arrow so both marks stay on the same side. - **Hovering the badge opens a tooltip.** It starts with what the listing already knows (*"In use by another program (cannot be replaced)"*) and fills in the program's name as soon as it can: resting on a badge starts a holder probe for that one file - the expensive half of the question, which is why a listing never asks it - and the tooltip is put up again with the answer without the cursor having to move. The badge wins over the file-name tooltip underneath it, and answers whether or not name tooltips are on. - The icon box a badge is placed against is now derived once (`EntryIconRect`) for drawing and for hit-testing alike, so a badge's tooltip lands exactly where the badge is drawn - in every view, and on a folder glyph shrunk inside its image box. #### 2026-09-12 *0.8.34* - **Opening a file with its registered program works on Windows where it quietly did not.** `FileAssociations::OpenWithDefaultApplication` went straight to `ShellExecuteEx` with whatever path spelling the caller carried and no COM apartment, which is two ways for a perfectly registered file type to come back "no application is associated": the shell resolves neither a relative name nor a `C:/like/this` spelling - both of which `std::filesystem` hands through, since a listing keeps the separators the folder was opened with - and a verb handler is a COM object, so the file types whose association is more than a command line failed on a thread without an apartment. The path is now made absolute and native for every shell call (`SHParseDisplayName`, which backs "Open with > *program*", is just as strict), and COM is initialized around the launch. - **A file type with nothing registered puts up Windows' own "How do you want to open this file?" chooser**, the way a double-click in Explorer does, instead of failing silently; what the user picks there opens the file and is remembered by the shell, so the next open needs no chooser. A chooser closed without a choice is an answer, not an error. A launch that does fail now carries the shell's reason - file not found, access denied, held by another program, the registered program did not answer - rather than "could not open". - **`FileAssociations::HasDefaultApplication(path)`**: does the OS name a program that would open this file? The candidate list answered a different question and was being read for this one - it also carries the applications that merely offer to open the type, and on Windows falls back to the unfiltered handler list, i.e. every application on the machine that ever registered itself, for exactly the types nothing is registered for. - **The Windows backend no longer mistakes `OpenWith.exe` for a registered program** - the shell names that chooser stub precisely when nothing is registered - and it now adds the program the registry *does* name to the candidate list when the handler enumeration misses it, which a ProgID registered without `OpenWithProgids` (plenty of older installers) always did. That program is the one flagged as the default, so "Open with >" leads with it. - **`UltraCanvasFilerWidget` only opens an entry on a LEFT double-click.** Windows reports a double-click for the right and middle buttons too, so a second right-click on a file - aimed at the context menu - opened it. #### 2026-09-12 *0.8.33* - **A colour can now be turned into transparency:** `PixelFX::Colour::ColourToAlpha(image, key, tolerance, softness, amount, despill)`. Until now the framework could *select* by colour (the magic wand and the tolerant flood fill) and could erase a selection, but nothing keyed a colour out: the only route was a hard 0/255 wand mask plus a delete, which leaves jagged edges on anti-aliased artwork and a fringe of the removed colour around whatever is left. - Every pixel is measured against the key with **the same colour distance the wand and the fill already use** - the mean absolute per-channel difference, 0..255 - so a tolerance of 32 means the same thing in all three. Pixels within `tolerance` are keyed in full, pixels past `tolerance + softness` are untouched, and the band between the two is smoothstepped, which is what keeps an anti-aliased edge smooth rather than stepped. - **`amount` is a fraction, not a switch:** 1.0 removes the colour outright, 0.4 takes 40% of its opacity away and leaves the rest, so the same call fades a colour back as well as knocking it out. Alpha is always scaled and never raised - already-transparent pixels stay transparent - and an image with no alpha band gains one. - `despill` un-mixes the key colour out of the pixels left only partly transparent (`out = key + (in - key) / alphaKept`), so a logo keyed off a white page carries no white halo into whatever it is composited over. - The ramp is a 256-entry lookup applied with `Colour::MapLut` over the distance image rather than per-pixel float work, so the cost is one pass whatever the thresholds. - **`UltraCanvasSlider` now fires `onValueChanged` when a drag ends**, which its documentation always said it did. `SetValue()` routes to `onValueChanging` while `isDragging` is set and to `onValueChanged` otherwise, and `HandleMouseUp()` cleared the flag without a final call - so on a slider with both handlers a drag produced a stream of "changing" values and no "changed" one, ever. Anything that acts on the committed value because acting per pointer-move is too expensive simply never ran: every parameter dialog in UltraPaint (and Curves, and the tool options) took the new number into its label and left the canvas showing the old result. The release now reports the value the drag settled on, once, and only when the drag actually moved it. Range mode is unchanged - its callbacks always fired from `SetLowerValue` / `SetUpperValue` either way. #### 2026-09-11 *0.8.32* - **A build without GL now shows the model instead of a sentence about GL.** `UltraCanvasSTLElement`'s non-GL fallback drew a dark rectangle, the triangle count, and the line *"(build with -DULTRACANVAS_ENABLE_GL=ON for 3D preview)"* - while the Filer, in the same process, had been drawing a shaded three-quarter view of the same mesh all along. The picture was always available; it was sitting in another file, private to the widget that wrote it. - **`include/UltraCanvasModelRaster.h` is that rasterizer, extracted.** Nothing about the maths moved with it - the same pose, the same framing, the same flat two-sided shading off triangle geometry rather than stored normals, the same transparent background. `UltraCanvasFilerWidget` now loads the mesh and calls it; the element caches one still per size, because nothing in a non-interactive view moves and re-rendering per frame would produce the same pixels. - **The extraction found a bug that had been invisible.** A mesh whose every vertex sits at one point produced a *fully transparent* tile rather than being refused, because `BoundingBox3D::Radius()` substitutes 1.0 for a degenerate box - the right answer for framing, the wrong one for deciding whether there is anything to draw. The Filer then recorded that empty tile as a successful preview, so the file showed nothing at all instead of falling back to its type glyph. The extent is now measured directly and such a mesh is refused. - **The fallback also says which nothing it is.** "No mesh loaded", "too large to preview" and "no usable bounds" are three different answers, and a blank rectangle told the reader none of them. - **`Tests/ModelRasterTest.cpp`** (new) is the first test this code has ever had - it was previously reachable only through a thumbnail worker, so it could only be checked by looking at a folder. It pins the refusals (empty, degenerate, over the cap), the output geometry (exact size, HiDPI scale, a scale below 1 clamped rather than shrinking the tile), that the model covers a reasonable share of the tile without touching its edges, that a cube shows at least three distinct face shades so the light is really applied, that the pose lights the top more than the bottom, that two renders of one mesh are pixel-identical - the caches depend on it - and that a triangle is drawn whichever way it is wound, since most STL in the wild is inconsistent. Clean under ASan/UBSan. - The GL path is untouched. Where GL is enabled the real viewer still runs, as it should the moment anyone drags to rotate. #### 2026-09-11 *0.8.31* - **macOS signing and notarization now run only on a push to `main`.** A pull request packages the same bundle unsigned, which is exactly what the Windows row of the build matrix has always done with `--no-sign`. - **Why: they are network calls to Apple, and they were failing.** `codesign` contacts Apple's timestamp service and `notarytool submit --wait` uploads the bundle and then polls appstoreconnect until Apple answers. Both ran on every PR build. On 2026-09-11 they failed **four times in one afternoon across four different pull requests** - twice on the timestamp service, twice on the notary (`NSURLErrorDomain -1009`, "The Internet connection appears to be offline") - every time with the build and the whole test suite already green. A red check that says nothing about the diff trains reviewers to ignore red checks, and each one cost a re-run of a 40-minute macOS job. - **Nothing about the packaging itself is skipped.** The app bundles are still assembled, the Homebrew dylibs still collected and relinked, the artifact still uploaded, so a pull request still proves `package-macos.sh` runs end to end. Only the two steps that ask Apple a question are gone - along with decoding the signing certificate onto a runner that no longer needs it. The macOS artifact from a PR run is therefore unsigned, and Gatekeeper will say so; the signed, notarized, stapled bundles still come from `main`, which is where the release artifacts have always come from. - The condition is a single job-level `RELEASE_BUILD` flag rather than the same expression repeated on three steps, and a `workflow_dispatch` validation run counts as a check rather than a release - so manually validating a branch no longer submits anything to Apple either. #### 2026-09-11 *0.8.28* - **MilkShape 3D (.ms3d) reads.** `Plugins/Models/MS3D/` is the one reader here with no container layer to split off, and deliberately so: an .ms3d is a fixed sequence of packed little-endian structs with no chunks, tags or offsets. A count, then that many records, to the end of the file. There is no generic grammar underneath it to isolate, so isolating one would be ceremony. - **It is a game format, and the shape of it says so.** Positions are indexed and shared while normals and texture coordinates are stored per *triangle corner*, so corners whose streams disagree become distinct document vertices - the same resolution the OBJ, COLLADA, X3D, FBX and Alembic readers perform. A group is a named triangle list with one material, which is exactly a mesh with one primitive, so groups become meshes. - **A smoothing group is a number, and `MeshPrimitive` holds a bitmask.** MilkShape numbers them 1..32 with 0 meaning none, so the number is the *bit*, not the value - group 3 is bit 2. Getting that off by one creases a model everywhere, and the test pins all three cases. - **Skinning is stored twice over**, and the two records disagree by design. Every vertex carries a single `boneId`; an optional later block adds three more bones with percentage weights. The block wins where it is present, and the vertex's own bone takes whatever share the other three leave - a file that has the block wrote the single id only for readers that predate it. - **Joints name their parents by string**, so the hierarchy is resolved by name and a parent declared after its child still works; a parent cycle is broken and reported rather than recursed. Rotation keys are absolute orientations composed with the rest pose and translation keys are relative to the rest position, which is how MilkShape's own viewer reads them. - **One thing the specification does not settle**, and this reader says so rather than implying an answer: MilkShape's `ms3dspec.h` comments a keyframe's `time` as seconds, while its interface works in frames and the file separately stores `fAnimationFPS` and `iTotalFrames`. The field is taken as seconds, as the header says, and the frame rate and frame count go into metadata so a caller that disagrees can convert. The test pins that choice. - **Everything past the joints is optional and versioned** - comments, vertex weights, joint colours, model settings - each with its own version number. A file may stop at any block boundary, and a version this reader does not know stops it there rather than guessing at the layout beyond, keeping everything already read. A declared count is never trusted over the bytes present: the counts are 16-bit, so a claimed 60000 vertices in a ten-byte file is refused before anything is reserved for it. - **`Tests/ModelMS3DTest.cpp`** (87 assertions) **builds .ms3d files by hand**, because the sample carries no joints, no skinning, no smoothing groups and no textures - and a fixed struct sequence is about as easy to write as to read. Clean under ASan/UBSan, including every truncation of a complete file. - **The sample is a canopy, not an aircraft**, and that is the finding worth keeping. Every other export of the E-45 in this repository carries both of its meshes; the .ms3d carries **one** - 1488 triangles, exactly twice the Alembic canopy's 744 faces, with material `Material.004` and the hull absent entirely. Its axes are permuted relative to the other exports, which the spans prove rather than assume: 1.1182, 3.0373 and 1.4645 are the Alembic canopy's X, Z and Y to four decimal places, and the symmetric axis stays symmetric. A later change that started "finding" a second mesh would be inventing it, so the count of one is asserted. - **Every 3D extension now reaches the framework, not just the dispatch.** The Models plugin claimed .step, .stp, .p21, .abc, .x, .ms3d and .blend, and `LoadModelDocument` read all of them - but `GraphicsFormatDetector`'s extension table listed none, so a `GraphicsFileInfo` for any of those files had `formatType == Unknown`, `IsValid()` was false, and `UltraCanvasGraphicsPluginRegistry::CanHandle()` refused a file the very next call would have loaded. The table now carries the 3D extensions the framework reads (and the ones its open readers will add), and `CanHandle` asks whether a plugin claimed the extension *before* consulting the table, because a registered plugin knows its own formats and the table is only the fallback for what nothing registered for. `ModelFormatsPluginTest` asserts the agreement from both sides, so a format added to the dispatch without the table cannot pass again. - **Proposal 2.6 no longer contradicts its own conclusion.** The section that argues .blend into being read was still titled "the second deliberate exclusion", and two comments in the Models plugin still said loading a .blend "deliberately yields nothing". All three now say what the code does. #### 2026-09-11 *0.8.26* - **The Filer and the media viewer show every 3D format this build reads, not just STL.** Both live in core; every format but STL lives in the Models plugin, which links *against* core - so core could not call it, and both viewers simply asked `ext == "stl"`. That answer had been wrong for nine formats and counting: `UltraCanvasMediaViewer::IsModelFile` still named one extension after OBJ, PLY, 3DS, COLLADA, DirectX .x, Alembic, .blend, STEP, FBX and MilkShape had readers, and the Filer's thumbnail worker refused everything else with the comment "the other 3D formats have no loader that works without a GL context". - **`include/UltraCanvasModelPreview.h` is the inversion**, and it inverts the question rather than the dependency. Core declares what it wants - is this extension one you read, and turn this path into a `Mesh3D` - and `RegisterModelFormatsPlugin()` installs an implementation on its way in. No provider installed means the answers fall back to STL, which is exactly what a build with `ULTRACANVAS_PLUGIN_MODELS=OFF` gets and what every caller got before. The provider hands back a flat mesh rather than a `ModelDocument`, because core has no idea that type exists and a thumbnail wants one triangle buffer rather than a scene. - **The GL context was never the obstacle.** `RenderModelPreviewPixmap` has always been a software rasterizer - it rotates, projects and shades the triangles itself - so widening it needed no renderer work at all, only a way to get the triangles. The existing triangle cap still applies afterwards, which is what keeps a subdivided FBX from stalling a preview worker. - **A STEP file previews now too.** It carries exact bodies and no triangles until something asks, and the provider asks: `TessellateOnImport` is on for previews specifically, so a `.step` draws as the solid it describes instead of as a blank tile. - **`.dxf` still does not preview as a model**, matching the dispatch's own deliberate refusal to claim it - a DXF is a drawing far more often than a model, and the Vector reader stays its default. `ModelPreviewSeamTest` asserts that from the preview side, where `ModelFormatsPluginTest` already asserted it from the dispatch side. - **`UltraCanvasSTLElement::LoadFromFile` is no longer STL-only** either, which is what makes the media viewer work without further changes: the element only ever wanted a triangle buffer, so it now takes one from the same seam. - **`Tests/ModelPreviewSeamTest.cpp`** (new) pins the contract rather than any one format: that a build with no provider behaves exactly as before, that a provider widens both questions, that a half-built provider is refused whole rather than called through a null `std::function`, that a provider returning no triangles reports failure rather than an empty preview, and that clearing it narrows core back to STL. Its sample half loads every export of the aircraft through the real provider and asserts each gives the preview a bounding sphere to frame and a normal per vertex to shade - and that the OBJ's 8110 quads arrive as exactly 16220 triangles, so nothing is dropped on the way through the flattening. #### 2026-09-11 *0.8.25* - **VRML97 (.wrl) reads, and with it X3D's Classic VRML encoding (.x3dv).** X3D is one node set with more than one encoding, and VRML97 is that same classic syntax a revision earlier. `Plugins/Models/X3D/` is now split the way the STEP, Alembic, FBX and `.x` readers are: `UltraCanvasX3DScene.h` is the scene as a tree of typed nodes with one reader per encoding under it, and `UltraCanvasX3DConverter.cpp` is the node *set* on top, which no longer knows which syntax it was handed. - **Which is the point.** These are the same three nodes with the same two fields: ``` Transform { translation 0 1 0 children [ Shape { geometry Box { size 2 2 2 } } ] } ``` Reading them twice would have meant two copies of `IndexedFaceSet`'s corner resolution, of the `T · C · R · SR · S · -SR · -C` transform composition, of DEF/USE, and of the ROUTE plumbing - and one copy would eventually have drifted. The test asserts directly against that: the same scene in both encodings must produce the same document, down to bounds agreeing to the last bit. - **One token of lookahead is the whole grammar problem.** A field's value can be a node or a literal, and both start with a bare word - `appearance Appearance { ... }` against `solid TRUE`. A word followed by `{` opens a node, `DEF` and `USE` always do, and everything else is a literal. The same test after `[` says whether brackets hold nodes or numbers, so the parser never needs to know which fields are MFNode - which matters, because knowing would mean teaching it every node type in the standard first. - **Only a quote and a backslash are escapable in a string.** Treating every backslash as an escape turns the `textures\E-45 _col.jpg` an exporter writes into an `ImageTexture` url into `texturesE-45 _col.jpg`. This was wrong in the XML encoding's MFString parser too, and is fixed in both. - **VRML 1.0 is refused by name.** It shares the `.wrl` extension and nothing else - `Separator`, `Coordinate3`, a different `IndexedFaceSet` - so read as VRML97 it would come out empty. Saying "this is VRML 1.0, whose node set is different" beats saying "this is not a model file". `PROTO` and `EXTERNPROTO` declarations are skipped whole and reported, the same answer `` already got. - Recognition is by **content, not extension**: both encodings must open with a line that names them (` files...` makes the same checks on the build side, reading the header bytes directly so the GNU and LLVM objdumps of the two MSYS2 environments do not matter, and `package-win.sh` runs it over every `.exe` and `.dll` in `dist/` before zipping. The x86_64 and arm64 CI packages are built from the same sources with identical file lists, and until now nothing but the PE header told them apart; a package containing a wrong-architecture or truncated binary now fails to build with the file named, rather than shipping and producing that dialog on a user's machine. - `Docs/UltraCanvas/UltraCanvasWindowsDiagnostics.md` gains a section on the dialog: what it is (`ERROR_BAD_EXE_FORMAT` from `CreateProcess`), why it is not a DLL or run-time failure, the three header causes, and why "an older version still works" should be answered by comparing the two files' headers before diffing the sources. #### 2026-09-11 *0.8.16* - **DirectX .x (.x) reads, text and binary.** `Plugins/Models/XFile/` is split the way the STEP and Alembic readers are: `UltraCanvasXFile.h` is the container - the `xof` header, the two tokenisers and a generic tree of typed objects, with no idea what a mesh is - and `UltraCanvasXFileConverter.h` is Direct3D retained mode's object set on top of it. Retained mode is long gone; its file format is still what Blender, 3ds Max, a great many game-asset pipelines and two decades of sample code emit. - **What is read**: the `Frame` hierarchy with a matrix per frame, `Mesh` with its n-gon faces kept as n-gons, `MeshNormals`, `MeshTextureCoords`, `MeshVertexColors`, `MeshMaterialList` with one primitive per material, and `Material` with its `TextureFilename`. `{ Name }` references resolve, so a material or mesh shared between objects is read once. - **The left-handed trap, and why this reader touches nothing.** Direct3D's space is left-handed, so an exporter coming from a right-handed application puts a *reflection* in the root frame (determinant -1) and reverses the face indices to compensate. Both halves are in the file and they cancel. The E-45 sample proves it numerically: its meshes have **negative** signed volume in their own object space and **positive** volume once the frame chain is applied, and their winding disagrees with the file's own `MeshNormals` in object space while agreeing in world space on 93 of 93 and 925 of 937 faces. So neither the matrix nor the winding is altered - a reader that "fixed" what it saw in object space would deliver a model that is inside out. This is the exact opposite of the Alembic reader's decision, for the opposite reason, and the two are worth reading together. - **That is measured rather than assumed.** Where a mesh carries `MeshNormals`, the winding is checked against them *through the node's world transform* - which costs only the sign of its determinant, since that is all a reflection changes about orientation - and a file whose faces really are inside out is reported instead of quietly loaded. - **The binary encoding is read too**, and is tested by building one byte by byte and asserting it produces the same document as the same scene in text. The two MSZIP encodings (`tzip`, `bzip`) are recognised and refused by name, because MSZIP is not plain zlib and a silent empty result would be indistinguishable from a corrupt file. - **A count is never trusted over the bytes present**: a `Mesh` claiming a hundred thousand vertices it does not carry, or a binary `FLOAT_LIST` longer than the file, is refused rather than allocated. Both are covered by tests. - **`Tests/ModelXFileTest.cpp`** (74 assertions) against `media/models/XFile/E-45-Aircraft.x`. Its synthetic half carries the weight, because one text export reaches neither the binary encoding nor any of the cases that make this format treacherous. Its sample half is a cross-format assertion: this is the **same export as the `.dae`** - 1995 triangles once fanned, two meshes, and the mirror modifier not applied - so proposal section 2.6 gains a fourth witness on that side of the split. - `ModelFormat` gained `XFile`; the plugin dispatches `.x` for reading, with no optional dependency, so it is always built. Read only, and geometry only: `AnimationSet`, `XSkinMeshHeader` and `SkinWeights` are reported rather than read, because the quaternion convention of an .x rotation key cannot be verified against a sample that carries none, and a silently wrong animation is worse than a missing one. The capability report says `Animations = false` and `Skinning = false` rather than implying otherwise. #### 2026-09-10 *0.8.15* - **X3D (.x3d) reads.** `Plugins/Models/X3D/UltraCanvasX3DConverter.h` reads the XML encoding of X3D 3.0-4.0 into `ModelDocument` - the second XML scene format after COLLADA, and gated on the same tinyxml2. - **What is read**: the `Transform`/`Group`/`Switch`/`LOD` hierarchy, `IndexedFaceSet` with n-gons kept as n-gons, `IndexedTriangleSet`, `IndexedQuadSet`, `TriangleSet`, `QuadSet`, the fan and strip sets, `IndexedLineSet`, `LineSet`, `PointSet`, the Immersive profile's `Box`, `Sphere`, `Cylinder` and `Cone`, `Appearance` with `Material`, `TwoSidedMaterial`, `ImageTexture` and `TextureTransform`, `DirectionalLight` / `PointLight` / `SpotLight`, `Viewpoint` and `OrthoViewpoint`, the `` metadata and `` statement, and animation assembled out of `TimeSensor`, `PositionInterpolator` / `OrientationInterpolator` and `ROUTE`s. - **DEF/USE is the instancing mechanism, and it applies to every node** - a Coordinate shared between two geometries, an Appearance between two Shapes, a whole Transform subtree reused. A pre-pass collects every DEF; a small RAII guard resolves a USE and unwinds a cycle. One geometry USE'd twice under the same material is one mesh with two nodes; under a different material it has to be two, because the material sits on the primitive. - **A `Transform` is not a TRS triple.** The spec composes it as `T * C * R * SR * S * -SR * -C`, so the matrix is built and decomposed rather than copied field by field: the common case decomposes back to exactly the fields that were written, and a `center` or `scaleOrientation` keeps its meaning instead of being silently discarded. - **`IndexedFaceSet`'s index streams are parallel and independent.** `texCoordIndex`, `normalIndex` and `colorIndex` line up with `coordIndex` by position, -1s included - except when normals or colours are declared per face, where the face counter indexes them instead. A corner is the tuple of whichever streams exist, and unique tuples become document vertices, exactly as the OBJ and COLLADA readers resolve theirs. - **The geometric primitives are tessellated rather than skipped**, because a hand-written X3D is usually nothing else. Caps are single n-gons rather than fans of triangles, sphere pole rings are triangles rather than quads with a doubled corner, and every face is wound so its normal points outward - which the test asserts directly, since a winding mistake is invisible until the model renders inside out. - **What has no field in the document is recorded, not dropped**: `creaseAngle` becomes a node extra (with a warning when it is below pi and the file carries no normals, since generated normals then average across every edge), the `ImageTexture` url fallback list keeps its alternates in metadata, and `Background`, `LineProperties` and a light's `ambientIntensity` are kept as metadata and material extras. - **`Tests/ModelX3DTest.cpp`** (118 assertions) against `media/models/X3D/E-45-Aircraft.x3d` - the same aircraft as the 3DS, OBJ, DXF, COLLADA and Alembic samples. It asserts the cross-format fact rather than hiding it: this export carries **exactly the OBJ's 8110 quads and is symmetric about X**, so its mirror modifier *was* applied - unlike the `.dae`, `.abc` and `.blend`. Proposal section 2.6 gained it: the split is not between evaluated and scene formats, it is between two exports of one scene written by one application on one day. - `ModelFormat::X3D` already existed; the plugin now dispatches `.x3d` for reading, behind `ULTRACANVAS_MODELS_X3D` / `ULTRACANVAS_HAS_X3D_CONVERTER`. Read only, for the reason COLLADA is: a caller wanting to write a scene should write glTF. `.wrl` and `.x3dv` are the classic VRML syntax, which this reader cannot parse, so they are left unclaimed rather than claimed and then refused. - **PLY (.ply) reads and writes.** `Plugins/Models/PLY/` covers all three encodings - ascii, binary_little_endian and binary_big_endian - because a reader that handles only ASCII fails on most scanner output, and one that assumes the host's byte order fails silently rather than loudly. - **PLY is the format with no fixed schema**, and that is why it is worth having: a file declares its own elements and properties, so a per-vertex `quality`, `confidence` or `classification` has nowhere to go in OBJ or 3DS but round-trips here as an `AttributeSemantic::Custom` under its own name. That open-ended attribute list is what `ModelDocument` had them for. - Positions, normals, texture coordinates (`s`/`t`, `u`/`v` and `texture_u`/`texture_v` all recognised), and vertex colours as either bytes or floats - a uchar 255 becomes 1.0 rather than staying 255. Faces come from `vertex_indices` or the older `vertex_index`, and n-gons are kept rather than triangulated. A file with vertices and no faces arrives as a point cloud rather than an empty mesh. - **An element nothing understands is stepped over by exactly its size.** In a binary file a mis-sized skip does not lose one element, it destroys everything after it, so an edge list or a per-face material table is measured precisely even though nothing reads it - and named in a warning rather than passed over in silence. - **`NumericPrecision` chooses the type positions are written as, not the digit count.** Compact writes `property float`, which is what almost every PLY carries; Full writes `property double`, which is the only way a document that came from CAD survives the trip. Written as a type so the flag means something in binary too - it previously had no effect there at all, producing byte-identical files either way, which the round-trip test caught. - **`Tests/ModelPLYTest.cpp`** works against small headers built inline, where PLY's awkward cases live: both spellings of every type name, all three spellings of a texture coordinate, binary in either byte order (asserted on the extents, since the wrong order yields denormals rather than an error), a skipped element, and a truncated file. Then against `media/models/PLY/E-45-Aircraft.ply` - a fourth export of the same aircraft, 32440 quads against the OBJ's 8110, from `E 45 Aircraft_Export_Ready.blend`. It is symmetric about X, so unlike the .dae, .blend and .abc its mirror modifier was applied, and its winding already agrees with the normals it stores (32434 of 32440), so unlike Alembic nothing is reversed. - The dispatch's "no converter for this extension" assertion now names an extension no one will ever implement. It had gone stale twice - once when .abc gained a reader and once when .ply did - because it named a format that was only unsupported *yet*. #### 2026-09-10 *0.8.14* - **Alembic (.abc) reads.** `Plugins/Models/Alembic/` is split the same way the STEP reader is: `UltraCanvasOgawaFile.h` is the Ogawa container and Alembic's object/property model with no idea what a mesh is, and `UltraCanvasAlembicConverter.h` is AbcGeom on top of it. No SDK - Ogawa is a flat file of groups and data blocks addressed by absolute offset, and the header blobs that name them decode in about four hundred lines. - **What is read**: `AbcGeom_Xform` as the node hierarchy (Alembic's row-major matrices transposed, and decomposed to TRS where that is exact), `AbcGeom_PolyMesh` with its n-gons kept rather than triangulated, `AbcGeom_SubD` as its control cage with a warning that the subdivided surface is not in the file, `AbcGeom_FaceSet` as one primitive per set so a material assignment survives, and per-corner normals and indexed UVs. First time sample only; the capability report says `Animations = false` rather than implying otherwise. - **Two conventions that are silent corruption when got wrong.** Alembic winds a face's indices the opposite way from the outward-normal convention, so every face is reversed on import - the sample disagrees with its own stored normals on 1680 of 1681 faces if it is not, and its signed volume comes out negative. And `N` and `uv` are face-varying, one value per corner rather than per vertex, so corners are de-indexed into distinct document vertices exactly as the OBJ reader does for its three index streams. - **An HDF5-backed archive is told apart from a non-Alembic file** and reported as such, because "not Alembic" would be a lie and a re-export fixes it. - **`Tests/ModelAlembicTest.cpp`** against `media/models/Alembic/E-45-Aircraft.abc` - the same aircraft as the 3DS, OBJ, DXF and COLLADA samples, from the same .blend. It checks the winding against the normals the file itself carries, and asserts the cross-format difference rather than hiding it: the OBJ is symmetric about X and the Alembic is not, because this export - like the .dae - was written without applying the mirror modifier. Proposal section 2.6 gained it as a third witness: even the format whose whole purpose is baked geometry came out of this scene half-mirrored. - `ModelFormat` gained `Alembic`; the plugin dispatches `.abc` for reading. Read only: writing an Alembic a DCC will accept means matching a schema far more strictly than reading it, and a document that needs to leave the framework has OBJ, STEP and the rest. #### 2026-09-10 *0.8.13* - **STEP reads and writes: the first B-rep converter.** `Plugins/Models/STEP/` fills `ModelDocument::Brep` from ISO 10303-21 files - AP203, AP214 and AP242 - and writes them back out. Nothing is tessellated on the way in unless the caller asks (`ConversionOptions::TessellateOnImport`): the exact surfaces are the point, and a mesh made at a tolerance the file never stated is a decision only the caller can make. - **The syntax layer is separate and testable on its own.** `UltraCanvasStepFile.h` parses Part 21 and interprets nothing: instances, parameters, strings with doubled quotes and `\X2\` escapes, comments between any two tokens, `$` and `*`, and - the one that matters - *complex instances*, the parenthesised pile of records that a rational NURBS can only be written as. A reader that handles only simple instances reads no curved freeform geometry at all, from any STEP file, ever. - **What the entity layer reads**: lines, circles, ellipses, parabolas, hyperbolas, polylines, rational and polynomial B-spline curves and trimmed curves; planes, cylinders, cones, spheres, tori, surfaces of extrusion and revolution, and rational and polynomial B-spline surfaces; the pcurves that trim them, through `surface_curve`, `seam_curve` and `intersection_curve` - and where a file carries none, the surface is inverted instead, in closed form for the analytic types and numerically for NURBS; `edge_curve`, `oriented_edge`, `edge_loop`, `poly_loop`, `vertex_loop`, `face_bound`, `face_outer_bound`, `advanced_face`, `closed_shell`, `open_shell`, `manifold_solid_brep`, `brep_with_voids`, `shell_based_surface_model` and `faceted_brep`; `si_unit` and `conversion_based_unit`, so an inch part is recognised as inches rather than arriving a thousand times too large; product names; and the six-deep `styled_item` chain down to `colour_rgb`, per face and per body. - **What it does not, said rather than implied**: assembly placements are not applied, so a multi-part file arrives with every part in its own coordinates - the reader warns when it finds them. PMI, tolerances and construction history are not read. - **Writing produces an `advanced_brep_shape_representation`**, with the presentation chain for colours and the product structure AP203 requires around a shape. A document holding meshes rather than solids is written as a faceted b-rep: one plane per facet, with edges shared between neighbours, which is what STEP has for a mesh and what a CAD system will read back. There is no six-digit mode - the reason to write STEP is that the numbers are exact, and a NURBS weight of cos 45 degrees rounded to six digits is no longer an arc. - **`ModelFormat` gained STEP, IGES, ACIS, Parasolid and OpenNURBS**, and `FormatCapabilities` gained `Brep`, `NurbsSurfaces` and `Assemblies` - so a converter that fills exact bodies rather than triangles can say so, instead of looking like a broken mesh reader. - **`Tests/ModelStepTest.cpp`** with three **hand-authored** samples in `media/models/STEP`, hand-authored because a reader tested only against files its own writer produced proves nothing. `Box.step` states one face's loop backwards with a `.F.` bound orientation and meshes to signed volume exactly 6000 in exactly 12 triangles - a reader that ignores that flag builds the face inside out, and the signed volume is the only measure that notices. `Pin.step` is in inches through a `conversion_based_unit`, has a seam edge used twice by one loop, and carries a body colour with one face overridden; its meshed volume closes on pi*r^2*h from below as the tolerance tightens. `NurbsSheet.step` is a rational patch whose weights make it an exact arc, so every point of it is 5 from the axis to 1e-15, and its boundary carries no pcurves - the meshed area being a quarter cylinder's is what proves the surface was inverted correctly. All three round-trip through the writer to the *same meshed volume to 1e-9*. - The Models plugin now dispatches `.step`, `.stp` and `.p21` for both reading and writing, so `LoadGraphicsFile` and `SaveGraphicsFile` reach them; STEP has no external dependency, so it is always built. #### 2026-09-10 *0.8.12* - **The 3D structure holds B-rep exactly, instead of tessellating it away.** `ModelDocument::Brep` is a `BrepData` (`DataFormats/UltraCanvasBrepStorage.h`): trimmed surfaces - plane, cylinder, cone, sphere, torus, extrusion, revolution, ruled and rational B-spline - with the topology that closes them into solids (solid, shell, face, loop, coedge, edge, vertex), curves in space and in a surface's parameter space, and `ModelNode::Solid` to place a body as `Mesh` places a mesh. That is what STEP, IGES, ACIS/SAT, Parasolid XT, OpenNURBS `.3dm` and DWG's `3DSOLID` / `REGION` / `BODY` / `SURFACE` actually contain. The proposal argued the opposite until now, and `Docs/Research/UltraCanvas3DModelProposal.md` §2.5 records the reversal rather than quietly editing it: a triangle mesh is a *view* of a B-rep taken at a tolerance the file never stated, so a reader that tessellates and discards decides irreversibly, for the user, that the model is approximate from now on - and no warning gives that back. - **Tessellation became an operation the caller asks for.** `ModelDocument::TessellateBreps(options)` approximates every solid at a chord and angular tolerance the caller picks, attaches the mesh to the node that placed the body, and leaves the exact bodies in place - so it can be re-run finer without re-reading the file, run twice for two levels of detail, or never run at all. Per face it samples the trimming loops to tolerance, decimates what the tolerance does not need, ear-clips the region with its holes bridged in, seeds interior points on a grid sized from the surface's own curvature, and red-green refines whatever the grid missed. A 2x4x6 box meshes to exactly 12 triangles with signed volume 48; a cylinder of radius 5 and height 10 to 96 / 384 / 1534 triangles at tolerances of 0.1 / 0.01 / 0.001, every vertex exactly on the surface. - **`BrepData::Validate` is what a reader owes its caller.** Every index in range, every loop actually closed, and - for a shell marked closed - every edge used exactly twice and in opposite directions. A STEP file referencing a surface it never defines is not rare, and without this the symptom is a wrong mesh rather than an error. - **Concave n-gons triangulate correctly.** `MeshPrimitive::Triangulate` ear- clips in the plane Newell's method fits to the face, so an L-shaped or slotted face comes out as its own area rather than its convex hull - the test's L is 5 units, and the fan it replaces gave 7. Convex faces still take the fan, which is the same answer far cheaper, so nothing got slower. The clipper lives in `DataFormats/UltraCanvasPolygonTriangulation.h` because the B-rep face mesher needs the same thing with holes. - **Smoothing groups round-trip, and are honoured.** `MeshPrimitive::SmoothingGroups` carries a 32-bit mask per face - the same idea as OBJ's `s` and the 3DS `SMOOTH_GROUP` chunk. The OBJ reader fills it; the writer emits `s` only where the value changes, `s` being stateful. `RecomputeNormals` now creases where the file asked, splitting the vertex where a smoothed face meets a creased one so both normals can exist, with attributes, tangents and morph deltas following the split; triangulation carries each face's mask onto the triangles it produces. - **Welding merges across lattice boundaries.** `WeldVertices` still buckets to find candidates but now searches a cell and its 26 neighbours and decides by real distance. Rounding is discontinuous exactly where geometry sits - the axis planes, the origin, every round number a CAD user typed - so single-cell bucketing failed on precisely the seams worth welding. Attributes still gate the merge exactly, and a zero tolerance still merges only bit-identical vertices. - **`Tests/BrepStorageTest.cpp`** (CTest-registered, no sample file needed - it builds its bodies, which is the point) asserts geometric quantities rather than structure: the signed volume of a meshed box, the radial error of a meshed cylinder and sphere, the area of a plate with a hole and that no triangle lies inside it, a rational quadratic B-spline reproducing a circular arc to 1e-12, and inverse projection round-tripping on sphere, torus and a bicubic patch. `ModelStorageTest` and `ModelOBJTest` gained the concave, welding and smoothing-group cases. All seven model suites pass. - Vectors and matrices moved to `DataFormats/UltraCanvasModelMath.h` (unchanged otherwise, same namespace) so the B-rep header can use them without a circular include - a `ModelDocument` owns its `BrepData`. #### 2026-09-10 *0.8.11* - **The 3D formats are a plugin now, not five classes in the core library.** `Plugins/Models/CMakeLists.txt` builds `UltraCanvasModelsPlugin` as its own static library with `ULTRACANVAS_HAS_MODELS_PLUGIN=1`, listed in `ULTRACANVAS_PLUGIN_TARGETS` and switched by `ULTRACANVAS_PLUGIN_MODELS` - the same shape as the Vector, CDR, XAR and EPS plugins. Until now the converters were compiled unconditionally into the core library, so every application linked 3DS, OBJ, DXF, COLLADA and Blender support whether or not it opened a model, and COLLADA's tinyxml2 dependency came with it. COLLADA and `.blend` are now options inside the plugin, each with its own define (`ULTRACANVAS_HAS_COLLADA_CONVERTER`, `ULTRACANVAS_HAS_BLEND_CONVERTER`), so a build without tinyxml2 or zlib still gets the rest. - **One call loads any 3D format.** `UltraCanvasModelFormatsPlugin` mirrors `UltraCanvasVectorFormatsPlugin`: `CreateConverterForExtension`, `LoadModelDocument`, `SaveModelDocument`, and an `IGraphicsPlugin` implementation so `LoadGraphicsFile` / `SaveGraphicsFile`, the FileLoader format inventory and the `Model3D` category reach every format. `GetFileInfo` reports vertices, faces, materials, unit, up axis and bounds - and for a `.blend`, the inspector's summary instead. `RegisterModelFormatsPlugin()` runs from `main.cpp` beside the vector one and registers the STL plugin too, fixing the older bug that `.stl` was invisible to FileLoader unless one particular demo page had been opened. - **`.dxf` is dispatchable but deliberately not claimed**, so a DXF still opens as a drawing through the Vector plugin by default; a caller wanting the 3D entities asks for the converter by name. Extension dispatch sits in its own translation unit away from the `IGraphicsPlugin` façade, because the façade returns a viewer element and needs the UI stack while conversion does not - so a converting tool links a fraction of the framework. `Tests/ModelFormatsPluginTest.cpp` adds 30 assertions over the seam itself: every claimed extension resolves, every converter agrees with the dispatch about its own extension, every save extension really has a writer, and all four geometry samples load - then a 3DS converts to OBJ and back - without the caller naming a format. #### 2026-09-10 *0.8.10* - **Blender `.blend` files are recognised and explained, never imported.** `ModelConverter::BlendConverter` and `ReadBlendFileInfo` (`Plugins/Models/Blend/`) read a `.blend`'s header, block index and embedded SDNA - the three parts stable across many Blender releases - and report what the file holds: version, pointer size, compression, the names of its objects, meshes and materials, the modifier types present, and how many vertices are actually stored. Then the converter declines, naming the modifiers and pointing at the export that would work. The refusal is the feature. A `.blend` stores the *unevaluated* scene, so the model an artist sees is not in the file: the E-45 sample holds **1147 vertices** behind Mirror, Subsurf and EdgeSplit modifiers, while the same model exported to OBJ with those applied is **11749**. A geometry reader would deliver a tenth of the aircraft, and half of it in X since the mirror is one of the unapplied modifiers - which is also why the `.dae` export holds exactly those same 1147 positions. Silent failure tells a user nothing; a partial import tells them something false. gzip save files are inflated; zstd (Blender 3.0's default) is reported by name rather than failing obscurely. `Tests/ModelBlendTest.cpp` covers it with 24 assertions, and `FormatCapabilities` is all-false because a capability report says what a converter does, not what its format could hold. #### 2026-09-10 *0.8.9* - **COLLADA reads into the universal 3D structure.** `ModelConverter::ColladaConverter` (`Plugins/Models/COLLADA/`) reads COLLADA 1.4/1.5 into `ModelStorage::ModelDocument`, and it is the format that finally exercises the whole of it: `` and `` - the first sample to state either - a node hierarchy four deep, `` n-gons, `profile_COMMON` materials with transparency and the sampler2D-surface-image texture chain, vertex colours, and animation. Nothing in the structure had to change to hold it. Three COLLADA details are handled rather than approximated: a node's transform is an ordered *sequence* of ``, ``, `` and `` composed in document order (Blender writes three separate `` elements, and reading them into fixed slots gives the wrong pose); `

      ` indices are per-corner and per-stream like OBJ's, so unique tuples become vertices; and a matrix-valued animation channel is decomposed per keyframe into translation, rotation and scale, because that is what the document interpolates. `Tests/ModelColladaTest.cpp` covers it with 41 assertions, including translate-then-rotate against rotate-then-translate. - **The DAE sample is deliberately not the same aircraft.** Its export holds half the hull (an unapplied mirror modifier), is far lower-poly than the 3DS, and displaces its two meshes by an armature's transforms. The reader's world bounds were checked against an independent walk of the same node chain and agree exactly, so the file is what it is - and the test asserts those differences on purpose, so a later change cannot quietly "fix" the reader into matching the other exports. #### 2026-09-10 *0.8.8* - **DXF read as geometry, not as a drawing.** `ModelConverter::DXFModelConverter` (`Plugins/Models/DXF/`) reads the 3D entity set into `ModelStorage::ModelDocument`: `3DFACE` (a 4th corner repeating the 3rd is a triangle, not a degenerate quad), polyface meshes, polygon meshes, 3D polylines, lines and points - one mesh per layer, the layer's ACI colour as its material, `$INSUNITS` as the document's unit, Z-up. This is the geometry the Vector plugin's DXF reader has to discard, because `VectorDocument` is 2D and has nowhere to put a Z. The two are complements: a floor plan is a drawing, an exported model is geometry, and a file that is only a drawing is now refused with a warning naming the other reader rather than returned as an empty document. - **The ACI palette moved to core.** `DataFormats/UltraCanvasCADPalette.h` holds `AciPaletteColor` - the exact classic colours 1-9, the 250-255 grey ramp and the 24-hue construction for 10-249 - because the 2D vector converters and the 3D model converters both resolve ACI and neither plugin owns it. `VectorConverter::AciPaletteColor` stays as a forwarder, so the vector converters read unchanged. - **Three formats of one aircraft now agree.** `media/models/` carries the E-45 as 3DS, OBJ and DXF exports of the same scene. The DXF and 3DS land on identical bounds with no conversion, the OBJ lands on them after `ConvertUpAxis`, the DXF and OBJ each hold 8110 quads, and triangulating either gives the 3DS's 16220 triangles exactly. `Tests/ModelDXFTest.cpp` adds 41 assertions, among them a synthetic polyface mesh that caught a real bug: a polyface *position* vertex carries flags 192 (128 | 64) and a face record carries 128 alone, so testing bit 128 by itself matched both and no polyface mesh would have loaded its positions at all. #### 2026-09-10 *0.8.7* - **Text formats choose their write precision.** `ModelConverter::ConversionOptions::Precision` selects between `NumericPrecision::Compact` - the C++ stream default of 6 significant digits, which is what OBJ files in the wild contain - and `NumericPrecision::Full`, enough digits that every value read back is bit-identical to the one written: 17 for the document's double positions and 9 for its float attributes (`max_digits10` for each). Compact stays the default, so existing output is unchanged. The difference matters wherever geometry sits far from the origin: a survey coordinate of 1234567.8912345678 comes back as 1234570 under Compact, 2.11 units - two metres - lost to six digits, while Full returns it bit-for-bit. The cost is about a third more file size (the E-45 aircraft goes from 1.37 MB to 1.87 MB as OBJ). The OBJ writer honours it for vertices, texture coordinates, normals and the `.mtl` library; the option sits on `ConversionOptions` because every text format the matrix gains - PLY ASCII, glTF's JSON, COLLADA, X3D - faces the same choice. #### 2026-09-10 *0.8.6* - **OBJ reads and writes through the universal 3D structure.** `ModelConverter::OBJConverter` (`Plugins/Models/OBJ/`) is the first format on `ModelStorage::ModelDocument` with both directions, so it is also the first round trip: OBJ to document to OBJ and back returns the same vertices, faces, bounds, names and materials. **N-gons survive** - the E-45 aircraft sample is 8110 quads and no triangles, and it comes back as 8110 quads. OBJ's three independent index streams (11749 positions against 12227 texture coordinates in that file) resolve into unique corners rather than being assumed parallel, `o`/`g`/`usemtl` split meshes and primitives, and the reader handles negative indices, all four face-corner forms, vertex colours and the MTL PBR extension. The writer emits a companion `.mtl` beside the model and reports everything OBJ cannot hold - the node hierarchy it bakes flat, animation, skinning, cameras, lights, morph targets, point and line primitives. - **Two readers now agree on the same aircraft.** `media/models/` carries the E-45 as both a Y-up OBJ of quads and a Z-up 3DS of triangles. `ConvertUpAxis` on the OBJ document reproduces the 3DS bounds to four decimals on every axis, both readers find 12227 vertices, and the quad count is exactly half the triangle count - the up-axis conversion, the n-gon representation and the vertex-splitting rule checked against ground truth instead of against themselves. `Tests/ModelOBJTest.cpp` holds it, with 34 assertions including malformed input and the parsing corners. #### 2026-09-10 *0.8.5* - **3DS models read into the universal 3D structure.** `ModelConverter::ThreeDSConverter` (`Plugins/Models/3DS/`) reads Autodesk 3D Studio files into `ModelStorage::ModelDocument` - the first scene format on the new structure, and the one that proves it holds a scene: named meshes, per-object matrices, materials with diffuse/specular/bump maps, per-face material groups split into one primitive each, cameras and lights. 3DS stores vertices in world space beside each object's own matrix, so the reader puts the matrix on the node and the inverse into the vertices - the object frame survives as real structure and composes back to exactly the coordinates the file held. Every read is bounds-checked against its enclosing chunk, so a truncated or hostile file yields a warning rather than an overrun, and what the format loses is reported: 12-character truncated texture names, several images stacked in one map slot, a KFDATA hierarchy this reader does not yet read. `media/models/3DS/E-45-Aircraft.3ds` is the sample; `Tests/Model3DSTest.cpp` covers it with 25 assertions including the malformed-input cases. - **A white specular is no longer read as metal.** `ModelMaterial::DeriveMissingModel` derived metalness from the Phong specular alone, so every painted surface with a white highlight - the commonest material in MTL, 3DS and COLLADA files - became raw metal, which renders black without an environment. Metal now also requires a dark diffuse, since having no diffuse albedo is what physically distinguishes one, and takes its base colour from the specular. `Matrix4x4::InverseAffine` joins the structure for readers of formats that store world-space vertices beside an object matrix. #### 2026-09-10 *0.8.4* - **One 3D structure for every 3D format.** `ModelStorage::ModelDocument` (`DataFormats/UltraCanvasModelStorage.h`) is to 3D what `VectorStorage::VectorDocument` is to 2D: the in-memory model each 3D file format reads into and writes out of. It is a core service, which the flat `Mesh3D` inside the STL plugin could never be - scenes, nodes with TRS or matrix transforms and instancing, meshes of primitives with double-precision positions and open-ended named vertex attributes (n-gons survive; point clouds are just `Points` mode), PBR *and* fixed-function Phong materials with a derivation between them, textures, skins, morph targets, keyframe animation, cameras, lights, and the declared unit, up axis and handedness that decide whether an import arrives the right size and the right way up. Operations every converter would otherwise rewrite come with it: triangulation, area-weighted normals, attribute-aware welding, transform flattening, up-axis conversion and TRS decomposition. `ModelConverter::IModelFormatConverter` (`DataFormats/UltraCanvasModelConverter.h`) is the matching read/write interface, shaped like `IVectorFormatConverter` down to the warning callback a lossy conversion must use. `Plugins/Models/UltraCanvasModelMesh3D.{h,cpp}` bridges to the existing `Mesh3D`, so the STL viewer and the Filer thumbnails keep working while formats migrate one at a time. `Tests/ModelStorageTest.cpp` covers it, and runs against a real 510 671-triangle STL when given one. The survey behind every field - what STL, OBJ/MTL, PLY, OFF, glTF/GLB, COLLADA, FBX, 3DS, X3D, USD, 3MF, AMF and the point-cloud formats each contain, and why B-rep (STEP, IGES, ACIS, DWG `3DSOLID`) is deliberately not in scope - is [UltraCanvas3DModelProposal](../Research/UltraCanvas3DModelProposal.md). #### 2026-09-10 *0.8.3* - **STL models have a demo page.** *3D Graphics → STL 3D Models* (`Apps/DemoApp/UltraCanvasSTLExamples.cpp`) reads every `.stl` file in `media/vector/STL` through `UltraCanvasSTLLoader` and shows it in an `UltraCanvasSTLElement` - shaded and orbitable on GL builds, a mesh summary without GL. The page reports what the parser found (triangles, vertices, extent, centre, ASCII vs binary, parse time), cycles the model material, toggles auto-rotation and opens the model fullscreen; samples are parsed on first view and cached, and the directory is scanned at page build, so a new sample file needs no code change. The element is now in the UI element catalogue and documented in [UltraCanvasSTLElement](UltraCanvasSTLElement.md). - **The CAD page shows its DXF samples.** *Vector Graphics → DWG / DXF Drawings* claimed both formats but only displayed the three `.dwg` samples. It now carries the `media/vector/DXF` drawings as well - five tiles in two rows, each captioned with its format - so the DXF reader that a `.dwg` file reaches only after decoding is demonstrated on its own input too. #### 2026-09-10 *0.8.2* - **UltraCanvasParliamentDiagram** *(1.0.0)*: new legislature seat chart, the "parliament diagram" of election reports - every seat one marker coloured by party, parties side by side in insertion order. Four layouts render the same party list: the classic `Hemicycle` of concentric arcs, whose span `SetArcSpan()` widens from 180 degrees into a horseshoe; `Circle`; `Westminster`, two blocks of benches facing each other across an aisle with the governing parties on the left, the rest opposite and a Speaker's chair at the head; and `Grid`. In the arc layouts each arc takes seats in proportion to its length and the seats are handed to the parties by sweeping the angle, which gives every party the familiar wedge; the number of arcs is the smallest that fits every seat unless `SetRowCount()` fixes it. A dashed majority marker sits at the half-way seat, the seat total in the empty centre, and a legend with seat counts under or beside the chamber. Parties carry a `government` flag - it fills the Westminster benches, fades the opposition with `SetHighlightGovernment()`, and feeds `GetGovernmentSeats()` / `GovernmentHasMajority()` - and a `vacant` flag for hollow seats such as the Speaker's. Hovering a seat or legend entry fades the other parties and shows a tooltip with the seat count and share; clicking selects, with `onPartyHover`, `onPartyClick`, `onSeatClick` and `onSelectionChange` callbacks. Three sample chambers ship in `ParliamentDiagramSamples`. DemoApp gains `Diagrams > Parliament Diagram` with four tabs - Bundestag hemicycle, European Parliament horseshoe, House of Commons on Westminster benches, and a coalition builder where clicking parties assembles a majority. Docs in `Docs/UltraCanvas/UltraCanvasParliamentDiagram.md`. #### 2026-09-10 *0.8.1* - **A drop can ask before it is carried out.** Dragging files onto a folder of `UltraCanvasFilerWidget` moved them the moment the button came up, and a drag is the one file operation that starts by accident - a press that wandered a few pixels on the way somewhere else - so the first sign of it was a folder that had emptied itself. `SetDropConfirmation(FilerDropConfirmation)` now chooses when the drop asks first: `NeverConfirm` (the default, unchanged behaviour), `MoveOnly` - the half that changes where the files live - or `AlwaysConfirm`, copies and files dragged in from other programs included. The question names what is about to happen, how many entries and into which folder, with the folder's full path underneath, and nothing is touched until it is answered; with dialogs disabled the drop is carried out rather than lost. (`Docs/UltraCanvas/UltraCanvasFilerWidget.md` > Drag & drop.) #### 2026-09-10 *0.8.0* - **A home icon that shows whose home it is.** `media/icons/home-user.svg` joins the shared icon set: a house with the user in it, drawn in the flat two-tone shape of the folder icon family so it reads at 16 px tree-row size as well as on a thumbnail tile. It replaces the monochrome `home-icon.png` glyph everywhere the icon set is used - the breadcrumb and toolbar examples in `Docs/UltraCanvas/` and in DemoApp, and UltraFiler's tree row, Computer tile and tab. - **A filer entry can be drawn under a name of the host's choosing.** `UltraCanvasFilerWidget::displayNameProvider(entry)` answers with the name to draw in place of the file name, `""` to keep it - the counterpart of `folderIconProvider`, asked ahead of every built-in rule including a desktop launcher's own `Name=`. Only the drawn name changes: sorting, renaming, the clipboard and every file operation still use the real one. It exists for entries that mean something other than a file of that name - UltraFiler's Computer page shows the home folder as *Home* rather than under the account it is named after. - **A dropdown arrow a control can actually wear.** `media/icons/dropdown.svg` is the arrow that says "this opens a menu". Until now the split buttons spelled it with a "▾" character, which a text renderer draws at a fraction of the button around it - a mark a few pixels across in a section nearly thirty wide. The icon's view box is cropped to the chevron, so it fills whatever size it is given. On a split button: `SetSplitSecondaryText("")` and `SetSplitSecondaryIcon(path)`, sized with `SetSplitSecondaryIconSize()`. One catch worth knowing: `SetUseIconAsMask()` is a button-wide flag, so a masked button paints the secondary icon in the color from `SetSplitSecondaryIconColors()` - white by default, which is invisible on a light button. Set it to the button's text color. #### 2026-09-10 *0.3.119* - **Radio group: a programmatic selection now shows.** `UltraCanvasRadioGroup::SelectButton()` unchecked the group's other buttons but never checked the one being selected. That is invisible on the click path, where `AddRadioButton` routes the button's own `onChecked` into it and the button has already checked itself, but a `SelectButton()` call from code left the group with no dot at all. It now checks the target as well, assigning `selectedButton` first so the re-entrant call arriving through `onChecked` finds a consistent group and the selection callback still fires exactly once, and returns early when the button is already the selection. #### 2026-09-09 *0.3.118* - **A font file opens full size in a window of its own.** `UltraCanvasMediaViewerWindow` is a new component: an `UltraCanvasMediaViewer` filling a top-level window, opened over the window the user is looking at. It is the companion to a preview pane — a pane answers "is this the file I meant", a window answers "let me look at it", which for a font is the difference between a two-letter specimen and every glyph in the file. Because it hosts the media viewer it has no per-format case of its own: images, video, audio, documents, spreadsheets, e-books, 3D models and fonts all open the same way, and Prev / Next walk the rest of the folder. One instance owns at most one window, so double-clicking through a folder gets one window that keeps up rather than a window per file; Escape closes it, and closing releases the file so a later rename is not blocked by a document engine still holding it open. - **UltraFiler: double-clicking a font opens it, rather than nudging the preview pane.** A font is the one previewable kind whose whole point is the part a pane cannot hold, so it now opens in a viewer window — unless this system has an application registered for it, which wins the way it does in Explorer. Single-click still previews in the pane. Every other kind is unchanged. - **The font viewer's control bar came back stacked when it was embedded.** The same failure the previous entry fixed, through the other door: the re-flow ran from `SetBounds()`, and a viewer inside a flex parent — the media viewer's column, which is how the file manager gets one — is sized by the layout engine through `Arrange()` and never through `SetBounds()`. Built at no size and given one later, it kept the placement it had at zero, which is none, so the layout engine stacked the pickers in a column over the glyphs. `Arrange()` now re-flows too, and skips the work when the size has not changed. The test arranges a viewer built at 0 x 0 and checks the bar; it fails nine ways without the fix. - **The media viewer's information bar said "No media" over a font.** `MediaKind::Font` was missing from the bar's kind list, so a font fell through to the image branch and reported nothing. It now names the file, its family and its glyph count — the number you compare two font downloads by, the way a PDF's page count is — plus the face for a collection. - **Legacy bitmap fonts previewed as `!"#$%` instead of letters.** A folder of `C:\Windows\Fonts` showed every `.fon` file as a run of punctuation or DOS box-drawing symbols while the TrueType files beside them correctly showed their own `Ag`. The cause was one missing step: characters are resolved with `FT_Get_Char_Index` against the face's *selected* charmap, and FreeType refuses to select one whose encoding is `FT_ENCODING_NONE` — which is precisely what the legacy bitmap formats expose (Windows FNT/FON, PCF, BDF with a non-Unicode registry). Those faces arrived with `face->charmap` null, every lookup answered 0, and the specimen concluded the font had no Latin coverage and fell back to drawing its first glyphs by index — which in those fonts are `!"#$%` (space is skipped as blank) or `☺☻♥♦♣` on the OEM codepages. `UltraCanvasFontFile` now selects a charmap itself when FreeType selected none — Unicode, then Apple Roman, then whatever the face carries — so those fonts show their letters, and the Hebrew and Arabic codepage faces show their own scripts. A face that did get a charmap keeps it, so a real symbol font whose only charmap is MS Symbol still falls back as it should. - **A one-character specimen was silently replaced.** The same fallback fired whenever *fewer than two* sample characters resolved, so a caller passing `FontSpecimenOptions::text = "A"` got the font's first six glyphs rather than its A — the option documented as taking any string quietly ignored the shortest ones. It now falls back only when nothing resolves at all; a font covering part of the sample draws the part it covers. - **A font file can be held open and browsed glyph by glyph.** `UltraCanvasFontFace` opens one face, enumerates its coverage once and then rasterizes individual glyphs from the face that is already open — the session type a glyph browser needs, where the existing one-shot calls re-open the file every time (which is what makes *them* safe on the thumbnail workers, and useless for a grid). `Glyphs()` lists every glyph in codepoint order where the face has a usable charmap and in glyph-index order where it has none; `Ranges()` cuts that into runs and names each after its Unicode block, as a partition, so a range picker cannot silently hide part of a font; `FindCodepoint()` jumps to a character and `GlyphName()` reads the font's own name for a glyph on demand — a CJK face has tens of thousands and a browser labels only the few under the pointer. `RenderGlyph()` scales the face's *bounding box* into the cell rather than the individual glyph, so no glyph can overflow and every cell shares a baseline: an `A` sits above it and a `g` hangs below it, which is what makes a grid read as text instead of as unrelated pictures. `FontGlyphOptions::fitInkToCell` opts into the other behaviour for a detail pane. Move-only, self-closing, and single-threaded: it owns a live `FT_Face`, which is not re-entrant. - **A font file now has a detail view: every glyph in it, scrolling.** `UltraCanvasFontViewer` is a new element - a grid of the font's own glyphs with a picker for the ranges it covers, a size control and an information line naming the glyph under the pointer. `UltraCanvasMediaViewer` shows it for the new `MediaKind::Font`, which is what finally puts something behind the Display > Detail view > Fonts switch: that switch existed but could never fire, because `UltraFilerWindow::CanShowInDetailView()` asks `IsSupportedMedia()` first and a font never got past it. Nothing is installed or registered to show one - the grid rasterizes straight from the file, so a folder of downloaded candidates browses exactly like a folder of installed ones. The range picker is what makes a 20 000-glyph CJK font navigable: scrolling to Hiragana is one choice rather than a long drag. Only the rows on screen are rasterized, and cells are cached by (entry, device-pixel edge), so the cost is a screenful rather than the font. The grid is a self-rendered view in the sense the house rules allow - the cells are content, painted as the filer paints its tiles - while every control is a real element: `UltraCanvasDropdown` for the pickers, `UltraCanvasSlider` for the size, `UltraCanvasLabel` for the information line, `UltraCanvasScrollbar` for the bar, and `UltraCanvasSmoothScroll` for the easing, so a wheel notch feels the same as it does in the filer. - **A label rendered without a window crashed instead of drawing.** `UltraCanvasLabel::Render()` segfaulted whenever the label was drawn into an offscreen context — one from `CreateRenderContext(size, nullptr)`, the kind the QR code plugin uses to export a PNG — because it built its cached `ITextLayout` from the context reachable through the element's *window*, which an unattached label does not have, and then dereferenced the null it got back. `UpdateInternalLayout()` had been handed the right context all along and ignored it. The layout is now built from the context the label is about to draw into, falling back to the window's; the two are the same object for a label in a window, so nothing changes there. A layout that still cannot be built now costs the label its words rather than the process — the check `IRenderContext::DrawText()` makes a few lines away. The offscreen path turns out to lay out text perfectly well: with the context threaded through, a label drawn into an image surface draws its text. Covered by a new `OffscreenRenderTest`, which segfaults without the fix. - **The font viewer's control bar landed in one column on top of the grid.** Putting the viewer in a window for the first time showed the range picker, the size slider and the information line stacked at the top-left over the first row of glyphs, and no scrollbar at the right edge at all. The bar's arithmetic was right; the placement was not applied. `SetBounds()` writes `finalBounds` only, and the parent's next `Arrange()` pass overwrites it — an in-flow child is re-stacked wherever the layout engine wants it, which for these was a column at the origin. The viewer now places every control out of flow (`SetElementSize()` + `SetElementAbsolutePosition()`), the way the filer places its rename editor and the toolbar builder its toolbar. Three smaller things the same first look found: the range picker did not follow the grid, so it went on claiming "Basic Latin" while the view was in Latin Extended-B; the empty state's "No font loaded" was drawn in the element's own rectangle and so sat behind the control bar rather than in the middle of the grid; and the scrollbar drew a full-height thumb down the edge when there was nothing to scroll. - **`UltraCanvasDropdown::SetSelectedIndex(index, false)` notified anyway.** The flag suppressed the `onSelectionChanged` callback but the method still posted a `DropdownSelect` event, which is a notification by any measure - and every caller passing `false` does so precisely to move the control without anything reacting ("don't fire SetInputDevice yet"). The event is now inside the flag, and the application singleton it is posted through is null-checked the way the rest of the core reaches it, so constructing a dropdown before (or without) an application is no longer a crash. Surfaced by the font viewer's tests, which build one headlessly. - `Tests/FontFileTest.cpp` pins both. The discriminator is pixel identity rather than ink volume: when a sample fails to resolve, *every* request falls back to the same six glyphs, so two different single characters come out byte-for-byte equal — an ink-volume comparison passes by luck, and did. The charmap half needs a face whose only charmap is `FT_ENCODING_NONE`, which only the binary bitmap formats produce (a BDF written by the test comes back as `ADOBE_STANDARD`, which FreeType selects by itself), so it probes the system's X11 bitmap fonts and skips where a machine has none. #### 2026-09-09 *0.3.113* - **Android: framework diagnostics reach logcat.** `debugOutput` and the process's stdio went nowhere on Android, where there is no terminal to inherit, so a crash or a warning left no trace at all. Both are now routed through `__android_log_write` under the app's tag. - **Android packaging builds x86_64 first.** The emulator most development actually runs on is x86_64; building arm64 first meant the usual loop waited on the ABI it was least likely to use. - **Android: real input dialogs instead of "Cancel" stubs.** The native dialog bridge answered every prompt as if the user had cancelled. It now shows the platform's own dialogs and returns what the user chose. - **Android: TLS verifies against the platform's trust roots.** The Linux TLS implementation had no way to reach Android's system CA store, so certificate verification could not be done properly on the platform. - **Android: images and files can be pasted from the clipboard.** The backend was text-only — an image or file copied in any other app was invisible. A clip's non-text items are `content://` URIs, which no POSIX call can open, so the activity copies each through the app's `ContentResolver` into the cache and the backend hands back paths, the same copy-to-cache bargain the SAF picker already makes. `GetAvailableFormats` now reports what the clip actually advertises rather than always `text/plain`. Writing files to the clipboard stays unimplemented on purpose: it needs a `ContentProvider` declared in the *application's* manifest, which framework code cannot supply on an app's behalf, and a bare filesystem path would look like it worked while being unopenable by every other app. - **Android: audio is on.** It was on the "waits on the cross-compiled sysroot" list it never belonged on — the backend is miniaudio, vendored in-tree, which speaks AAudio natively with OpenSL ES beneath it, both reached through `dlopen` rather than a link line. No new dependency, no new link library, and no manifest permission for playback. The optional codec libraries stay absent, which costs formats rather than the backend. `scripts/android-syntax-check.sh` type-checks the miniaudio translation unit against the real NDK, so enabling it is a change rather than a claim. #### 2026-09-09 *0.3.117* - **LaTeX documents open as documents (LaTeX engine Phase 3).** New `UltraCanvasLaTeXDocumentReader` (`include/Plugins/Documents/LaTeX/UltraCanvasLaTeXDocumentReader.h`, core library) imports the `article` document subset of a `.tex` file into `UCRichDocument`, the model the ODT/DOCX readers fill: `\maketitle`, sectioning with article numbering, paragraphs and line breaks, text formatting and colours, `itemize` / `enumerate` / `description`, quotes, alignment environments, `tabular` with `\multicolumn` / `\multirow`, floats with numbered captions, `\includegraphics` (embedded as media, sized from `width=` / `height=` / `scale=`), verbatim and listings, footnotes, `\label` / `\ref` / `\eqref`, `\cite` with `thebibliography`, `\newtheorem` environments, `\newcommand` / `\def` / `\newenvironment` expansion, `\input` / `\include`, accents and text symbols. Formulas are kept as LaTeX source (the definitions they use prepended) and typeset by the math engine wherever the document is shown. Unknown commands and environments, missing images, undefined labels and TikZ pictures are reported as line-numbered diagnostics while the rest of the document still imports. It is an importer, not a TeX interpreter: no page layout, no arbitrary packages, no writer. - `UCRichDocument` gained `RichTextRun::math` (inline formula source) and `RichBlockType::MathBlock` (display formula). `ToMarkdown` writes them as `$...$` and `$$` fences, emits `^x^` / `~x~` for single-word super- and subscripts, and pads spanning table cells so the Markdown grid stays aligned; `FromMarkdown` reads `$$` fences back; `ToHTML` and the ODT/DOCX writers degrade a math block to a centred `$$...$$` paragraph. - `WordDocumentFormat::LaTeX`: `DetectWordDocumentFormat` recognises a LaTeX head (`\documentclass`, `\begin{document}`, a sectioning command; comments skipped) or a `.tex` / `.latex` / `.ltx` text file, and `UCWordDocumentIO::Load` / `LoadLaTeX` dispatch to the reader, so `UltraCanvasFileLoader::LoadTextDocument` (and its dialog filter), the Filer's preview page and the Media Viewer open `.tex` as the rendered document. `UltraCanvasSupportedFormats` lists `tex`. Texter keeps editing `.tex` as source. - Demo "LaTeX Documents": a formula-only file still goes to the LaTeX view; an article-style file renders through the reader in a Markdown TextArea with its diagnostics in the header; only TikZ / pgfplots documents fall back to a reference image. New sample `media/LaTex/article-quadratic-note.tex`. - `Tests/LaTeXDocumentTest.cpp` (registered as `LaTeXDocumentTest`): the vocabulary above, macros, diagnostics with line numbers, Markdown and ODT round trips of the math model parts, format detection, and the shipped `media/LaTex` corpus (every file imports without a diagnostic). Docs: `UltraCanvasLaTeXDocumentReader.md`; the proposal, the ODT/DOCX proposal, the Media Viewer, Filer and element catalogue pages updated. #### 2026-09-09 *0.3.116* - **Inline math in the text stack (LaTeX engine Phase 2).** Formulas now render typeset inside text: `UltraCanvasTextArea`'s Markdown mode sets `$...$` (text style), `$$...$$` (display style, inline) and `$$` fenced blocks (centred) through the LaTeX module's native engine, baseline-aligned with the surrounding words - in paragraphs, headings, list items, blockquotes and table cells. Word and ODT documents therefore show their equations typeset (the OMML / MathML importers already produced `$latex$` runs; the Markdown serializer now keeps them unescaped instead of doubling their backslashes). Without the LaTeX module the previous Unicode substitution remains. A `$` pair counts as math only without a space after the opener / before the closer and no digit after the closer. - New core API `UltraCanvasInlineMath` (`include/UltraCanvasInlineMath.h`): `Typeset(latex, px, colour, display, ctx)` returns width, ascent, descent and `Draw(ctx, x, baseline)`, reaching the module through four new ABI entry points (`UltraCanvasLaTeXModule_TypesetInline` / `_InlineMetrics` / `_DrawInline` / `_ReleaseInline`, ABI 3) so any element that lays out text can place a formula. Text layouts gained `TextAttributeFactory::CreateShape(width, ascent, descent)` (a Pango shape attribute with a height) and `ITextLayout::IndexToBaseline()`. - Markdown table cells keep backslashes other than `\|` for the inline parser (they were stripped, which broke escapes and LaTeX in cells). - `Tests/InlineMathTest.cpp` (registered as `InlineMathTest`): the handle through a real `dlopen` of the module, the parser's placeholder and cursor map, and an offscreen TextArea render whose ink proves the inline rule and the centred block. `Tests/WordFormatsTest.cpp` checks the Markdown output of an OMML fraction. The element catalogue lists the LaTeX view and the inline-math handle. #### 2026-09-09 *0.3.115* - **LaTeX: the native math engine (Phase 1) is the LaTeX view's default typesetter.** `UltraCanvasMathEngine` (`include/Plugins/LaTeX/`, `Plugins/LaTeX/`, documented in `Docs/UltraCanvas/UltraCanvasMathEngine.md`) replaces MicroTeX behind `UltraCanvasLaTeXView`: `UltraCanvasMathParser` turns LaTeX into an atom tree (~180 commands, ~600 symbols, the matrix / align / cases / array environments with full column specs, `\newcommand`, text mode, colours, boxes, `\cancel`, `\sideset`, `\longdiv`, ...), `UltraCanvasMathLayout` sets it by The TeXbook's Appendix G rules with the font's OpenType MATH constants (spacing table, scripts, fractions, radicals, delimiters with variants and assemblies, large operators with limits, accents, arrays with per-cell rules), and `UltraCanvasMathRender` draws the box tree through `IRenderContext` as outline paths. Any OpenType math font works; the `.clm2` is no longer needed by the default engine. Errors no longer blank the formula: the parts that parse are typeset, the offending command is shown in red at its place and `GetLastError()` names it. `\text{}` uses the math font's upright glyphs; characters the font lacks go through the context's text layout. - `UltraCanvasLaTeXView` gains `SetDisplayStyle(bool)` / `IsDisplayStyle()` (display, the default, or text style) — module ABI 2. The CMake cache variable `ULTRACANVAS_LATEX_ENGINE` (`native` | `microtex`) sets the build default and the environment variable of the same name overrides it at run time; MicroTeX stays in the module as the test oracle. - `Tests/MathEngineTest.cpp` (registered as `MathEngineTest`): parser atom trees, layout metrics against the font's constants, and a MicroTeX oracle run over the demo corpus plus forty formulas (mean deviation 6% width / 7% height; every shipped `.tex` typesets without a diagnostic). - `Docs/UltraCanvas/UltraCanvasLaTeXView.md` and the engine proposal updated for the two-engine transition. #### 2026-09-09 *0.3.114* - **LaTeX: investigation of a native math engine, and its first piece.** `Docs/UltraCanvas/UltraCanvasLaTeXEngineProposal.md` reports what the vendored MicroTeX plugin contains (19,462 compiled engine lines behind a 321-line adapter, a FontForge-generated `.clm2` font, one font, no baseline for inline use), where the "1 GB LaTeX" concern really comes from (TeX distributions ship packages and fonts; engines are small), and sizes a native math typesetter on the framework's own vector layer (`IRenderContext` paths, `VectorStorage`, FreeType) at 10-13k lines. It proposes a phase plan - font layer, native engine behind the existing module ABI with MicroTeX as the test oracle, inline math for the text stack (imported Word/ODT equations are currently shown as flat text), a LaTeX document-subset importer, TikZ and pgfplots subsets - and argues against a real TeX. The chart engine is untouched by this work; it appears only as the target of the last-phase pgfplots reader. - **Phase 0 shipped: `UltraCanvasMathFont`** (`include/Plugins/LaTeX/UltraCanvasMathFont.h`, `Plugins/LaTeX/UltraCanvasMathFont.cpp`, built into `libUltraCanvasLaTeX`, documented in `Docs/UltraCanvas/UltraCanvasMathFont.md`) reads an OpenType math font directly through FreeType: all 56 MATH constants, italics correction, top-accent attachment, extended shapes, math kerning, size variants, glyph assemblies, exact glyph metrics and cached outlines. Any font with a MATH table works at runtime without a `.clm2`; a font without one still loads for metrics and outlines; a malformed table is refused cleanly. - `Tests/MathFontTest.cpp` (registered as `MathFontTest`) links the vendored engine as an oracle and compares the `.otf` reader with the `.clm2` over all 4,802 glyphs of Latin Modern Math - constants, advances, heights, depths, 1,002 italics corrections, 2,475 top-accent attachments, 176 variant lists and 114 assemblies equal - checks math kerning on a synthetic MATH table loaded from memory, and loads STIX / TeX Gyre math fonts when installed. - **Cross-checked against the Ladybird port's processor-detection findings** (`OS/MSWindows/UltraCanvasWindowsDiagnostics.cpp`), which changed three things here. **x86 instruction sets are read from CPUID** — leaf 1, leaf 7 subleaf 0, leaf 0x80000001 — on every platform, sharing the bit assignments and the psABI-level rules that file verified flag-by-flag against `/proc/cpuinfo`. Neither Win32's `IsProcessorFeaturePresent` nor a filtered `/proc/cpuinfo` list names GFNI, VAES or VPCLMULQDQ, which are exactly the VEX-encoded extensions `-march=native` picks up without AVX-512 — so a CPU could hold every feature the panel printed and still refuse the binary, which is how an AVX2-capable Ryzen 5 5500U came to fault on `VGF2P8AFFINEQB`. New `CPUInfo::x86MicroarchitectureLevel` names the highest x86-64 psABI level the machine satisfies, shown as *Baseline level: x86-64-v3*: that is a flag a packager can paste, and GFNI/VAES/VPCLMULQDQ/SHA are listed without raising it, because no `-march=x86-64-vN` emits them. The detection is guarded on the **architecture**, not the compiler, since MSYS2's CLANGARM64 defines `__clang__`. - **`CPUInfo::emulation` reports a process that is not running natively** on the CPU it describes — `IsWow64Process2` on Windows, `sysctl.proc_translated` (Rosetta) on macOS — shown as a *Running under* row. Under emulation the two halves of `CPUInfo` describe different things: the model and core counts are the silicon's, the instruction sets are the emulator's, and Windows on ARM offers no AVX-512 at all. That is the gap that reached the field as `lagom-gfx.dll` faulting with `ILLEGAL_INSTRUCTION` on Windows 11 while the same package ran on Windows 10. #### 2026-09-09 *0.3.112* - **Work that was pushed but never published now announces itself.** A branch can carry days of finished work and still be invisible to `main`: no pull request was ever opened for it, or its pull request was merged and the commits pushed afterwards are stranded on a branch nothing tracks. Neither announces itself — the push succeeds, the session ends, and the change is simply not in the product, which surfaces days later as "the fix did not arrive". `scripts/check_publication.py` answers it mechanically: run with no arguments it lists the commits on the current branch that are not in `main` and exits non-zero when there are any; `--all` sweeps every branch on the remote and separates the ones fully merged, the ones carrying unpublished work, and the ones that share no history with `main` (they predate a history rewrite, so nothing can be concluded from their commits). It cannot see pull request state — that needs GitHub — so it names the check to run there and what each answer means. `AGENTS.md` rule 3 now requires running it after a push and **saying so unprompted** when there is no open pull request: not opening one unasked is the rule, leaving the user to discover that nothing was published is not. #### 2026-09-09 *0.3.111* - **A double-click that starts a program now says so: the busy pointer.** Spawning a program takes milliseconds, the program appearing takes seconds, and nothing in between told the user their double-click had arrived — so a heavy application got double-clicked twice. Windows grew `ShowBusyPointer(delayMs, holdMs, shape)` / `HideBusyPointer()`: after a delay (**one second** by default) the pointer changes to the new `UCMouseCursor::AppStarting` — the arrow with a busy sign (`IDC_APPSTARTING` on Windows, the theme's `left_ptr_watch` / `progress` and otherwise the watch on X11, `progress` on WASM, unchanged on macOS where launch feedback is the Dock's), and after a hold (**eight seconds**) it goes back by itself. The delay is what makes it usable: a program that is up before the second has passed never changes the pointer at all. While it is up the busy shape wins over element cursors — the window stays usable — and when it comes down the element under the pointer gets its cursor back without waiting for a mouse move. - **UltraCanvasFilerWidget arms it on every launch it makes**: a native binary, a script answered with *Run*, a `.desktop` launcher, an application bundle, a `.webloc` address and anything handed to the OS default application. A launch that fails immediately takes the pointer down again through `onError`. #### 2026-09-09 *0.3.110* - **A dialog can drop the severity icon, and the extract window's progress ring is centred again.** Every modal dialog put the coloured severity badge (the blue `i`) in its own column left of the message, and the whole content column started to the right of it. That is right for a message, and wrong for a dialog whose content carries its own graphic: UltraFiler's compress / extract window drew its progress ring inside that offset column, so the ring sat visibly right of the window's centre with an empty strip beside it. - `DialogConfig::showIcon` (default `true`) and `UltraCanvasModalDialog::SetIconVisible()` / `IsIconVisible()` turn the badge off. Hidden means `display:none`, not merely invisible — the icon reserves no column and no flex gap, so the message column spans the full content width and an element added with `AddDialogElement()` that centres itself is centred **in the window**. `AutoSizeToContent()` no longer keeps the icon's 48px floor for a dialog that has no icon. - `AlertOptions::showIcon` passes the switch through the alert façade, and `UltraCanvasAlert::Plain(message, title, ...)` is the icon-less one-liner next to `Info()` / `Warning()` / `Error()`. The severity still names the window and picks the accent colour. - `UltraCanvasProgressDialog::Show()` gained a trailing `showIcon` argument that **defaults to false**: the ring is that dialog's graphic, so a badge beside it adds nothing and costs it the centre. `UltraCanvasFilerWidget`'s archive jobs (Compress / Extract, the context-menu entries and the multi-archive extract queue) open the window that way, so the "Unpacking …" popup now shows the ring in the middle of the dialog. - `Tests/DialogIconLayoutTest.cpp` lays the dialog's content row out on the real layout engine and pins both halves: with the icon the ring's centre is 30px right of the window's, without it the two coincide at every window width. The DemoApp's Alert page gained an *Alert Without Icon* button. #### 2026-09-08 *0.3.109* - **LaTeX: the on-demand module now finds its math font (and itself) in a normal build, and a view that cannot typeset says why.** The demo's "LaTeX Documents" page showed no formula at all: the plugin loaded, but `Plugins/LaTeX/UltraCanvasLaTeXBackend.cpp` looked for `latinmodern-math.clm2` under `/media/microtex` and `/share/UltraCanvas/media/microtex`, while the top-level CMake copies `media/` to `/share/media` (and a package installs it to `/../share/media`). The font was only found when the working directory happened to be the repository root, and a failed engine initialisation left the view blank with the error reachable only through `GetLastError()`. - The font search now starts at `GetResourcesDir() + "media/microtex"` — the framework's own resource root, the one every other `media/` consumer uses — followed by the `share/media` layouts next to the executable; the old candidates are kept. A view whose font lookup failed retries once `SetLaTeXFontSearchDir()` is called, instead of staying dead. - The loader (`core/UltraCanvasLaTeXModuleLoader.cpp`) also probes `/lib/`: in a dev build the executable sits at the build root and the module in `/lib`, which no previous candidate covered — with a static core there is no rpath to fall back on, so `CreateLaTeXView()` returned `nullptr`. - `UltraCanvasLaTeXView` draws its `GetLastError()` text in red, sized as its content, whenever there is no render (engine not initialised, parse error), so a broken formula is visible in the UI rather than an empty box. - Demo: a plain-math document with no live view now reports `GetLaTeXModuleError()` instead of the misleading "needs TikZ" note. - `Docs/UltraCanvas/UltraCanvasLaTeXView.md`: search-order and diagnostics sections updated. - **`UltraCanvasTreeView`: "jump to first entry" also answers a click on an open parent.** `SetShowFirstChildOnExpand(true)` moved the selection on to a parent's first child when the parent was expanded - by its button, a double click or Enter - but a single click on a parent that was already open (every heading of an `ExpandAll()`'d tree) left the parent selected. For a tree whose headings show their first sub page, that was two rows showing the same content. The click now moves on too; a node still opts out through `TreeNodeData::showFirstChildOnExpand`, and Ctrl+click in multi-select mode keeps adding the parent itself. The jump also gives the keyboard focus to the child it selects, and the arrow keys step over open parents - from the first child of one heading straight to the last child of the one before - so a heading is never the row left selected from the keyboard either; a closed parent is still stepped onto, since it can be opened from there. #### 2026-09-08 *0.3.110* - **Vector document model: precision, bounds, hit-testing, units and CAD layers.** First step of the shared-model work for the vector converter matrix, with the survey and plan in `Docs/Research/UltraCanvasVectorModelProposal.md`. - `VectorStorage::Matrix3x3` is double precision throughout (CAD drawings carry 10⁶-unit offsets with 10⁻³ detail; the DXF reader had grown its own double affine to cope) and gains `IsIdentity()`. Its row-major `FromValues` order is documented; the unused XAR matrix helper that passed PostScript order straight through is corrected. - `VectorGroup::GetBoundingBox` / `VectorDocument::GetBoundingBox` skip empty children instead of unioning them with the origin, so a group holding an empty group or an unsupported element no longer reports a box dragged to (0,0); a transformed empty group stays empty; an empty document reports its page. - `HitTestDocument` carries the point through each layer's and group's inverse transform, so children of a transformed group (every CAD block insert, every mirrored entity) are hit where they are drawn; an element without bounds never hits. - Units: `LengthUnit`, `PointsPerUnit()`, `LengthUnitSymbol()`, and `VectorDocument::SourceUnit` / `PointsPerSourceUnit` record the unit a file measured in and the scale the reader applied. The DXF reader sets them from `$INSUNITS` and uses the physical scale when a unit is declared and gives a usable page (an A4 plan in millimetres becomes 842 × 595 pt); the DXF writer emits `$INSUNITS` and writes the source unit back, keeping lineweights physical. - CAD layer properties on `VectorLayer`: `Frozen`, `Plottable`, `DefaultColor`, `DefaultStrokeWidth`, `LineTypeName`, `DefaultDashArray`. The DXF reader fills them (with `Locked` and `Visible`) from the LAYER table; the DXF writer emits the layer table from them and writes hidden layers as *off* layers. - `UltraCanvasVectorConverter.h` drops the never-implemented `VectorConverterFactory`, `VectorConversionManager` and helper declarations; the registry is `UltraCanvasVectorFormatsPlugin`. - New `Tests/VectorModelTest.cpp` (CTest `VectorModelTest`). #### 2026-09-08 *0.3.109* - **The vector sample media moved under `media/vector/`.** The format folders that sat at the media root — `media/SVG/`, `media/cdr/`, `media/eps/` and `media/xar/` — now live beside the existing `AI`, `DWG`, `DXF` and `STL` sets as `media/vector/SVG/`, `media/vector/CDR/`, `media/vector/EPS/` and `media/vector/XAR/`, so every vector sample is in one place with one folder per format. `media/cdr/demo.jpg` (the reference render for `demo.cdr`) travelled with its drawing. Every path that named them was rewritten: the demo's SVG, CDR, EPS and XAR pages (`Apps/DemoApp/UltraCanvas{SVG,CDR,EPS,XAR}Examples.cpp`), the `EPS_SAMPLES_DIR` / `XAR_SAMPLES_DIR` compile definitions in `Tests/CMakeLists.txt` that feed `EPSProbeTest` and `XARProbeTest`, and the component docs. No other application referenced these folders — the rest of `Apps/` reaches only `media/icons/`, `media/appicon/` and `media/Logo_Texter.png` — and the packaging scripts copy `media/` whole, so nothing else needed touching. - **Seven unused icons deleted from `media/icons/`**: `about.png`, `exit.png`, `image1.png`, `image2.png`, `images.png`, `keyboard.png` and `light 001.jpg`. No application loaded any of them (`exit.svg` is the icon the toolbars actually use); the only mention anywhere was `light 001.jpg` as an illustrative path in the mind map docs, which now name `info.png`. - **DWG files open and preview natively.** The Vector plugin's `DWGConverter` read a drawing only by shelling out to GNU LibreDWG's `dwg2dxf`, so on any machine without that GPL tool a `.dwg` produced no document and no preview. Reading is now a native decoder (`Plugins/Vector/UltraCanvasDWGDecoder.h/.cpp`, no third-party code) that handles every release from R13 to R2018 (AC1012, AC1014, AC1015, AC1018, AC1021, AC1024, AC1027, AC1032): the bit-coded value types, the R13–R2000 section locators, the R2004+ encrypted file header with its LZ77-compressed system and data pages, the R2007 Reed-Solomon coded pages, the object map, the CLASSES table for variable-type entities and the per-entity field layouts. It renders the drawing database as tagged DXF for the DXF reader, so both CAD formats share one import path and `DWGConverter::DecodeToDxf()` doubles as a DWG-to-DXF converter. - Entities: LINE, POINT, CIRCLE, ARC, ELLIPSE, LWPOLYLINE, POLYLINE (2D, 3D, polyface and polygon meshes with their VERTEX chains), SPLINE, HATCH (every boundary edge type, solid/pattern/gradient), SOLID, TRACE, 3DFACE, TEXT, ATTRIB, MTEXT, LEADER, INSERT/MINSERT with their block definitions, the seven DIMENSION types through their rendered blocks, plus the LAYER/LTYPE/STYLE tables, true colours, lineweights, linetypes and visibility. Unsupported types (3D solids, images, proxies, tables, multileaders) are counted and reported through the warning callback. - Validated against LibreDWG's sample corpus (R13, R14, 2000, 2004, 2007, 2010, 2013 and 2018 editions of the same drawing decode to the same entity set as the reference DXFs) and real-world 2007/2013 drawings; 130+ files run clean under AddressSanitizer/UBSan. - `dwg2dxf` remains only a fallback for files the decoder declines (pre-R13 drawings); writing still needs `dxf2dwg`, since the format has no public specification and the framework is MIT-licensed. - **DXF reader: blocks, inserts and dimensions.** The reader drew only the ENTITIES section, so the block references that make up most real drawings were missing. It now parses the BLOCKS section and expands INSERT/MINSERT (nested, scaled, rotated, arrayed, and mirrored through the OCS extrusion), with "0"-layer and ByBlock inheritance, draws DIMENSION entities through their rendered blocks, and adds ATTRIB, LEADER, 3DFACE, 3D polylines, polyface and polygon meshes (projected onto the XY plane), MTEXT rotation and TEXT vertical alignment. Entities whose object coordinate system is not the world's are wrapped in a transformed group. Off/frozen layers, invisible entities and paper-space entities (when the model space has content) are no longer imported. The page is the drawing's real extents — computed from the built geometry, block content included, and reconciled with `$EXTMIN`/`$EXTMAX` — and a page derived from the extents is scaled to a sensible point size, since drawing units are arbitrary (a car in metres and a house in millimetres both come out with legible strokes). - **SVG writer: shapes without a fill are written `fill="none"`.** The model's "no fill" was written as no attribute, which SVG renders black - every closed outline exported from a drawing came out as a solid blob. - New `DWGReaderTest` (block machinery on a synthetic drawing; the native decoder on `Tests/DataFormats/cad-test-document.r2000.dwg`, the framework's own test document converted with dxf2dwg; extra `.dwg` files on the command line are decoded, reported and optionally exported as SVG). - DemoApp: new "DWG / DXF Drawings" page in the Vector Graphics category (`Apps/DemoApp/UltraCanvasDWGExamples.cpp`) showing the samples in `media/vector/DWG/` in `UltraCanvasVectorElement` tiles with a fullscreen pan/zoom viewer, the decoder's statistics and warnings per drawing, and a walk-through of the DWG → DXF → `VectorDocument` pipeline. The Dependencies page now lists DWG reading as in-tree and LibreDWG as the optional writer only. #### 2026-09-07 *0.3.108* - **WebAssembly: real applications link, and the browser clipboard works.** The Emscripten backend (`UltraCanvas/OS/WASM/`) rendered and took input, but it defined neither the `UltraCanvasNativeDialogs` statics nor `UltraCanvasFileLoader::NotifyRecentFile`, which every other platform directory supplies and the core references unconditionally - so any app that used a dialog or the file loader failed to link, and the minimal demo only passed because the static archive never pulled those objects in. - **New `OS/WASM/UltraCanvasWASMNativeDialogs.cpp`**: message and question dialogs through `window.alert()` / `window.confirm()` (two buttons, the title becomes the first line), text input through `window.prompt()` (password prompts report Cancel rather than echo), `SaveContent()` as a browser download, `ShowPrintDialog()` through `window.print()`. The synchronous file pickers report Cancel - a browser picker cannot answer before the function returns - and the desktop `SaveContent()` in `core/UltraCanvasFileLoader.cpp` is compiled out for `__EMSCRIPTEN__` like it already was for Android. - **New `OS/WASM/UltraCanvasWASMFileLoader.cpp`**: `NotifyRecentFile` as a documented no-op. - **New `OS/WASM/UltraCanvasWASMClipboard.h/.cpp`**, selected by `core/UltraCanvasClipboard.cpp` under `__EMSCRIPTEN__` (Emscripten does not define `__linux__`, so the clipboard used to report "not supported"). Text only: `SetClipboardText()` caches and calls `navigator.clipboard.writeText()`; reading is asynchronous in the browser, so `UltraCanvasWASMApplication` now lets the Ctrl/Cmd+V keydown reach the browser, catches the `paste` event it answers with, hands the text to the backend and *then* queues the Ctrl+V key event - the text field's paste handler finds the text in place. `GetClipboardText()` also starts a `readText()` refresh for menu-driven pastes. - `OpenURL()` opens a new tab under Emscripten instead of calling `system("xdg-open")`, which fails with ENOSYS in the sandbox. - **`UltraCanvasWASMSupport.h/.cpp` rewritten.** The kept 2025 utilities returned JavaScript promises through `EM_ASM_INT` (a garbage integer, at once), downloaded empty blobs and stubbed most of their surface. Now: IDBFS mount / sync with completion callbacks; file helpers over the virtual FS; `fetch()`-based `FetchAsync` / `FetchTextAsync`; `DownloadFile` with the actual bytes; **`PickFilesAsync`**, which opens the browser's file picker and copies the picked files into the virtual FS so any framework file API can load them; browser-decoded `LoadImage`; `LoadFont` that fetches a font file into the virtual FS for `RegisterFontFile()`; complete query-parameter access. Nothing in the framework depends on these; they are for applications. - The library's link options now export what those JavaScript bridges call (`-sEXPORTED_FUNCTIONS=_main,_malloc,_free`, `-sEXPORTED_RUNTIME_METHODS= UTF8ToString,stringToUTF8,stringToNewUTF8,lengthBytesUTF8,HEAPU8,FS`). - **New doc `Docs/UltraCanvas/UltraCanvasWebAssembly.md`** - the browser as a platform from an application's point of view: what is identical, what differs (table), the static-application rule, the utilities with examples, serving, known limitations. `OS/WASM/README.md` keeps the build guide and gains the clipboard/dialog rows and the EM_ASM top-level-comma rule; `AndroidPortInvestigation.md` §6 is marked historical - it still described `OS/WASM/` as unwired dead code. - **New workflow `.github/workflows/wasm-build.yml`** (manual dispatch): builds the wasm sysroot (cached on the script's hash) and the demo app with Emscripten, so the backend is compiled somewhere other than one developer's machine. Not in the per-PR matrix: the sysroot takes the better part of an hour. - These additions were syntax-checked against stub Emscripten headers on a desktop compiler and are not yet exercised in a browser; run the workflow against the branch before relying on them. - **A video codec an application brings is now actually used.** The codec registry landed in 0.3.105 could classify a video format and advertise it, but decoding still went through `IVideoBackend` alone — so "registering a container" stopped short of the only part that plays it, and the doc had to say so. `libspecific/Video/VideoCodecPlugin.h` closes that: a registration carries a factory returning an `IVideoDecodeSession`, and `UltraCanvasVideoPlayer` opens it while `CaptureVideoThumbnail` drives the same factory for a poster frame. A plugin therefore gets thumbnails without writing any — the generic decode-session grab was generalised from "the backend's `OpenDecoder`" to any session opener — and may supply a dedicated fast grab when it has something cheaper. - **The decoding half lives beside the backend, not in the public header.** It deals in `IVideoDecodeSession`, and `UltraCanvasMediaCodecRegistry.h` has to stay includable from anywhere — the media viewer includes it even on a build with no video backend at all. So the registry keeps recognition and capability, and `VideoCodecPlugin.h` keeps the callbacks, in a side table keyed by the registry's own canonical extension so aliases and content probes resolve the same way. - **Video plugins run first; audio plugins run last — and the asymmetry is the point.** `IVideoBackend::OpenDecoder` never declines a source: the GStreamer backend builds a `playbin` and reports an undecodable file asynchronously on its bus. Ordering the plugin as a fallback therefore handed every source to the backend and left a registered codec permanently unreachable, which is exactly what the new test caught on its first run. Precedence is safe because the lookup matches only extensions a plugin explicitly registered — a source no plugin claimed still goes straight to the backend, untouched, and the test asserts that so the rule cannot quietly become interception. - **`Tests/VideoCodecPluginTest.cpp`** drives a synthetic in-process codec through the real `UltraCanvasVideoPlayer` and `CaptureVideoThumbnail`: classification, the inventory entry, a decoded frame arriving on `onFrameReady`, the thumbnail fallback, a dedicated grabber taking over, the size bound still being applied, and unregistering a decoder while leaving the format recognised. It passes with `ULTRACANVAS_ENABLE_VIDEO=OFF` too — the case that shows a plugin working on a build with no platform backend. #### 2026-09-06 *0.3.107* - **Raster editing layer — what a bitmap editor needs and the framework did not have.** PixelFX has always been a complete whole-image engine (filters, colour, resampling, formats), but a paint program also needs the pixels *under the brush*: a mutable layer, a layer stack, a selection, undo, dab stamping and an element that edits rather than views. The investigation is written up in `Docs/UltraPaint/FeatureGapAnalysis.md`; the additions are framework code so the next application gets them too. - **New `UltraCanvasRasterLayer.h`** — `UCRasterLayer`, a straight-RGBA 8-bit pixel buffer with name / visibility / lock / opacity / blend mode (`RasterBlendMode`: Normal, Multiply, Screen, Overlay, Darken, Lighten, Difference, Addition, Subtract, Soft Light, Hard Light), whole-layer edits, compositing onto a premultiplied ARGB32 pixmap with the blend arithmetic, and a lossless PixelFX round trip (`ToPixelFX` / `FromPixelFX`). - **New `UltraCanvasRasterSelection.h`** — `UCRasterSelection`, a soft coverage mask with rectangle / ellipse / polygon / mask shapes, replace / add / subtract / intersect, feather / grow / shrink / invert / translate, and the marching-ants outline. - **New `UltraCanvasRasterDocument.h`** — `UCRasterDocument`, the layer stack of one canvas: layer operations, whole-image geometry, pixel-edit brackets (`BeginEdit` / `EndEdit` / `RecordEdit`) and copy-on-write structural snapshots for undo / redo under a memory budget, selection-aware PixelFX filter application (`ApplyFilter`, `PreviewFilter`), a cached composite pixmap re-drawn by dirty rectangle, file load / save through PixelFX and a layered `.ucraster` project file (ZIP of `document.json` plus one PNG per layer, via `UCZipPackage` and `UltraCanvasJSON`). - **New `UltraCanvasBrushEngine.h`** — `UCBrushStroke` (round / square dabs with hardness, opacity *capping the stroke* versus flow *per dab*, spacing with carry-over so speed does not change density, anti-aliasing, pressure; paint / erase / clone / smudge / dodge / burn) and `RasterPaint` (anti-aliased line / rectangle / ellipse / polygon through 4×4 supersampled coverage, scanline flood fill with tolerance and a global mode, magic-wand mask, linear / radial / reflected gradients interpolated in premultiplied space, mask stamping, eyedropper). None of it needs libvips. - **New element `UltraCanvasPaintSurface`** — displays a document's live composite at any zoom (preset ladder, wheel about the pointer, fit, 100 %), pans (middle button, Space+drag, or a pan mode), draws the checkerboard, pixel grid from 8×, marching ants on a timer, a brush-size cursor, and forwards pointer events to the host's tool **in image coordinates** with an overlay hook for rubber bands. Added to the element catalogue. - **`IRenderContext::SetImageSmoothing(bool)`** — nearest-neighbour pixmap drawing (Cairo: `CAIRO_FILTER_NEAREST`) so a zoomed bitmap shows square pixels; default on, the surface switches it off above 200 %. - **New application `Apps/UltraPaint`** (own changelog `Docs/UltraPaint/CHANGELOG.md`, `BUILD_ULTRAPAINT_APP`): the bitmap editor on this layer — 23 tools, layers panel, PixelFX adjustments and filters with live preview, Curves through `UltraCanvasCurvesDialog`, export through `UltraCanvasImageExportDialog`. `ULTRAPAINT_VERSION` joins `cmake/UltraCanvasVersion.cmake`; `package-linux.sh` bundles it. - **New doc `Docs/UltraCanvas/UltraCanvasPaintSurface.md`** covering the five classes; Masterfile_modules.md gains the raster editing section. - **`Tests/RasterEditingTest.cpp`** (with `BUILD_TESTS`) runs the layer arithmetic, selection algebra, brush engine, document undo / redo, selection-aware filters and the PNG / `.ucraster` round trips headless — 112 checks, no window. #### 2026-09-06 *0.3.106* - **"Is this file in use by another program" is now a question the framework can answer** (`UltraCanvasFileLock.h`). It is the question behind every *"the action can't be completed because the file is open in another program"*, and until now nothing here could ask it. `ProbeFileLock()` answers for one file and `ProbeFileLocks()` for a whole folder in one pass; the answer separates **Locked** (a write or a replace would fail right now) from **OpenElsewhere** (somebody has it open, which on Unix stops nothing), and can name the holding program where the platform allows. The probe asks for the access an overwrite needs while granting every sharing flag itself, then closes the handle: nothing is written, and a probe is never what another program trips over. Windows answers through the share mode and the Restart Manager, Linux through `/proc/locks` and `/proc//fd`; on macOS and the mobile targets `FileLockProbeAvailable()` is false and every probe says Unknown. Tested by `Tests/FileLockTest.cpp` (ctest: `FileLockTest`). - **The file display marks files that are being held.** With `SetShowLockState()` (default on) a held file wears a padlock badge in the corner of its icon, carries `X` among its attributes (`O` for one merely open elsewhere) and is described in the info bar — so a file that will refuse to be copied over, renamed or deleted says so before the attempt. Each shown file is probed once per listing on the folder-statistics worker, never on the UI thread, and only the entries actually drawn are asked about. - **A closed media preview lets go of its file.** `UltraCanvasMediaViewer` stopped playback when its file was closed, but a stopped clip is still an open clip: the decoder kept the file, and on Windows that handle is exactly what makes it impossible to rename, replace or delete — next to a preview pane that offers all three. `CloseFile()` now unloads the video and audio players as well, through the new `UltraCanvasVideoPlayerElement::Unload()` and `UltraCanvasAudioPlayerElement::Unload()`. - **VirtualFS no longer holds an archive open after listing it.** The libarchive provider kept a read handle on the archive for as long as it sat in the manager's cache (up to ten at a time), although every operation already opens its own handle — libarchive cannot rewind, so each pass needs a fresh one anyway. On Windows that spare handle made a browsed `.zip` impossible to overwrite or rename for the rest of the session, and it broke *deleting an entry inside one*: that rewrites the archive and renames the new file over the old, which the provider's own handle refused. - **The folder breadcrumb's "Computer" node can open a page of the host's own.** `FolderBreadcrumbOptions::onComputerClick` — when set, a click on the strip's leading *Computer* node calls it instead of navigating to the drive root of the shown path (its dropdown lists the drives as before). UltraFiler uses it for its Computer page; a host that leaves it unset gets the old behaviour. - **A pie chart handed a new data source draws the new slices.** `UltraCanvasPieChartElement` cut its slices once and only its own setters told it to cut again, so `SetDataSource()` on a chart already on screen changed the centre text and nothing else. The slices now follow every base-cache invalidation (the data source, the plot area). #### 2026-09-06 *0.3.105* - **Media classification is driven by the codecs the build actually has.** The media viewer decided what counted as audio or video from two extension lists written into `UltraCanvasMediaViewer.cpp`, which had to be kept in step by hand with what CMake linked — and were not. That is the whole reason an `.m4a` was classified as audio by a build with no AAC decoder in it: the viewer built a player, the decode failed, and nothing had anything to say. Adding a codec meant editing the viewer, the format inventory and the Filer's tables and hoping they agreed. - **New `UltraCanvasMediaCodecRegistry.h`** holds the answer once, and keeps two questions apart that were previously answered from the same list: `IsMediaFileOfKind` ("is this audio at all?") and `CanDecodeMediaFile` ("can we play it?"). The gap between them is the useful part — a format registered as **recognised but unsupported** is classified correctly, so the viewer shows an audio transport and names the missing decoder, while the format inventory stays silent about it because the inventory answers "what can this build do". `UltraCanvasSupportedFormats` is now built from the registry rather than from its own copy of the matrix, so the inventory, the open dialogs, the Filer's categories and the viewer cannot disagree again; the inventory it produces is byte-for-byte what it produced before. - **Registration is the extension point, and it is a real one.** An audio registration may carry `decodeAudio` / `encodeAudio` callbacks, and `UCAudio::LoadFromFile` and `SaveToFile` fall through to them once the built-in backend and codec libraries have declined a file. Running last means a plugin never has to displace anything to be reachable. Registering the same extension twice **upgrades** the entry — capabilities OR-ed, aliases unioned — so adding an encoder cannot silently drop a decoder; `UnregisterMediaCodec` is how you replace one outright. Video decoding stays with `IVideoBackend`; what a video registration contributes is recognition and capability, and the doc says so rather than implying more. - **Extensions two kinds of file share are settled by content.** A registration can carry a `probeFile` callback, run outside the registry's lock, and an entry that has one is deliberately kept out of the extension-keyed format inventory — a name-only lookup could not honour it. The `.ts` transport-stream check moved out of the viewer and onto its registration, which is where it belongs. - **`Tests/MediaCodecRegistryTest.cpp`** covers the built-ins, the recognised-versus-decodable split, inventory agreement in both directions, probe gating, the upgrade-not-duplicate rule, and a registered codec being reached through `UCAudio::LoadFromFile` and `SaveToFile`. It passes with the audio and video backends both compiled in and both switched off, which is the configuration that caught the one bug in this change. - **New doc: `Docs/UltraCanvas/UltraCanvasMediaCodecRegistry.md`.** #### 2026-09-06 *0.3.104* - **M4A plays.** UltraFiler showed an audio player for an `.m4a` and then sat there: the viewer advertised the extension as audio, but nothing in the build could decode it — miniaudio reads WAV/MP3/FLAC only, and the optional codec layer covered Ogg and Opus. Two pieces were missing, and both are now in place. - **New `libspecific/Audio/Mp4AudioDemux.{h,cpp}` — the container half.** A dependency-free ISO base media file format walker: it finds the audio track through `moov/trak/mdia/minf/stbl`, identifies the codec from the sample entry (and, for `mp4a`, from the `esds` objectTypeIndication, which is what tells AAC apart from an MP3 hiding in an MP4), recovers the AudioSpecificConfig or ALAC magic cookie, and expands `stsc`/`stsz`/`stco` into the flat list of access units a decoder wants. It handles the layouts real encoders emit — 64-bit box sizes and `co64` offsets, `stz2` packed sizes, uniform sample sizes, multi-sample chunks, `mdat` written before the `moov` — and reports a fragmented file as fragmented rather than half-parsing it. For HE-AAC it reads the *extension* sampling frequency, not the core one, so those files no longer come out half-length. Every box size and table index is bounds-checked; `Tests/Mp4AudioDemuxTest.cpp` builds the MP4s it parses in memory, so it needs no media assets, and it walks a truncation of every length past the parser. - **New `libspecific/Audio/AudioCodecsAAC.cpp` — the bitstream half**, with three routes tried in order: **FAAD2** (`libfaad`), **fdk-aac**, and **GStreamer's `decodebin`**. The third is the one that matters most in practice: a desktop that already has the GStreamer plugins installed for video now plays M4A with no new package at all — and ALAC, WMA and AIFF with it, which nothing here could read before. It exposes only the audio stream (`expose-all-streams=false`), so a cover-art track cannot wedge the pipeline, pulls with a timeout rather than blocking forever on a stalled decoder, and watches the bus so an undecodable file fails instead of hanging. - **Licensing stayed on the right side of the line.** FAAD2 is GPL-2.0 and fdk-aac carries the Fraunhofer FDK AAC license, so neither is bundled and neither is required: CMake compiles the binding only when the build host already has one, and the GStreamer route (LGPL 2.1, already a dependency of the video backend) changes nothing about the framework's own MIT terms. The consequences are written down in `THIRD_PARTY_LICENSES.md`. - **A dead transport bar now says why.** `IAudioBackend::DescribeDecodeFailure` lets the optional-codec layer name what the container actually held — "this MPEG-4 file holds Apple Lossless (ALAC) audio", "this is a fragmented MP4", "no AAC decoder is compiled into this build", and what would unlock it — and `UltraCanvasAudioPlayer` puts that in `GetLastError()` in place of the generic "unsupported or damaged". The media viewer shows the reason instead of "Failed to open audio: ". - **The format inventory tells the truth again.** `aac`, `m4a`/`m4b` and, where the platform fallback is compiled in, `wma`, `aiff`/`aif`/`aifc` and `mka` now appear in `UltraCanvasSupportedFormats` as load-only, naming the backend that will actually decode them — and they still do not appear when no decoder was found, which is the state the old "AAC stays absent" comment described. `AudioFormatFromExtension` and the Filer's type table learned `m4b` along the way. - **The same audit, on the video side.** The viewer offered WMV, FLV, MPG, OGV, 3GP and transport streams and the GStreamer backend played them, but the inventory listed only the five containers the *capture* path can mux — so the video open dialog refused files the player then played perfectly well, and the Filer had no category for them. They are now listed load-only (saving stays limited to what `MuxerFor()` writes: MP4, MKV, WebM, MOV, AVI), with the Windows and macOS candidate lists extended to what those backends actually demux. - **`.ts` is decided by content, not by its name.** Claiming it for video turned every TypeScript file in a source tree into a video the player could not open — the viewer classifies video before text, so the extension alone was never going to be enough. The viewer now checks for the MPEG transport stream's 188-byte packet sync bytes, and the format inventory is keyed on the unambiguous `m2ts`/`mts` instead. - **Media metadata reads a few more files.** The Filer's probe covers `m4b` and `3g2` as MPEG-4, and `.asf`/`.wma` through the ASF reader it already had for `.wmv`; its MP4 codec table learned `.mp3`, DTS, AMR-WB and the 24/32-bit PCM sample entries instead of showing their raw four-character codes. #### 2026-09-05 *0.3.103* - **macOS applications and shortcuts read like the other two desktops'.** An application bundle was a folder called `Example Editor.app`, a `.webloc` a property list nothing opened, and a Finder alias a file nothing could follow. The file display now shows a bundle by the **application's own name**, with **the icon inside it**, typed `Application` and categorised as a program rather than a folder (`FilerEntry::isBundle`); it carries no shortcut badge, because it is not a reference to something else — it is the application. A `.webloc` shows its address and opens it, and on macOS a Finder alias resolves like any other shortcut. - **Activating a bundle depends on where you are**: on macOS it launches, the Finder's rule; everywhere else it opens as the folder it is, because navigating in is the only thing that machine can do with a Mac application. - **New `UltraCanvasMacBundle.h`.** `ReadApplicationBundle` returns what a bundle's Info.plist says — display name, identifier, version, the executable inside it, and the `.icns` it is drawn with, found through the omissions real bundles have (a `CFBundleIconFile` without its extension, a `CFBundleIconName` that points into a compiled asset catalog, or nothing at all, where the `.icns` in Resources is the answer). `ReadWebLocation` reads the address out of a `.webloc`. `ResolveFinderAlias` follows bookmark data on macOS (`OS/MacOS/UltraCanvasMacOSAlias.mm`, resolved without UI and without mounting, so a folder listing never puts a volume password dialog on screen) and reports false everywhere else rather than guessing: an alias survives its target *moving*, and only the system that wrote it can follow that. - **New `UltraCanvasPropertyList.h`: Apple property lists in both encodings** — the XML form through the tinyxml2 the framework already carries, and the binary `bplist00` form (object table, offset table, 32-byte trailer) parsed here, since that is what most shipped Info.plists actually are. Deliberately not a general plist library: it serves the top-level dictionary flattened to text, which is what a bundle or a web location needs, and skips nested containers rather than half-modelling them. Every offset in a binary plist is bounds-checked. - **The icon reader learns `.icns`** (`UltraCanvasIconResource`), so an application bundle has an icon on every platform, not only on macOS: modern renditions hold a PNG, the classic ones a run-length encoded RGB bitmap whose transparency arrives as a separate mask element, and the small ARGB renditions the same encoding with alpha first. A rendition this build cannot decode falls through to the next. Raising the reader's ceiling to 1024 (an icns goes that big) also separated out what a 0 in an `.ico`'s one-byte size field means, which is 256 and not "the biggest there is". - Tests: `Tests/MacBundleTest.cpp` builds a bundle directory, a web location, an alias-shaped file, an XML plist and a binary one byte by byte, and reads them all back — on every platform, because the reading has to work on every platform. `Tests/FilerShortcutEntryTest.cpp` and `Tests/IconResourceTest.cpp` gained the bundle, web-location and `.icns` cases. #### 2026-09-05 *0.3.102* - **Linux shortcuts are shown the way Windows ones now are.** A `.desktop` launcher was a grey sheet named `org.mozilla.firefox.desktop`; the file display reads it now, and shows it as **Firefox Web Browser** with **Firefox's own icon**, the shortcut badge in the corner, `Shortcut` as its type and the program it starts in the info column. The icon comes out of the icon themes installed on the machine (`Icon=` is a name, not a file), and the name out of the entry's localized `Name=` — the file name of a desktop entry is an id nobody reads. Only the drawn name changes: renaming, sorting and every file operation still use the real file name, so nothing on disk is addressed by a display string. The filter-as-you-type box matches both, so typing "firefox" finds the launcher whatever its file is called. - **Double-clicking a launcher runs it.** The `Exec=` line is expanded and launched detached with the entry's own `Path=` as the working directory, and a `Type=Link` entry opens its web address. Before this, activating a desktop entry handed a text file to a text editor. `FilerEntry` gained `linkDisplayName` beside `isShortcut` / `linkTarget`; for a launcher `linkTarget` is the executable it resolves to on **this** machine (`/usr/bin/firefox`), empty when the program is not installed. - **New `UltraCanvasDesktopEntry.h`: the framework's single reader for the format.** `ReadDesktopEntry` returns what a `.desktop` says — Type, localized Name / GenericName / Comment, Exec / TryExec / Path, Icon, URL, MimeType and the flags — plus the executable those resolve to here. `FindDesktopIconFile` turns an icon *name* into an image file the way the icon-theme specification says: the configured theme (from the GTK and KDE settings files, or `SetDesktopIconTheme()`), everything it inherits, hicolor, then the flat pixmap directories, picking the exact installed size if there is one, else a scalable icon, else the nearest larger. Theme listings and lookups are cached, so a folder of a hundred launchers costs one directory listing per theme rather than a walk per icon. `DesktopEntryCommand` expands `Exec=` into an argv. A file with no `[Desktop Entry]` group is refused, so a text file that ends in `.desktop` is never mistaken for a launcher. - **The "Open with" service now shares that reader** instead of parsing `.desktop` files and hunting for icons itself: its Linux backend lost its private parser, its hicolor-only icon lookup and its Exec tokenizer, and gained the localized application names and full theme lookup that came with the shared one. Two readers of the same files would eventually disagree about what a launcher is called. - **`OpenWithApplicationPath` accepts a `.desktop` file** on Linux/BSD, not only a program. Picking a launcher in the "Other application…" dialog used to try to *execute* the text file and fail; now the entry is read and the command it names is what runs, with its own `Path=` as the working directory. - Tests: `Tests/DesktopEntryTest.cpp` builds a throwaway icon theme (a custom theme inheriting hicolor, several sizes, a scalable icon, a flat pixmap) and points `XDG_DATA_HOME` at it, so what it asserts about the lookup does not depend on what the machine has installed; it also covers the localized keys, `[Desktop Action]` groups, `Type=Link`, the `Exec=` field codes and the files that only end in `.desktop`. `Tests/FilerShortcutEntryTest.cpp` now scans launchers alongside `.lnk` shortcuts and checks the entries the display produces from both. #### 2026-09-05 *0.3.101* - **Windows shortcuts are shown as what they point at, with the icon of the program they start.** A folder of `.lnk` files used to be a wall of identical grey "LNK" sheets — the one thing a desktop is mostly made of, and the file display could say nothing about any of it. Every shortcut it lists is now read: its type is `Shortcut`, its category (colour, grouping, the preview switch that governs it) comes from its target, the info column and the info bar show that target as the link stores it (`C:\Program Files\…`), and the tile carries **the icon the link names** — the application icon of the program, or the `.ico` a browser wrote for a web shortcut. A small arrow badge in the icon's bottom-left corner is what tells the shortcut apart from the file whose icon it wears; below 24 px it is left off rather than smudged over the icon it annotates. Double-clicking a shortcut to a folder navigates into that folder, and one to a file opens the file. `FilerEntry` gained `isShortcut` and `linkTarget` (the target as **this** host opens it), so an application that runs Windows programs itself can launch the real target from `onFileActivated`. - **New `UltraCanvasShellLink.h`: the Windows shell link format, read on every platform.** `ReadShellLink` returns what a `.lnk` says — target, arguments, working directory, comment, icon location and index, target attributes — including the icon location and index that answer the one question a file display asks: which file's icon is this shortcut drawn with (that one, else the target's, which is Explorer's rule). `ResolveWindowsPathOnHost` maps the Windows paths inside a link onto the host by looking for the drive they name: the `drive_c` / `dosdevices` layout of a Wine prefix, the root of a mounted Windows disk (a directory holding both `Windows` and `Users`), then `$WINEPREFIX` and `~/.wine` — matching each path component case-insensitively, because Windows wrote them that way and the host filesystem is not. A link written on another machine usually carries an absolute path that is wrong here and a `%ProgramFiles%` form that is right; every form the link offers is tried, and the first one this host can actually find wins. A file that merely ends in `.lnk` is never mistaken for a shortcut: the header signature and CLSID decide. - **New `UltraCanvasIconResource.h`: `.ico` files and PE icon resources decoded without a Windows shell.** `LoadIconResource(path, index, size)` walks the resource directory of an `.exe` / `.dll` itself and decodes the frame nearest the wanted size — PNG frames through the image pipeline, DIB frames (1/4/8/16/24/32-bit, palette and AND mask) here, including the pre-XP 32-bit form whose alpha band is unused. Windows' index convention, so a shortcut's icon index means what it means on Windows. Only the header range and the resource section of a program are read, so finding a 32-pixel picture in a 300 MB installer costs neither the file nor the memory; every offset in the format is treated as untrusted. - **Application icons are no longer a Windows-only feature.** `NativeFileIconAvailable` / `LoadNativeFileIconPixmap` (`UltraCanvasNativeFileIcons.h`) now answer for `.exe`, `.dll`, `.ico` and `.lnk` on every platform — through the shell on Windows, by reading the files everywhere else. A Windows disk mounted on ULTRA OS, Linux or macOS, and the `drive_c` of a Wine prefix, show their programs and shortcuts with their own icons instead of a generic sheet. On Windows the shell is still asked first, and now also serves the icon a shortcut's association provides when its target holds no icon resource of its own (a shortcut to a document or a folder); a file the shell declines falls back to the portable reader. `.ico` files that this build's image pipeline decodes but the icon reader does not still fall back to the pipeline, so nothing that used to show a picture stopped. - Tests: `Tests/ShellLinkTest.cpp` builds shell links byte by byte and reads them back (LinkInfo targets, `%ProgramFiles%` targets, folder targets, non-Unicode strings, case-insensitive resolution inside a prefix, and the files that end in `.lnk` without being links); `Tests/FilerShortcutEntryTest.cpp` scans a folder of them and checks the entries the file display produces (type, category, info column, resolved target); `Tests/IconResourceTest.cpp` assembles an `.ico` and a minimal PE binary in memory and checks the decoded pixels, mask-driven transparency included. The links all three build come from `Tests/ShellLinkTestSupport.h`. #### 2026-09-04 *0.3.100* - **Folders can be drawn as an icon.** The file display asked nothing about a folder before: every one of them was the same painted folder shape. It now asks its host, through the new `folderIconProvider(entry)` callback, and draws whatever image the host names — any format the image pipeline loads, in every view from the 16 px icon column of the Details rows up to a maximized tile, with `FilerStyle::folderIconScale` still applying. An empty answer keeps the shape, so a display that sets no provider looks exactly as it did. The images go through the shared image cache, so the same icon on a hundred folders is rasterized once per size. - **Writing a `.qoi` file no longer depends on ImageMagick.** `SavePixmapAsQoiFile(pixmap, path)` and `SaveImageFileAsQoi(sourcePath, destPath, maxEdge)` (`ImageCairo.h`) encode through the bundled QOI codec (`qoi.cpp`), which is compiled into every build — unlike `UCImageSaveFormat::QOI`, which routes through `magicksave` and is unavailable wherever the local ImageMagick has no QOI writer. `SaveImageFileAsQoi` reads any format the image pipeline loads and fits the result into a `maxEdge` box: a vector source is rasterized at the full box (a vector has no resolution of its own), a raster is only ever scaled down. It is what an application storing a picture as an icon or a cached thumbnail wants — UltraFiler's folder icons are converted with it. The file is written through `PathFromUtf8`, so a non-ASCII path works on Windows too, which the encoder's own `qoi_write()` (narrow `fopen`) does not. #### 2026-09-04 *0.3.99* - **Imported spreadsheets lost their column widths, and files that carry none showed every column at the same default width.** OpenDocument does not put the width on `` — it lives in a `style:style` of family `table-column` that the element references through `table:style-name` — but the ODS importer only looked for an inline `style:column-width` attribute, so no real `.ods` ever arrived with its layout. The importer now resolves `table-column` and `table-row` styles (and the inline attribute as a fallback), through one shared length converter that handles `cm`/`mm`/`in`/`pt`/`pc`/`px` at 96 dpi instead of three ad-hoc multipliers, and honours `style:use-optimal-column-width`. The `.xlsx` importer gained `sheetFormatPr/@defaultColWidth` and `defaultRowHeight`, treats `bestFit` columns as content-fitted rather than authored, and no longer materializes 16 384 column definitions for a `` run that is really a sheet-wide default. - **The ODS writer emitted widths LibreOffice ignores.** It wrote `style:column-width` straight onto ``, which is not valid ODF; a sheet saved from UltraCanvas reopened with every column back at the default. Column widths and row heights are now written as `table-column` / `table-row` automatic styles referenced by `table:style-name`, so a document round-trips through UltraCanvas with its layout intact. - **Columns the document does not size are fitted to their content.** Every CSV, and any `.ods`/`.xlsx` that leaves a column at the default, now gets that column measured against its widest displayed value instead of clipping it — the bundled demo document has no column styles at all, which is why its headings read "Chargeba" and "Sales (€" before. The fit uses the render context's real glyph advances in each cell's own font, so a bold heading is measured as bold and `1.234,00 €` is measured as ten characters rather than the thirteen bytes the old `length() * 8` estimate counted. A width chosen by the document, by a header drag or by `SetColumnWidth` is marked explicit and left alone. - **There was no way to format cells from the UI.** The engine had alignment, number formats, fonts, colours and merging, but every one of them needed application code to reach. `UltraCanvasSpreadsheetFormatMenu.h` adds the standard formatting menu — alignment and wrap, number-format presets each showing a live sample rendered through the cell formatter itself, increase / decrease decimals, font style and size, a text and background palette, column/row sizing, merge and unmerge — applied to the current selection. The grid opens it on a right-click with no application code (`onCellContextMenu` or `SetFormatMenuEnabled(false)` to override), and `ShowFormatMenuAt` puts it behind a toolbar button. The demo's spreadsheet page gained that button, a line reporting where its column widths came from, and a right-click hint. - **The tree view never drew its connecting lines.** `TreeLineStyle` has always defaulted to `Dotted`, `SetLineColor` has always existed, and `RenderNode` answered both with a comment saying the implementation *would* draw a line from the parent to the current node. Every tree in the framework therefore showed rows floating at an indentation with nothing tying them to their parent — which branch a row belonged to had to be counted out by eye once more than one branch was open. The connectors are now drawn: a vertical line descends from the centre of a parent's expand button, a horizontal stub joins each child's row to it, the line stops at the last child, and the trunks of the ancestors that still have rows below continue through the deeper levels. They are drawn over the row background, so they stay visible on the selected row, and under the expand button, which caps the stub. Dotted lines sit on a shared even-pixel grid so trunks and stubs meet cleanly at every junction, and `TreeLineStyle::NoLine` still turns the whole thing off. - **Rows without children sat one button width left of the rows with them.** The expand/collapse button was only reserved on rows that had children, so a folder and an empty folder at the same depth started their icon and label at different x positions and the tree read as if it had half-levels. The 16px expander slot is now reserved on every row and left empty when there is nothing to expand, so siblings line up whatever their contents; the slot disappears from all rows, as before, when `SetShowExpandButtons(false)` is set. The expand button's own geometry and hit box are unchanged, and both now come from the same constants rather than from numbers repeated in the renderer and the click handler. - **`showRootLines` was a dead field.** The tree view set it to `true` in all three constructors, had no setter for it and never read it, so the top-level rows of a forest — the sections of a tag tree, "Pinned" and "Computer" in a file manager — hung side by side with nothing showing they belong to one list. It is now the switch it always claimed to be, `SetShowRootLines()` / `GetShowRootLines()`: a trunk down the left margin with a stub into every top-level row, drawn exactly like the levels below it. The rows move one indent right to make room, and only while that trunk is actually drawn — it is off under `TreeLineStyle::NoLine`, and on a tree whose root is visible, where the root row already is the trunk everything hangs from, so those trees keep their current left margin to the pixel. - **Rows can carry a check flag.** `SetShowCheckboxes(true)` draws a checkbox on every row, between the expand button and the icon, for the "tick what you want backed up / exported / tagged" case that until now meant building a second list beside the tree. The flags are independent of the row selection, which keeps working as it did. A parent whose subtree is only partly ticked shows Mixed — a filled square rather than a tick, so "some" never reads as "all" — and `SetCheckPropagation(false)` turns the whole subtree logic off for trees where each row stands alone. A click on the box toggles it and leaves the selection where it was, the space bar does the same from the keyboard, and `onNodeCheckChanged` fires once per row that actually moved, so a "7 of 12 flagged" caption can follow it. `GetCheckedNodes`, `SetAllChecked`, `SetNodeChecked` and `SetCheckboxColors` round it out, and a single row can drop its box (`TreeNodeData::showCheckbox = false`) while keeping the slot, so a section header stays aligned with the rows around it. - **The demo's Tree View page shows both.** It advertised a "Checkable Nodes" variant that did not exist. There are now two more examples on the page: a forest whose connectors switch between None / Dotted / Solid from a segmented control, with the root-level trunk on a checkbox beside it, and a folder tree of check flags with a live count and a propagation toggle — the two features above, in the place a newcomer looks for them. #### 2026-09-04 *0.3.98* - **The Filer draws names with or without their file extension, and can put the extension back on the tile.** A file display that ends every name in `.png` spends a third of a narrow tile caption on four characters that the icon already said, and one that simply cuts them off leaves nothing saying what the file is. `UltraCanvasFilerWidget` now separates the two questions (`Display > File extensions`): `SetFileExtensionsInNames(bool)` decides whether the *drawn* name keeps its extension, and `SetExtensionBadge(FilerExtensionBadge)` — `NoneBadge` / `Bar` / `Icon` — decides what a thumbnail tile shows instead, either a strip across the foot of the icon box with the extension in a tag at its right end, or that tag alone in the corner. Both ship off / on as before, so nothing changes for a display that does not ask. Both are display-only: `FilerEntry::name` still holds the real name, so sorting, the Type column, the info bar, the inline rename editor and every file operation work on it exactly as before — a hidden extension cannot be lost by a rename and never has to be re-appended by one. The name is shortened only where its tail really is a file type: `ExtensionTagOf()` answers that for the name rule and the tag rule alike, so `UCDemo-Windows-0.3.27-x86_64` keeps its version, a dot file keeps its whole name, a folder keeps every dot, and none of them gets a tag either. `DisplayNameOf(entry)` hands a host the name as the display draws it. The tag is painted *over* the foot of the icon box rather than under it, so switching it on changes no tile's height and relays out nothing, and the four colours and heights it uses are `FilerStyle` fields (`extensionBarBackground`, `extensionTagBackground`, `extensionTagTextColor`, `extensionBadgeHeight`). Both switches sit in the context menu under `Display > File extensions` and report through the existing `onDisplayFormatsChanged` hook, so an application persists them from the same place it persists the Thumbnails / Detail view switches. `Tests/FilerExtensionDisplayTest` covers the name and tag rules. - **The "Open with" icon cache grew forever.** Handler icons are extracted into PNG files under `%LOCALAPPDATA%\UltraCanvas\openwith-icons` (and `~/Library/Caches/…` on macOS) so the menu, which draws image files, does not re-extract them on every open. Nothing ever deleted one. The key is where the icon came from — an executable's path, a bundle path — so every application the user upgrades, moves or uninstalls leaves behind a PNG that nothing will ever ask for again, accumulating for the life of the account. Each file now carries the day it was last served as its modification time, and the first lookup in a process deletes everything not served for **two weeks**, plus any `.tmp` an interrupted write left behind. Only `.png` and `.tmp` are ever considered; a swept icon that turns out to still be wanted is extracted again. The stamp is rewritten at most once a day, so a context menu that opens all afternoon costs no disk writes, and a clock that was set back reads as fresh rather than expired. - **That retention policy is shared, not copied.** `kIconCacheMaxAge`, `SweepIconCache` and `StampIconCacheFile` are declared in `UltraCanvasFileAssociationsBackend.h` and implemented once in `core/UltraCanvasFileAssociations.cpp` — plain `std::filesystem`, no platform code — so the Windows and macOS backends cannot drift apart on how long an icon lives. - **A folder of pictures could blank the application icons next to them.** The filer's thumbnail cache held every finished picture in one 96 MB budget, and on overflow it did not evict — it dropped *every* finished entry it had and started over. So one video poster frame landing on a full cache erased the whole screenful, and in a folder like a program's install directory, where a few large previews sit beside dozens of executables, the `.exe` and `.dll` icons were the ones that went: they were re-extracted, evicted by the next preview, re-extracted again, and what the user saw was that the icons "stopped showing" and did not come back. The cache now evicts **least recently drawn first**, and only as far as it takes to get back under budget, so what is on screen survives what is scrolling past it. - **Application icons no longer compete with content previews for memory.** They are the file's identity, not a courtesy preview, and they cost a rounding error next to a poster frame — so they now have their own 16 MB budget that nothing else can spend. `UltraCanvasFilerWidget.md` documents both pools and what overflowing one does. - **A shell icon extraction that failed once failed for good.** The slot was marked Failed and never retried, so a single transient refusal from the shell left that executable drawn as a generic EXE glyph for the rest of the session. Extraction now gets up to three tries before the tile settles on its glyph; content decodes, which fail the same way every time, still stop after one. - **The thumbnail workers had never joined a COM apartment.** `SHDefExtractIconW` is a shell call and the shell expects one of its caller; the workers ran without, which is a plausible source of exactly the intermittent per-file failures above (the main thread, which does `OleInitialize`, never saw them). Each worker now holds a `NativeFileIconThreadScope` for its lifetime — multi-threaded apartment, since these threads have no message pump — declared in `UltraCanvasNativeFileIcons.h` and empty on platforms without an extractor. - **Cache byte accounting is now balanced on every path out of a slot.** The old wipe recomputed the total from scratch each time it fired, so nothing needed to subtract; incremental eviction does, and pruning a slot or overwriting one now returns its bytes (and drops any decompressed copy of it) through a single helper, so the counters cannot drift. #### 2026-09-04 *0.3.97* - **Five sibling modules were missing from the demo's "ULTRA OS modules" category.** UltraCloud, UltraCrypt, UltraDatabase, UltraVault and UltraWin all exist in the tree, are registered in `Masterfile_modules.md` and carry their own documentation, but the demo listed only nine modules — so the one place a newcomer goes to see what ULTRA OS is made of showed roughly half of it, and the modules that hold the credentials, the database and the Windows tier were the invisible half. All five are now registered in `Apps/DemoApp/UltraCanvasDemo.cpp`, each opening its module documentation screen (intro, architecture diagram where one exists, full README) like the other doc-only entries. - **UltraVault had no `Docs/Modules/` entry to open.** Its documentation was the design document under `UltraAI/Docs/`, outside the folder the demo copies to its resources directory, so the module could not be shown at all. `Docs/Modules/UltraVault/README.md` now covers it as a module — why credential storage is a system service rather than per-app code, the backend table (memory and encrypted file implemented; libsecret / Keychain / Credential Manager planned), the design rules that make it safe (nothing throws, no passphrase-versus-tampering oracle, secrets wiped on `Shutdown`), the public surface with a worked example, and its consumers — and links the design document for the full argument. - **The dependencies table listed modules the demo did not, and the demo listed modules the table did not.** The in-app *Dependencies & Third Party* screen now carries an "Additional ULTRA OS modules" group for each of the five, in the same order as the tree: UltraCloud (no library of its own — UltraNet, UltraDatabase and UltraVault, plus bundled yyjson for provider JSON), UltraCrypt (libsodium, optional at build time and failing closed without it), UltraDatabase (system SQLite for Stage 1, the networked drivers still planned), UltraVault (crypto through UltraCrypt; native backends planned) and UltraWin (Wine, winetricks and QEMU spawned but never linked, FreeRDP linked, bundled yyjson for QMP — Linux only). `Docs/Dependencies.md` gained the three sections it lacked and the library-link rows behind the new names, so every library in the table is clickable there too, and its UltraVault section moved so both files read in one order. `master_dependencies.yaml` gained the `database:` section for SQLite, which no manifest recorded. - **The "ULTRA OS modules" overview page now names the modules.** Selecting the category itself rendered the ULTRA OS prose and the architecture diagram but never said what sits under it; `Docs/Modules/ULTRA-OS/README.md` now lists all fourteen with a line each, and points at the module registry and the dependencies screen for the detail. #### 2026-09-04 *0.3.96* - **Font definition files can be previewed and read.** Fonts were the one document class the framework consumed but could never show: they went into the text pipeline by family name and never came back out as something a file manager could display, so a `.ttf` was a nameless blob with a generic glyph on it. `UltraCanvasFontFile` (`include/UltraCanvasFontFile.h`, `core/UltraCanvasFontFile.cpp`) reads one as a document instead. `ReadFontFileInfo()` returns the container format, the face count and, per face, the decoded name records (family, subfamily, full and PostScript name, version, copyright, trademark, manufacturer, designer, license and its URL, the font's own sample text) alongside glyph count, units per em and the scalable / fixed-width / kerning / bold / italic flags. `RenderFontSpecimenPixmap()` rasterizes a card carrying a line of the font's own glyphs, fitted to the box it is given. Both go straight at the file with FreeType — no fontconfig, no Pango, no render context, and above all no requirement that the font be installed, which is the whole point: a folder of fonts someone just downloaded has to preview before any of them is. Each call owns its `FT_Library`, so the surface is safe to run concurrently on background threads. The name table stores every string once per platform, encoding and language it was built for; the records are scored and the best one per name id kept, preferring Windows Unicode US-English, and the typographic names (ids 16/17) win over the legacy ones so a split-weight family reads as "Ubuntu" / "Light" rather than "Ubuntu Light" / "Regular". There is no shaping — glyph lookup plus kerning — which is enough for a Latin specimen and is all that is possible without a registered font and a Pango context; a symbol or icon face with no glyph for the sample characters falls back to drawing its own first glyphs rather than an empty card. The default sample follows the shape of the box — "AaBbCc" where it is at least twice as wide as it is tall, "Ag" otherwise — because a six-glyph line in a square tile is fitted by its width and comes out too small to read the letterforms off, which is the whole point of a specimen. - **The filer thumbnails fonts.** `ttf`, `ttc`, `otf`, `otc`, `woff`, `woff2`, `pfa`, `pfb`, `bdf`, `pcf`, `fon` and `fnt` are now a file category of their own (`FilerFileCategory::Font`, with its own type names and colour) and a preview kind of their own (`FilerPreviewType::Fonts`), rendered on the same background workers as photos and video poster frames and carried by the Display ▸ Thumbnails and Display ▸ Detail view switch sets released just before it — per kind and per format, on by default like the rest. Fonts is the mirror image of the Audio kind those switches introduced: Audio has a detail view and no thumbnail producer, Fonts has a thumbnail producer and, so far, no viewer. Like PDF pages and 3D models the specimen is only drawn from about a 40 px box up, so the icon column of a Details row keeps the type glyph instead of showing a smear of ink. `MediaFormatCategory::Font` puts the same formats in the framework-wide inventory, so a file dialog can build a font filter from `GetLoadExtensions(MediaFormatCategory::Font)`; they are listed as loadable there and stay excluded from `CanImagePipelineLoad()`, so a font is never handed to a raster decoder by mistake. - **A font file can be made usable for text rendering without installing it.** `UltraCanvasApplicationBase::RegisterFontFile()` adds a file's faces to this process by name — FontConfig on Linux, Android and WASM, GDI `AddFontResourceExW(FR_PRIVATE)` plus FontConfig on Windows (Pango is pinned to its FontConfig backend there), CoreText process scope on macOS — with `IsFontFileRegistered()` and `GetRegisteredFontFiles()` alongside it. Until now the only font registration the framework had was the hardcoded bundled list in `LoadBundledFontsNative()`, so an application that shipped a font of its own, or opened a document that embedded one, had nothing to call. Pair it with `ReadFontFileInfo()` to learn the family name to ask for. Registration is process-private, and permanent for the life of the process: neither FontConfig nor the framework can withdraw one file's faces from a running text stack without discarding every application font, so there is deliberately no unregister. A successful registration is followed by the new `RefreshFontConfiguration()`, which rebuilds the FontConfig FontSet and signals Pango's default font map with `pango_fc_font_map_config_changed()` so the family resolves in the very next layout rather than only in windows created afterwards — the reason the build now links `pangoft2` (a module of Pango itself, so no new dependency; where it is absent, and on macOS, the default font map is dropped instead and surfaces created later pick the font up). Covered by `Tests/FontFileTest.cpp`, which runs against the framework's own bundled Ubuntu faces and so needs no installed font. #### 2026-09-03 *0.3.95* - **Linking UltraCrypt into an application that also links a shared libultracanvas failed to link on Windows.** The string helpers (`Trim`, `Split`, `ToLowerCase`, `StartsWith`) and the Base64/Base32 codecs shared one translation unit in `UltraCanvasTextUtils.cpp`, and therefore one object file. The UltraCanvas library links that archive publicly, so a shared core exports the string helpers; UltraCrypt links it too, for `Base32Decode` alone. Pulling that one codec out of the archive extracted the whole object — the string helpers with it — and those collided with the same symbols already exported by the shared core: `multiple definition of UltraCanvas::Trim`, fatal on PE/COFF. - `Base32` now lives in its own translation unit and its own static library, `UltraCanvasBase32`, which UltraCrypt links instead. The split is by **link-time home, not by kind**: the string helpers *and* Base64 stay with `UltraCanvasTextUtils`, because UltraNet calls Base64 and UltraNet is absorbed into the shared core, so those symbols must come from the core on every platform. Base32's only consumer is UltraCrypt, which is deliberately UI-free, so it is the one piece that belongs apart. Nothing the shared core exports is now reachable from an archive on UltraCrypt's link line. Declarations stay in `UltraCanvasTextUtils.h` and the code is moved verbatim, so no caller changes. - This was latent rather than new: nothing previously put UltraCrypt and a shared core on one link line in a way that forced the object to be extracted. It surfaced when UltraMail's credential vault moved to UltraVault (which links UltraCrypt), breaking both `UltraMail` and `EmailCleaner` — the latter only because it links the mail engine. #### 2026-09-03 *0.3.94* - **The filer decides per file format what gets a thumbnail — and, now, what gets a detail view.** `Display > Preview` gated thumbnails only, in eight coarse kinds (nine now — see the next entry), and nothing gated the detail pane a host opens beside the display: that pane asked `UltraCanvasMediaViewer::IsSupportedMedia()` alone, so a CorelDRAW or Xara file that had a thumbnail still had no preview, and an EPS had neither. The submenu is now `Display > Thumbnails`, `Display > Detail view` sits beside it with the same eight switches, and each set additionally takes **per-format exceptions** — one extension switched off while its kind stays on. New API: `SetThumbnailKind(s)` / `IsThumbnailKindEnabled` / `GetThumbnailKinds`, the same for the detail view, `SetThumbnailFormatEnabled` / `SetDetailViewFormatEnabled` (plus the `GetDisabled…Formats` / `SetDisabled…Formats` pairs an application persists), `ThumbnailEnabledFor(entry)` and `DetailViewEnabledFor(entry)`. The old `SetPreviewType(s)` / `IsPreviewTypeEnabled` / `GetPreviewTypes` are replaced by the thumbnail half of that set — same enum, same bit values. `GetPreviewableFormats()` reports every format the switches address (its extension, readable label, kind, and whether this build can produce a thumbnail for it at all), which is what a settings page builds its list of files from without repeating the widget's tables; `PreviewTypeLabel()` and `AllPreviewTypes()` give it the menu wording and order. `formatListMenuProvider` lets the host hang its own entry into those lists at the end of both submenus, and `onDisplayFormatsChanged` fires whenever any of the four sets changes, whoever changed it. - **Every format the FileLoader knows is in those lists.** The kinds skipped audio entirely — `FilerFileCategory::Audio` mapped to no preview kind — so the mp3s a build can play appeared in neither list and their detail pane could not be switched off. `FilerPreviewType::Audio` closes that: the nine kinds now cover all seven `MediaFormatCategory` values, so every format the FileLoader inventory reports is filed under exactly one of them. `FilerFormatListTest` asserts precisely that — present, and under the kind its media category belongs to — for every extension and alias the inventory reports. Audio has no thumbnail producer (nothing here reads cover art), so its rows report themselves unsupported; its switches govern the detail view, where a host's viewer does play the file. - **The "can this build render it" answer stopped over-promising.** Text, Docs and Spreadsheets claimed a preview for every format in them, including the ZIP and record containers no reader here unpacks (xls, epub, mobi, prc, azw, azw3, fb2.zip). Worse than the wrong claim: the extractor did read them, and since a head-of-file read stops at the first NUL, an epub drew a miniature page holding `PK` instead of keeping its type glyph. Both now go through one answer, `TextPreviewReadable()`. - **EPS, PostScript and old Illustrator files thumbnail from the preview they carry.** Nothing here rasterizes PostScript — that needs an interpreter, and a libvips build with the delegate is the exception, not the rule — so an `.eps` showed the same bare glyph in every build. `UltraCanvasEmbeddedPreview` now reads both preview mechanisms the EPSF specification defines: the TIFF section of a DOS EPS binary header, and the hex-encoded EPSI preview in the comment block, which is converted to a greyscale PGM (its samples are ink coverage, so 0 is white). A PDF-compatible `.ai` — every Illustrator file since CS2 — is recognised from its `%PDF` signature and rendered by the PDF plugin like the document it is. `EmbeddedPreviewTest` covers both mechanisms against files it writes itself, including the polarity of the greyscale it produces. - **The media viewer shows a vector document it cannot rasterize.** A new `MediaKind::Vector` covers Xara (`.xar/.web/.wix`), CorelDRAW (`.cdr/.cdt`) and PostScript (`.eps/.epsf/.epsi/.ps/.ai`): `IsSupportedMedia()` accepts them, and `LoadCurrent()` shows the file rasterized where the image pipeline can do it and the embedded preview bitmap otherwise — the same treatment a `*.ucd` container already got. A file that carries no preview says so in the info bar rather than leaving an empty pane. #### 2026-09-03 *0.3.93* - **The splash screen can credit the toolkit the host is built on.** `SplashScreenConfig` grew an attribution block — `attributionText`, `attributionImagePath` and `attributionName` — drawn between the version line and the website link, so an application whose UI is UltraCanvas but whose branding is its own can say so: "GUI by" / the UltraCanvas hexagon / "Ultra Canvas". The Ladybird port is the first caller; the alternative was the port painting its own borderless window, which is exactly the hand-rolled UI the house rules exist to prevent. Each of the three fields is independent and each is omitted when empty, so a splash that sets none of them renders exactly as it did before. - **The splash can show a release date under the version.** `versionDate` is a new `SplashScreenConfig` field, drawn on its own line under the version in the same size and colour, and `cmake/UltraCanvasVersion.cmake` now hands out the date to put in it: alongside every `_VERSION` it sets `_VERSION_DATE`, taken from the `YYYY-MM-DD` on the same changelog line the version was already parsed out of. That is the date the release shipped, which is the one worth showing — a `__DATE__` build stamp gives two builds of one release two different dates, and makes a bug reported against "0.1.0 of 3 September" impossible to identify. No changelog is touched and no version moves; the module simply keeps the half of the line it used to throw away. - **Splash logo sizes are configurable.** `logoSize` (default 250) and `attributionLogoSize` (default 90) replace the hard-coded 250 px logo box. Both are square boxes the image is fitted inside — `ImageFitMode::Contain` is unchanged, so a non-square logo still keeps its aspect ratio, and the default reproduces the previous layout. - **`UltraCanvasSplashScreen` now has a component doc** — `Docs/UltraCanvas/UltraCanvasSplashScreen.md`, and a row in the UI element catalogue. It was the one startup-time window with neither, which is how the two-phase startup it wants (silent work before `Show()`, anything that opens a window in `onSplashClosed`) stayed folded into Texter's `main.cpp` instead of being written down where the next caller would find it. - **New shared asset: `media/appicon/Ladybird.png`,** the Ladybird mark on its gradient disc, at 512 x 512 with transparency outside the disc so it serves as a window icon as well as a splash logo. It is generated rather than hand-drawn — `scripts/make_ladybird_icon.py` holds the geometry, stroke weight and gradient stops — so it can be re-cut at another size without tracing it again. See `Docs/Ladybird/SplashScreen.md`, which is how the port wires the splash up: the assets it ships, and where the call goes in its startup. - **`Docs/Ladybird/` is in the LLM docs corpus.** It held only a changelog, which `generate_llms_txt.py` excludes by name, so the directory was invisible to `llms.txt` — and its first piece of developer documentation would have been too. It is on the `APP_DOC_DIRS` allowlist now. #### 2026-09-02 *0.3.92* - **The filer showed the type glyph instead of every thumbnail whenever the libvips capability probe came back empty.** Before decoding anything the widget asks `UltraCanvasSupportedFormats::CanImagePipelineLoad()` whether the image pipeline handles the extension at all, and that answer is cached per extension for the life of the process. It was assembled purely from runtime probes, so any build where the probe could not speak answered *no format loads* — and every picture in every folder silently fell back to its coloured "PNG"/"JPG" glyph while `UCImage::Get()` went on decoding the very same files perfectly for the viewer and the preview pane. Two ways the probe went quiet, both of which leave a working libvips behind: `VIPS_INIT` returns non-zero on an ABI mismatch between the headers and the installed library, and the loader suffix list — collected once from the registered `VipsForeignLoad` subclasses — was latched even when it came back empty, which it does when the loader classes are not registered yet. The suffix list is now only cached once it holds something (and is collected under a lock), and the answer starts from the format table the `UCImage` load path implements, so a probe that cannot contribute can no longer subtract: worst case one decode is attempted and fails, instead of the picture disappearing with nothing to show why. - **A thumbnail that produces nothing now says so in the log** — `UltraCanvasFilerWidget: no thumbnail produced for ""`. A failed decode leaves exactly the tile a switched-off preview kind leaves, which is why the regression above could look like a display setting rather than a fault. - **Vector graphics: the filer now covers the same formats as the FileLoader.** It classified only `svg`, `eps`, `cdr` and `xar` as vector files; everything else the vector plugins load or write — `svgz`, `epsf`, `ps`, `ai`, `cdt`, `cmx`, `ccx`, `web`, `wix`, `emf`, `wmf`, `dxf`, `dwg` — was listed as a nameless "file", outside the Vector category, its colour, its grouping and its Display > Preview switch. All of them are named now, and an extension no table in the widget knows is looked up in the runtime format inventory (the one the FileLoader's dialogs are built from) before it is written off, so a format arriving with a plugin the application registers is classified without the widget having to be taught about it. - **`set-version.sh` and the configure-time staleness warning now cover UltraFiler's Windows resource files** as well as UltraTexter's, and the script derives both from their changelogs instead of hard-coding one app's two-component version format. The `.rc` and `.manifest` of those two are the only literal copies of a version left in the tree; everything a binary displays comes from the `_VERSION` definitions `cmake/UltraCanvasVersion.cmake` reads out of the changelogs. What UltraFiler did with its copy is in its own changelog (UltraFiler 1.17.1). - **Xara and CorelDRAW files show a real thumbnail again.** Xara documents (`.xar`, `.web`, `.wix`) carry a GIF/JPEG/PNG preview among the first records of the file head and the ZIP-based CorelDRAW documents (`.cdr`, `.cdt`, X4 and newer) carry one as `previews/thumbnail.png`; both decode like any bitmap, which is how these formats get a thumbnail at all without a renderer that works off the UI thread. The XAR half existed and was lost in a file-level overwrite (`579a55c`); it is back, generalised over both families and moved out of the widget into `UltraCanvasEmbeddedPreview.h` (`FormatCarriesEmbeddedPreview`, `ExtractEmbeddedPreviewBytes`) so it is reusable and testable — `Tests/EmbeddedPreviewTest.cpp` runs it over the repository's own sample documents. `svgz` rasterizes through the SVG renderer like `svg`. `emf`, `wmf`, `dxf`, `dwg`, `ai` and the older RIFF-based `.cdr` still keep their glyph: there is no renderer for them that a background worker can drive. #### 2026-09-02 *0.3.91* - **Filer tile captions read PascalCase names as the words they are made of.** `UltraCanvasTexter.exe` under a thumbnail wrapped as *UltraCanva* / *sTexter.exe* and `UltraCanvasDemo.exe` as *UltraCanva* / *sDemo.exe*: with no separator anywhere in the name, the line ran to the pixel the name stopped fitting, one letter past where the next word starts. An upper-case letter that opens a new word — one following a lower-case letter or a digit, or the last capital of an acronym before a lower-case letter (`PDF` / `Viewer`) — is now a break opportunity like a space: the line ends before it (*UltraCanvas* / *Texter.exe*), a word missing its last letter or two is pulled up whole into the caption's slack as before, and a caption with room for more lines shows the words one per line (*Ultra* / *Canvas* / *Texter.exe*), balanced like any other name. `TextWrapping::Options:: camelCaseBreaks` / `FilerStyle::captionCamelCaseBreaks` (on by default) switch the rule off; the "content beats typography" re-break that rescues a name the tidy breaks would cut short drops it along with the word rule, so a name never shows less of itself for the sake of a capital. ASCII letters only. `Tests/TextWrapTest.cpp` covers the two reported names, the acronym rule and the boundary table. - **The Filer widget wraps its captions through `UltraCanvasTextWrapping.h` again.** The *Ladybird updates* commit of 2026-08-27 carried a stale copy of `UltraCanvasFilerWidget.cpp` that put the pre-0.3.79 wrapper back — the header still declared `CaptionWrapOptions()` / `CaptionOverflowSlack()`, but nothing defined them, and the whole-word rule, the overflow slack and the content-beats-typography re-break of 0.3.79 were not in effect in the widget (the header and its test suite were untouched). The widget binds to the shared wrapper as 0.3.79 intended, so the rules above — and the new one — are what the tiles draw. #### 2026-09-01 *0.3.91* - **New: `UltraCanvasVolumeMonitor`** (`UltraCanvas/{include,core}/UltraCanvasVolumeMonitor.h/.cpp`, backends under `OS//`) — the mounted volumes of the machine, and a notification when that set changes. `ListMountedVolumes()` / `ListVolumeRoots()` are now the framework's single volume enumeration, and the monitor reports a mount or unmount through the operating system's own channel: `poll()` on `/proc/self/mountinfo` (Linux/BSD), `WM_DEVICECHANGE` on a hidden top-level window (Windows — a message-only window does not receive the broadcast), and `NSWorkspace` mount notifications (macOS). Where no backend exists the monitor polls the volume list on a background thread, so `Start()` succeeds everywhere and callers need no fallback of their own. Documented in `Docs/UltraCanvas/UltraCanvasVolumeMonitor.md`; new test `Tests/VolumeMonitorTest.cpp`. - **Drive lists were wrong on three platforms.** `ListDriveRoots()` (the path strip's *Computer* dropdown) and UltraFiler's folder tree enumerated volumes separately and disagreed: one scanned `/media`, `/mnt` and `/Volumes`, the other `/media` and `/mnt`, and **neither looked at `/run/media`** — where udisks2 mounts on Fedora, RHEL, Arch and openSUSE — so on those systems a USB stick was invisible in both, restart or not. macOS mounts every removable volume under `/Volumes`, which the tree never scanned, so a stick never appeared there at all. `ListDriveRoots()` also offered any directory it found as a drive, including the empty mount-point folder an unmount leaves behind. Both now call the one enumeration, which covers `/media`, `/run/media` (each also one level down, for the per-user directory udisks creates), `/Volumes` and `/mnt`, and tests each candidate against the platform's mount table (`/proc/self/mounts`, `getmntinfo()`, `GetLogicalDrives()`) as well as the device-differs-from-parent check — the table sees a bind mount from the same filesystem, which the device check cannot. - **`UltraCanvasFolderWatcher` reports a watch that dies on its own.** New optional third argument to `Watch()`: a failure callback, fired at most once and never as a result of `Stop()`. A watch ends silently when its volume is unmounted, the share drops or the handle is invalidated — on Linux the kernel retires the descriptor with `IN_IGNORED`/`IN_UNMOUNT`, on Windows the overlapped read fails — and the backends parked on it forever while the caller kept believing it was watched. Both backends now detect it and report. - **`UltraCanvasFilerWidget` recovers from a lost watch.** It falls back to the fingerprint worker for that folder instead of quietly ceasing to notice changes — pulling a USB stick while one of its folders was open left the view frozen on a listing that no longer existed until the user navigated away. It keeps polling the folder while it is gone, so the same stick plugged back in re-lists itself. `IsFolderWatchNative()` reports `false` after the fallback. - **`UltraCanvasTreeView::RemoveNode()` left dangling pointers.** It destroys the node's whole subtree but only dropped the view's selection / hover / focus pointers when they aimed at the named node itself, so removing a populated node — an unmounted drive, a deleted folder that had been expanded — and then hovering the tree dereferenced a child that no longer existed. #### 2026-09-01 *0.3.90* - **UltraCanvasFilerWidget: a file list can now grow while it is produced.** `AppendToFileList(paths)` adds paths to the list already on display, stat-ing only the new ones and leaving the scroll position and the selection where they are; before, the only way to extend a result list was to hand the whole grown list back to `ShowFileList()`, which re-stat-ed everything already listed once per batch and jumped the view back to the top. This is what lets a search show its matches while it is still walking the disk — UltraFiler's sub-folder scan does exactly that (see [`Docs/UltraFiler/CHANGELOG.md`](../UltraFiler/CHANGELOG.md) 1.16.0). The per-entry finishing pass every listing gets (weight, attribute letters, the info column) moved into `DecorateEntry()` so both paths share it. - **Windows: a C++ exception thrown while handling an event no longer kills the process.** The window procedure runs inside a callback the kernel dispatched, and on x64 the unwinder cannot walk back across that boundary, so an exception thrown by any event handler below it (a click on a folder, a hover, a key) never reached the application's `try`/`catch` around `Run()`. Instead the process died, reported by the crash filter as `STATUS_BAD_FUNCTION_TABLE` (0xC00000FF) in ntdll or as the bare GCC throw code (0x20474343) in KERNELBASE, with the error text lost; UltraFiler showed that dialog twice on opening one particular folder and then closed. `StaticWndProc` now catches what escapes `ProcessWindowMessage` / `HandleMessage` and hands it to the new `ReportWindowsEventException()` (`UltraCanvasWindowsDiagnostics.h`): the event is abandoned, the error is written to the log every time and shown in a message box once per process — the same outcome the same exception has on the other platforms. The crash reporter names the two codes above (plus `BAD_STACK`) and, for any escaped C++ exception, says that the address shown is the unwinder rather than the throw and how to capture the error text with `ULTRACANVAS_DEBUG_LOG`. - **Filer widget: a throwing decoder no longer ends the process.** Nothing catches an exception that leaves a `std::thread`, and the filer's four workers (thumbnails and text previews, folder statistics and media probes, the folder fingerprint, the listing prefetch) each open files the user pointed at. Every job now runs under a guard (`RunGuarded`) that logs the job, the file and the error text and lets the worker go on: the file costs its thumbnail, statistic or fingerprint, not the user their file manager. See UltraFiler 1.15.1, which does the same for its own threads. #### 2026-09-01 *0.3.89* - **UltraFiler: the Home folder's curation is a setting now — Settings > Display > Home folder.** *Show all content* lists every subfolder of the profile (in the tree the main folders keep their own icons, and a redirected one is listed once, by its real path); *Show only predefined folders* is the curated view — Desktop, Documents, Downloads, Music, Pictures, Videos and nothing else. One setting governs the folder tree's Home entry and the file display of the home folder alike, applies live to every open tab and the folder preview, and is stored as `display.home.content` (`all` / `predefined`). The defaults differ by platform: curated on Windows, whose profiles carry a dozen system folders ("3D Objects", "Saved Games", the sync clients); show-all on Linux and macOS, where the home folder is the user's own. Display > Hidden files in the file display still reveals everything regardless. #### 2026-08-31 *0.3.88* - **VirtualFS: nested archives no longer spill to a temp file.** Reading `/outer.zip/inner.7z/docs/report.txt` extracted `inner.7z` to the temp directory first, because `IVirtualFSProvider::Open()` only accepted a real path — so every nested traversal left the inner archive's decompressed bytes on disk, including archives decrypted from a password-protected parent, which landed there as plaintext. New optional `IVirtualFSProvider::OpenFromMemory()` takes the bytes directly; the libarchive provider implements it over `archive_read_open_memory()` and advertises the new `VirtualFSCapability::MemoryOpen`. `VirtualFSManager::OpenNestedArchive()` prefers it and falls back to a temp file only when a provider returns `NotSupported`, so providers that need a real path keep working unchanged. libarchive streams are forward-only, so the provider reopened the archive from its path in seven places; those are now a single `Impl::NewReadHandle()` that opens from either source, which is what makes the memory path apply to listing, extraction and validation rather than only to the first read. An archive opened from memory cannot be modified — rewrites go through a temp file and rename, which needs a real path — so `RewriteArchive()` reports `NotSupported` for one. Also fixed: a failed temp write left the partial file behind, and a temp file was written before checking that any provider could handle the format. New regression test `Tests/VirtualFSNestedMemoryTest.cpp` covers nested reads, listing, existence and the cached-provider second read, asserting the temp directory stays empty throughout. - **VirtualFS: OS-visible RAM discs** (`VirtualFS/VirtualFSRamDisk.h`). A disc created here is a real mount point — `fopen()`, other processes and the platform file manager all reach it — not an in-process structure. VirtualFS drives the facility each platform already provides rather than shipping a driver: a `0700` directory on the `/dev/shm` tmpfs on Linux, `hdiutil attach ram://` plus `diskutil erasevolume` on macOS, and the ImDisk driver on Windows when it is installed. Windows ships no RAM disc facility of its own, so the back end **detects** ImDisk instead of depending on it and degrades to a `%TEMP%` directory (wiped on destroy) when it is absent or the process is not elevated. That fallback is never disguised: every disc reports its backing, and `VirtualFSRamDisk::IsTrueRam()` tells callers whether bytes can reach persistent storage, so code holding decrypted content can refuse it. Discs are private to the calling user, and disc names are validated against a strict character set before becoming part of a real path so one cannot escape the mount root. `VirtualFS_ListRamDisks()` finds discs left behind by a process that died before destroying them — ImDisk discs included, located by the volume label their name is stamped into — which makes a start-up sweep possible. `VirtualFS_UseRamDiskForTemp()` points the manager's temp directory at a disc; destroying a disc still serving as the temp directory moves the manager off it first, so later temp writes do not fail against a mount that no longer exists. New test `Tests/VirtualFSRamDiskTest.cpp` verifies the disc is reachable through plain stdio, that a Linux disc really is tmpfs rather than a plain directory, the `0700` permissions, name and size validation, duplicate refusal, temp redirection and its unwind, and idempotent destroy. The Linux back end is verified end to end; the macOS and Windows back ends compile for their targets, but their `hdiutil`/`diskutil`/`imdisk` invocations need those platforms to exercise. - **EmailCleaner 0.2.0 — acting on a selected block** (block / unsubscribe / move to Trash). The app keeps its own changelog now: [`Docs/EmailCleaner/CHANGELOG.md`](../EmailCleaner/CHANGELOG.md). Two framework changes below carry it. - **`UltraCanvasTreeMapElement` responds to clicks.** It has always published `onNodeSelect`, `onNodeDoubleClick` and `onNodeRightClick`, but nothing ever fired them: the chart base turns a mouse press into drag tracking only, and the element had no `OnEvent` of its own. It now selects the block under the pointer on a left click, drills into it on a double click, reports a right click, and clears the selection on a click into the background. Hit-testing was fixed to match: it tests what `RenderChart` draws — the current level's children, laid out and leaf or not — instead of recursing for leaves whose bounds no layout pass had ever set, which is why a grouped treemap could not be clicked at all. This is what makes EmailCleaner's sender map a navigation surface. Version 1.1.0. - **CI builds the EmailCleaner suite on every row and runs it on Linux** (`ULTRACANVAS_BUILD_EMAILCLEANER_TESTS=ON` in both configure steps). It is headless, so building it on macOS and Windows costs seconds and gates what those rows uniquely exercise: the STATIC UltraCanvas link. The Linux `ctest --output-on-failure` step then runs it with everything else. - **Every application keeps its own changelog now**, so an app no longer moves when the framework releases. `Docs//CHANGELOG.md` for AnchorPoint, EmailCleaner, UltraAI, UltraAuthenticator, UltraFiler, UltraMail, UltraSocial and UltraViewer, plus `Docs/Modules/UltraWin/CHANGELOG.md` for UltraWin — joining Texter, UltraCleaner and Ladybird, which already had one. `cmake/UltraCanvasVersion.cmake` reads the first line of each into `_VERSION` (`_DOT4` / `_COMMA4` too) through one `_ultracanvas_declare_product()` line apiece, and re-runs configure when any of them changes. DemoApp deliberately stays on this file: it is the framework's showcase and its artefacts are named `UCDemo-` from this changelog, so a second number for it would be the duplication the module exists to prevent. Only EmailCleaner's entries were moved (see 0.3.87 and the top of this entry); everything else stays where it was published, because this file is the record of what shipped in each framework release and describing one change in two files under two versions is what the rules here forbid. - **`UltraMailEngineTests` links again in a static build.** `libultranet.a`'s MIME parser calls `UltraCanvas::Trim` / `Base64Encode` / `Base64Decode`, and GNU ld scans each archive once in place, so the core library has to *follow* UltraNet on the link line — through `UltraMailEngine` it came first, and the target failed with a page of undefined references to those three helpers. `Tests/UltraMail/CMakeLists.txt` now names `UltraNet` and the core library explicitly after the engine, as the EmailCleaner suite already did. A no-op in a shared build, where UltraNet is absorbed into the `.so`. 51 tests pass. - **Demo app, Menu page: the context menu now opens on right-click.** The first element on the page ("Right-Click for Context Menu") wired both the menu and the "wrong button" popup to `onClick` and told them apart by inspecting `GetCurrentEvent().button`. `UltraCanvasButton` never routes a right-click there: it activates on the left button only and hands the right button to `onContextMenu`, so the `UCMouseButton::Right` branch was dead code and no menu ever appeared. The menu is now opened from `onContextMenu` (at the window coordinates it passes) and `onClick` keeps the left-click reminder. The list items further down the page were unaffected — `UltraCanvasLabel` reports every mouse button through `onClick`. - `Docs/UltraCanvas/UltraCanvasButtonExamples.md` now documents `onContextMenu`, `onToggle` and `onSecondaryClick`, and states that `onClick` is left-button only, with the right-click wiring spelled out. - **Menu separators have room to breathe.** `MenuStyle::Default()` set `separatorHeight` to 1, and `RenderSeparator` centres a 1px line in that row, so the line touched the items above and below and read as a hairline rather than a divider. The row is now 7px. Texter's menu bar and UltraFiler's context menus use `Default()` without overriding it, so they gain the clearer grouping too. - **`MenuStyle::Dark()` and `Flat()` now derive from `Default()`.** They were built on a bare `MenuStyle`, so everything they did not set came from the struct's member defaults instead: item height 28 rather than 24, left padding 4 rather than 8, corner radius 4 rather than 0, a drop shadow, the default font size and `separatorHeight` 8 — the same menu changed shape, not just colour, when it changed theme. `Dark()` also gets border, pressed, disabled, shortcut and separator colours suited to a dark background. The demo's Dark and Flat menus, which had no separator at all, now show one. #### 2026-08-30 *0.3.87* - **New application: EmailCleaner** (`Apps/EmailCleaner`, target `EmailCleaner`, `BUILD_EMAILCLEANER`) — a mailbox analysed into a map of who sends what, when. Described in the app's own changelog as **EmailCleaner 0.1.0**: [`Docs/EmailCleaner/CHANGELOG.md`](../EmailCleaner/CHANGELOG.md). This release carried no framework changes of its own. #### 2026-08-29 *0.3.86* - **UltraFiler's folder tabs moved to the top of the window.** The tab strip was inside the split's folder pane, so it started to the right of the folder tree, two toolbars down. It is now the topmost bar of the window, full width, browser style: the tabs name the folders, and the navigation row, the command bar and the folder display below them all act on whichever tab is selected. - **The "+" that opens a tab sits at the end of the tab list**, where a browser puts it, instead of being the first icon of the navigation row. That icon is gone from the toolbar; `UltraCanvasTabbedContainer`'s own new-tab button (`SetShowNewTabButton` / `NewTabButtonPosition::AfterTabs` / `onNewTabRequest`) does the work, so the button follows the tabs as they are added, closed and reordered. Clicking a tab while the History or Favorites view is up now returns to the folder display, since the strip stays visible over both. - **`UltraCanvasTabbedContainer` can detach its pages from its tab strip:** `SetContentHost(container)` moves the tab contents into a container anywhere else in the element tree, which then sizes them with its own layout, and leaves the tabbed container as nothing but the tab bar (`GetContentHost()`, `IsContentDetached()`; pass `nullptr` to take the pages back). Pages already added move with the call, so the host can be set before or after the tabs. This is what lets a tab strip stand apart from the pages it switches — the layout every browser has and UltraFiler now uses — instead of the strip and the content having to be one block. `Docs/UltraCanvas/UltraCanvasTabExamples.md` documents it, along with the new-tab button, which had no documentation at all. #### 2026-08-29 *0.3.85* - **New `UltraCanvasHardwareInfo`: the framework can now describe the machine it runs on.** One read-only capture returns the CPU (cache sizes per level and instance, hybrid performance/efficiency core tiers, instruction sets, temperature and load), the GPUs, the NPU or other AI accelerator, memory down to the individual module (type, MT/s, manufacturer, part number, slot), every drive with its bus, physical connector ("PCIe 4.0 x4 (NVMe)", "SATA 6.0 Gbps", "USB 3.1 Gen 2 (10 Gb/s)"), on-drive cache, temperature and mounted volumes, every network interface including the Wi-Fi association (SSID, access point, band, channel, signal, rate), the USB controllers and everything plugged into them, and the Bluetooth adapters with their live connections. `HardwareQuery` selects categories, because probing costs differ by orders of magnitude; `RefreshSensors()` re-reads only the live values into an existing snapshot and never adds or removes a device, so a monitor loop keeps its indices. A value the platform will not give up never becomes a zero: the reason lands in `HardwareSnapshot::warnings` in words a user can act on ("drive cache size needs ATA IDENTIFY on the block device, which requires root"). - **Identifiers are masked by default.** Serial numbers, MAC addresses and BSSIDs keep only their tail (`**********3456`, `**:**:**:**:34:56`) unless a caller deliberately opts out — a hardware panel is exactly the screen that gets photographed or pasted into a bug report. - **New `UltraCanvasHardwareInfoPanel` element** — a drop-in "System information" view for a settings screen, an about box or an ULTRA OS control panel. It is an `UltraCanvasColumnsTreeView` filled from the report, so none of it is hand-painted; `RefreshSensors()` writes the new numbers into the rows that are already there, leaving the user's expansion, selection and scroll position untouched. - Probes are per platform behind an internal backend header, and introduce no new third-party dependency: Linux reads procfs and sysfs (hwmon and thermal zones for temperatures, DMI for memory modules, the wireless extensions for Wi-Fi, `/sys/class/accel` and PCI class 12h for NPUs); Windows uses documented Win32 only — registry, `GetSystemFirmwareTable` for SMBIOS, storage IOCTLs including the Windows 10 temperature query, IP Helper, the WLAN API, SetupAPI and the Bluetooth API, with no COM or WMI; macOS uses sysctl and the IOKit C API. Platforms with no native probe (WASM, Android, the BSDs) link a fallback that reports what the C++ runtime knows and says what it cannot. - The module is deliberately separate from **IODeviceManager**: that one *operates* peripherals (connect, configure, scan, print — handles, protocol drivers, a device lifecycle), this one only *describes* the host and holds nothing. See `Docs/UltraCanvas/UltraCanvasHardwareInfo.md`. - New `HardwareInfoTests` (`ctest -R HardwareInfoTests`): formatters, masking, the query selector, report shape, and the invariants a live capture must hold on the machine it runs on. - **The CPU line in an ILLEGAL_INSTRUCTION crash now names the feature that is actually missing.** A field report had an AVX2-capable Ryzen 5 5500U fault on `VGF2P8AFFINEQB` while the reporter said `[SSE4.2 AVX AVX2]` — every feature it knew how to print was present, and the one that mattered was not on the list. The cause is `IsProcessorFeaturePresent`: it has PF_ constants for a handful of extensions and none for GFNI, VAES or VPCLMULQDQ, which are the ones a `-march=native` build picks up without needing AVX-512 at all. Detection now reads CPUID directly (leaf 1, leaf 7 subleaf 0, leaf 0x80000001) and prints those three alongside FMA, BMI2, AES, SHA and the AVX-512 family. - The summary also names the **highest x86-64 psABI level the machine satisfies** — `(x86-64-v3)` — and the crash text now quotes that level back as the flag to build with. `-march=x86-64-v` means exactly that feature set, so it is a flag the builder can paste rather than a vague "lower the baseline". GFNI, VAES, VPCLMULQDQ and SHA deliberately do not raise the level: they belong to no level, which is precisely why no `-march=x86-64-vN` will emit them and only `-march=native` drags them in. - `Docs/UltraCanvas/UltraCanvasWindowsDiagnostics.md` corrects guidance that was actively misleading: it previously said a `C4`/`C5` prefix means "VEX, i.e. AVX/AVX2". VEX also encodes GFNI, VAES, VPCLMULQDQ and BMI, so a CPU can hold every feature in the printed list and still refuse a VEX instruction — exactly what happened here. The section now shows how to decode the bytes with `objdump` instead of guessing from the prefix, works the reported case through, and adds the opt-in-extension cause to the two it already covered. #### 2026-08-28 *0.3.84* - **Spell checking is now switched on and usable in UltraTexter.** The service and the `UltraCanvasTextArea` integration already existed, but nothing in the application ever turned them on, so no user ever saw a squiggle. UltraTexter now has **Edit → Spelling** with a *Check Spelling* toggle, a *Dictionary* list of the installed languages and *Recheck Document*; both the toggle and the chosen dictionary persist in `config.ini`. The backend is loaded lazily, the first time checking is turned on, so a launch without it costs nothing. - **The editor has a real context menu.** Right-clicking inside the text used to do nothing but move the caret — only the tab bar had a menu. It now opens Undo / Redo / Cut / Copy / Paste / Select All with live enabled state, plus the *Spelling* submenu, and when the click lands on a flagged word the suggestions, *Add to Dictionary* and *Ignore* sit at the top of that same menu instead of in a competing popup. - **Markdown documents no longer squiggle their own markup.** A new scanner (`Apps/Texter/UltraCanvasMarkdownSpellRanges.h`) keeps fenced and indented code, inline code spans, link and image targets, autolinks, inline HTML, math and YAML front matter out of the check, while the prose around them — link text and image alt text included — is still checked. - Added `UltraCanvasTextArea::onContextMenu`, so an application that already shows its own editor menu gets the right-click before the built-in suggestion popup and can splice the suggestions into its own menu. - Added `UltraCanvasTextArea::onPrepareSpellCheck`, called with the exact text about to be checked. `SpellCheckOptions::shouldSkipRange` works in byte offsets, so a hook built once went stale on the first edit; it can now be rebuilt per check from the text that check will actually run on. - Added `UltraCanvasTextArea::IsPositionInsideSelection()`. A right-click now moves the caret to the click unless it landed inside the selection, so **Paste** acts where the user clicked while **Cut** and **Copy** still act on what is highlighted. - Fixed the hit test behind the suggestion menu resolving to column 0 whenever the caret had previously been on another line: moving the caret first made that line the active one, and until the next frame `GetActualLineLayout` still returned the *previous* active line's layout. The menu is now built before the caret moves. - Added `Tests/TexterMarkdownSpellRangesTest.cpp` (48 checks) covering the markdown skip scanner. - **TreeView: a double-click or the Enter key on a lazily-loaded node left it empty.** Both gestures toggled the node with a bare `TreeNode::Toggle()`, which flips the expansion state without firing `onNodeExpanded` — the callback a lazily-populated tree loads its children from. The node then sat expanded showing only its "..." placeholder; the UltraFiler's curated Home entry made it visible (double-clicking *Home* showed nothing until something else - navigating into a subfolder, or collapsing and re-opening it with the expand button, which did fire the callback - loaded the children). All toggle gestures - the expand button, double-click, Enter - now go through the new public `UltraCanvasTreeView::ToggleNode()`, which routes into `ExpandNode` / `CollapseNode`, so `onNodeExpanded` / `onNodeCollapsed` fire for every gesture. - **The UltraFiler's home folder display is curated like its tree entry.** New `UltraCanvasFilerWidget::SetCuratedHomeFolder(homePath, mainFolders)`: while set, displaying that folder lists only the given main folders — each by its resolved path, so a Documents redirected into OneDrive is listed too — plus the folder's regular files; the profile clutter ("3D Objects", "Saved Games", working folders) stays out, matching the folder tree. The UltraFiler sets it on its tab filers and the folder-preview pane with the same main-folder set the tree shows. Other folders are never affected. - New **Display > Hidden files** checkbox in the filer's context menu — the `SetShowHiddenFiles` toggle finally has UI. It doubles as the curation escape hatch: hidden files ON means "show me everything" and reveals the home folder's untouched physical listing. - **CI builds Linux on ARM as well as x86_64.** The build matrix gained an `ubuntu-22.04-arm` row, so every commit is now compiled, unit-tested and packaged for Linux/aarch64 next to the existing x86_64 job — the same 22.04 base, so both Linux artifacts keep one glibc floor. Linux joins macOS and Windows in having both architectures gated; until now an ARM-only build break (or an aarch64 codegen difference in the renderer) could only be found by a user on the hardware. The upload is `UltraCanvas-Linux--arm64.tar.gz` alongside the x86_64 tarball. - The Linux dependency list — apt packages plus the MuPDF, libopusenc and c-ares source builds — moved out of the matrix row into the *Install dependencies (Linux)* step, so the two Linux jobs share one copy instead of drifting apart. The only architecture-dependent part is the multiarch libdir the source builds install into, now read from `dpkg-architecture`. The Rust toolchain step bootstraps `rustup` when the runner image does not preinstall it (the arm64 image ships a smaller toolset), so the Vectorizer plugin gate cannot fail for that reason. - `package-linux.sh` is architecture-aware: the package name takes its label from `uname -m` (`x86_64` or `arm64`, matching the artifact naming used for the macOS/Windows ARM builds, overridable with `ARCH=`), and the ImageMagick delegate is copied from the host's own multiarch directory instead of a hard-coded `/usr/lib/x86_64-linux-gnu`. The Linux artifact upload no longer hard-codes `x86_64` in the tarball it looks for. #### 2026-08-28 *0.3.83* - **The XAR renderer matches Xara Designer Pro X19 output much closer now** — three fidelity fixes found by comparing a real Designer Pro file's render against its author's PDF export, pixel by pixel: - Soft shadows composite at their true darkness: the shadow atom record (`TAG_SHADOW`) carries the exact opacity as a double, which Xara's own export uses — the controller record's coarse percentage field made shadows twice as dark (50% instead of 25% in the reference file). The penumbra is a real gaussian blur now: blurring a silhouette equals averaging copies of it shifted over the kernel, so the silhouette renders in up to 64 passes at gaussian-distributed offsets (a deterministic golden-angle spiral), each at the low alpha that accumulates to the shadow darkness — the measured edge falloff matches the reference export point for point, where the previous widened-stroke fake produced a hard edge with dark banding. - QuickShapes (rectangles/ellipses/polygons) render bitmap and contone fills now — Designer Pro's image placeholder is a rectangle QuickShape with a bitmap fill, which previously fell back to a flat colour. The path node's bitmap-fill painter is shared instead of duplicated. - A line-level left indent (`TAG_TEXT_LEFT_INDENT` attached to a text line) shifts that line's origin — indented paragraph blocks rendered flush left before. - **UltraCanvasFilerWidget: name filter (filter-as-you-type).** `SetNameFilter(text)` narrows the displayed listing to the names containing the text (case-insensitive) without a disk rescan per keystroke — the full scan is kept while a filter is active — and `""` shows everything again. The filter survives rescans, keeps the selection on the entries that stay visible, and is cleared by `SetPath()` (it belonged to the listing it was typed against). A listing the filter empties shows *"No matches for "…""*, and `SetFilterEmptyAction(label, action)` centers a host-provided escalation button (a real `UltraCanvasButton` child) under that notice. - **UltraCanvasFilerWidget: Explorer-style type-ahead.** A printable character selects the first entry whose name starts with it; the same key again walks on to the next such entry, wrapping around. `SelectNextEntryStartingWith(ch)` exposes the step so hosts can route characters typed elsewhere in their window into the visible filer. - **UltraFiler: the search field filters as-you-type.** Typing narrows the shown folder immediately (the status bar notes the filter); when nothing matches, a centered **Search in sub folders** button appears, which — like Enter in the field — runs the recursive search that used to require Enter for every query. A letter typed anywhere outside a text field walks the visible listing by first character (window-level type-ahead routing). - **UltraFiler: "New folder ▾" split button.** The command bar's New folder / New file pair is now one split button: the primary section creates a folder (Ctrl+F unchanged), the arrow opens a menu with the same entries as the context menu's "New >" submenu — Folder, then the filer's document kinds (Text, Doc, Spreadsheet, Bitmap, Vector, Audio, Video), read live via the widget's new `GetNewDocumentTypes()`. Every creation command first ends the search — the field, the live filter and a recursive-result display — and `CreateNewFolder()` / `CreateNewDocument()` themselves leave a file-list display and drop an active name filter, so the fresh entry is always visible with its inline rename editor open. - **UltraCanvasSplitPane: fixed-size panes.** `SetPaneFixedSize(index, px)` pins a pane at an absolute axis size that survives container resizes (maximizing the window included) — only the weighted panes share what a resize changed. Dragging an adjacent splitter still resizes the pane; the dragged size becomes its new fixed size. `SetPaneSizes` updates a fixed pane's absolute size instead of its weight; min/max clamps apply; `0` returns the pane to weight-based sizing. - **UltraFiler: the folder tree keeps an absolute width.** It opens at 280px (previously ≈345px proportional) and stays at whatever width the user drags it to — maximizing or resizing the window changes only the folder display's share. - **UltraFiler: the folder preview no longer fires on a double-click.** Clicking a folder still shows its content in the detail pane, but only after the double-click interval has passed with the folder still selected — the first click of a double-click that *opens* the folder no longer scans it into the pane (and whatever the pane showed stays put while the delay runs). #### 2026-08-28 *0.3.82* - **An ILLEGAL_INSTRUCTION crash now names what the machine actually has.** Reporting `0xC000001D` and the faulting module is only half an answer: it says the binary used an instruction this CPU will not execute, but not which instruction, and not what the CPU does support — so the next step was still guesswork. For that exception (and `PRIV_INSTRUCTION`) the reporter now adds the CPU brand string, the instruction sets the machine offers, and a hex dump of the bytes at the fault. `62` is an EVEX prefix (AVX-512), `C5`/`C4` is VEX (AVX/AVX2) — enough to identify the instruction from the dialog alone. - Feature detection goes through `IsProcessorFeaturePresent`, not raw CPUID bits, so it reports what the OS *permits*. That matters for the second cause of this crash: an x64 binary running under emulation on an ARM64 machine, which is now called out explicitly (`EMULATED: x64 image on a ARM64 machine`, via `IsWow64Process2`). Windows on ARM emulates only a subset of x86 — no AVX-512 at all — so a binary that runs on every x64 box still faults there. - The startup banner gains the same `cpu` line, so comparing a machine that works against one that does not is a diff of two logs rather than a guess. This is the failure most often misread as an operating-system problem: "works on Windows 10, crashes on Windows 11" is what you see when the two machines also differ in CPU, which they usually do. - CPUID is reached through the compiler's own `` (`__get_cpuid_count`) on GCC and clang, `` on MSVC, and is compiled **only** where the architecture has it. The guard is on the architecture, never on the compiler: MSYS2's CLANGARM64 toolchain defines `__clang__`, so a "GCC or MSVC" split sends an ARM64 target down the MSVC-intrinsic path and asks for `__cpuidex` on a CPU with no CPUID at all. Going through `` rather than inline asm also gets the 32-bit-PIC EBX save/restore right, which a naive `"=b"` output constraint does not. On ARM the brand string is simply "ARM64". - Only the instruction-fault path pays for any of this. The extra text is built only for those two exception codes, and everything else — a stack overflow above all, which runs the filter on the stack that just ran out — keeps to the original small buffer and short message. CPUID and the feature queries run at install time, while the process is still healthy; the byte dump is guarded by `VirtualQuery` so the handler never reads an uncommitted page. - `Docs/UltraCanvas/UltraCanvasWindowsDiagnostics.md` gains an "ILLEGAL_INSTRUCTION: built for a CPU this machine is not" section covering both causes, why the crash lands in graphics and image libraries (that is where the vector code is), and why `-march=native` must never be shipped — `-march=x86-64-v2` is a safe floor for Windows 10/11, while `v3` requires AVX2 and excludes many current laptops and most virtual machines. #### 2026-08-27 *0.3.81* - **Telling a crashed Windows app from a hung one.** `uc-diagnose.bat` can only report what a process exits with, which is nothing at all when it does not exit. A host that starts, opens its log file and then never shows a window looks identical to one that died on the spot — and the two have nothing in common as bugs. New `scripts/uc-diagnose.ps1` launches the executable, watches it for 20 seconds and returns one of three verdicts: **exited** (with the decoded exit code), **window appeared**, or **alive with no window** — the last with the CPU time that separates "blocked on a wait" from "spinning". It also lists the child processes, any Windows event-log entry naming the executable, and the framework log, and leaves the process running to be inspected. Windows PowerShell 5.1 and PowerShell 7 both work; it changes nothing on the machine. Ships in the Windows package next to the batch file. - It starts the process through `[System.Diagnostics.Process]::Start` rather than `Start-Process -PassThru`, whose returned object can report `ExitCode` 0 for a process that exited non-zero — enough to file a crash as a clean exit. The same trap is now documented for anyone testing by hand: **PowerShell does not wait for a GUI-subsystem process**, so `$LASTEXITCODE` after `.\App.exe` is whatever ran before it, not the app's code. `cmd` does wait, which is why the batch file can read `%ERRORLEVEL%`. - This release is the first to carry the Windows event loop's servicing of host file-descriptor watches (`PollAndServiceFdWatches()`): registered Winsock sockets are polled around the `MsgWaitForMultipleObjectsEx()` wait, and that wait is bounded so level-triggered readiness is picked up promptly. Before it, `AddFdWatch()` was honoured on Linux and ignored on Windows, so a host driving UltraCanvas from its own loop — Ladybird's IPC to its WebContent process is the motivating case — waited forever for a reply the loop would never deliver: no crash, no log, no window. `Docs/UltraCanvas/UltraCanvasWindowsDiagnostics.md` gains an "Alive but no window" section that walks that symptom back to its cause. #### 2026-08-27 *0.3.80* - **A Windows build that fails to start can now say why.** The Windows executables are linked as GUI-subsystem binaries, which gives them no console: `std::cerr` went nowhere, an unhandled exception killed the process without a word, and a failed `InitializeNative()` returned false and exited with no window and no message. `debugOutput` made it worse by compiling to a do-nothing stream outside Debug builds — the packaged binaries users actually run were the only ones that could not be asked what went wrong. Reports of the "it starts on this machine and does nothing on that one" kind had no evidence to work from at all. - `debugOutput` now picks its sink at **runtime**, in every build configuration. `ULTRACANVAS_DEBUG_LOG=` appends to that file (flushed per line, so it survives a crash), `=1` writes to stderr, `=0` silences even a Debug build, and leaving it unset keeps the old behaviour — stderr in Debug, nothing in Release. Disabled, it costs one branch per `<<`. Values with no `operator<<(std::ostream&, T)` are dropped rather than rejected, so call sites that only ever compiled against the old null stream keep compiling. `IsDebugOutputEnabled()`, `SetDebugOutputFile()` and `SetDebugOutputEnabled()` expose the sink to application code. - New `UltraCanvas/OS/MSWindows/UltraCanvasWindowsDiagnostics.{h,cpp}`, wired into `InitializeNative()`: stdio is reconnected to the console the process was launched from, when there is one; a startup banner records the real Windows build number (via `RtlGetVersion` — `GetVersionEx` reports 6.2 to an unmanifested app, making Windows 10 and 11 indistinguishable), architecture, paths and `FONTCONFIG_FILE`; an unhandled-exception filter turns a silent crash into a logged exception code, faulting address and **module name**, plus a message box; and each fatal initialisation step reports its stage and `FormatMessage` text instead of returning false quietly. The crash path writes with raw Win32 calls — no allocation, no stream, no lock — because the process is already unsound. `ULTRACANVAS_NO_ERROR_DIALOG=1` suppresses the dialogs for helper processes and CI. - `scripts/uc-diagnose.bat` ships in the Windows package: it runs the executable *attached* so the exit code survives (a `start`-based launcher discards it — which is why such a launcher looks identical whether the app came up or died), turns the log on, flags a Mark of the Web on the binary, and decodes the NTSTATUS exit codes that matter (`DLL_NOT_FOUND`, `ENTRYPOINT_NOT_FOUND`, `INVALID_IMAGE_FORMAT`, `ILLEGAL_INSTRUCTION`, `ACCESS_DENIED`, …). New `Docs/UltraCanvas/UltraCanvasWindowsDiagnostics.md` walks through reading the result, and lists the Windows 11-only environment differences — Smart App Control, Mark-of-the-Web enforcement — that stop an unsigned binary with no dialog at all. #### 2026-08-27 *0.3.79* - **Tile captions in the Filer stop cutting file names apart.** "Logo CoderBox with text.png" under a thumbnail read *Logo CoderBo* / *x with text.png*: the line took the name to the exact pixel it stopped fitting, which bought it a single character and cost the word. Wrapping now keeps words whole — a break inside a word is only made when the word has to be split, and never where it would leave a stub of `captionBreakTolerance` characters or fewer (3 by default) on either side of it. A line may also use `captionOverflowSlack` pixels of the inset the caption already has around it (auto: half a character) to pull the last letter or two of a word up rather than break it, and to keep a last line that is a few pixels too wide whole instead of opening it with "…". Nothing is lost to the tidier break: when keeping the words whole would push part of a name off the caption, the name is re-broken with mid-word breaks allowed and whichever version shows more of it wins. Line counts — and with them the tile heights — are unchanged. - The wrapper itself moved out of the widget into the new header-only `UltraCanvasTextWrapping.h` (`Wrap`, `WrapGreedy`, `LineCount`, `Ellipsize`, `Truncate`). It measures text through a callable instead of an `IRenderContext`, so the same code runs in the widget and against a synthetic proportional font in `Tests/TextWrapTest.cpp` — the new suite covers the reported name, the stub rule, the slack, UTF-8 breaks and a width sweep asserting no wrapped name silently loses characters. #### 2026-08-27 *0.3.78* - **GNU LibreDWG is listed in the dependency overviews now.** The DWG converter's delegation to LibreDWG's `dxf2dwg`/`dwg2dxf` command-line tools (external processes, never linked; GPL 3) appears in the demo app's *Dependencies & Third-Party Libraries* page — with a new "Vector formats plugin" section that also records that the SVG/XAR/EPS/CDR/PDF/ EMF/WMF/AI/DXF converters are implemented in-tree — and in the READMEs' Technical Stack. #### 2026-08-26 *0.3.77* - **The UltraFiler's Home entry is curated instead of scanned.** Expanding *Home* listed the whole profile — *3D Objects*, *Contacts*, *Favorites*, *Links*, *Saved Games*, *Searches*, the sync-client folders and every working folder a user had dropped in there, ~20 rows on a stock Windows profile — which is exactly the list a places tree exists to spare you. It now shows the user's main folders and stops: Desktop, Documents, Downloads, Music, Pictures, Videos. The paths still come from the platform (`SHGetKnownFolderPath` / the macOS home layout / `xdg-user-dirs`), so a redirected or localized folder — *Bilder*, a Documents folder moved into OneDrive — is the one listed, under its own icon, and the duplicate rows a localized profile used to show (*Documents* **and** *Dokumente*) are gone with it. Nothing became unreachable: the folder display still lists the whole home folder. The set is one array in `UltraFilerWindow.cpp` (`kHomeTreeFolders`). The "has subfolders?" probe answers for Home from the same curated list, so a profile holding none of them is a leaf rather than an expand button that opens onto nothing. - **New "Cloud Storage" section in the UltraFiler's folder tree**, between Home and the drives: OneDrive (personal and every business tenant), Google Drive, Dropbox (personal and business) and iCloud Drive in one place, instead of scattered through the profile — and instead of a Google Drive that mounted as a virtual drive letter hiding among the real drives. Like the Pinned section it is hidden entirely while there is nothing in it, and shown open when there is. The cloud roots keep *Delete* disabled in the context menu the way the drive roots do: deleting one syncs the deletion to every other device. - New `UltraCanvas::GetCloudStorageFolders()` (`UltraCanvasCloudStorage.{h,cpp}`) behind it — the counterpart of `GetWellKnownUserFolders()`. It gets a header and translation unit of its own rather than joining its companion in `UltraCanvasUtils` because reading the Dropbox configuration needs `UltraCanvasJSON`, and `UltraCanvasUtils.cpp` sits at the bottom of the stack: `HTMLReaderTest`, `EBookEngineTest` and `WordFormatsTest` each compile it standalone, without linking the framework library, just to get `Trim()`. Each provider is asked where it put its folder rather than guessed at: the `OneDrive*` environment variables, the Google Drive mount under `HKCU\Software\Google\DriveFS` plus the fixed drives labelled *Google Drive*, and the Dropbox `info.json` (where a relocated or a second, business folder is recorded, read through `UltraCanvasJSON`) on Windows; the per-provider folders macOS 12+ keeps under `~/Library/CloudStorage` — what Finder's sidebar lists — plus `~/Library/Mobile Documents/com~apple~CloudDocs` on macOS; the GVFS mount table (GNOME Online Accounts) and the native sync-client defaults on Linux. Only folders that exist right now are returned, each once, in a canonical order: a client that is installed but signed out has no folder and is not listed. Nothing is mounted, signed in to or contacted. The UltraFiler runs it on its own thread and fills the section when it answers, so a wedged mount cannot hold up the window. #### 2026-08-26 *0.3.76* - **Every vector format is now covered by the plugin system, load and save.** The graphics plugin registry gained a save side and the converter matrix gained readers: - `IGraphicsPlugin` now has a save interface (`GetSaveExtensions()` + `SaveGraphics()`, default load-only so existing plugins are unaffected), with registry dispatch (`SaveGraphicsFile`, `CanSaveGraphicsFile`, `GetSupportedSaveExtensions`). The `UltraCanvasSupportedFormats` inventory reports per-extension load/save from the real interface instead of a hardcoded STL special case, and the STL plugin implements the new interface (mesh back to `.stl`). - The new `UltraCanvasVectorFormatsPlugin` exposes the whole converter matrix through that registry: loading SVG/XAR/EMF/WMF/DXF/DWG into an editable `UltraCanvasVectorElement`, saving all ten formats (SVG/XAR/EPS/CDR/PDF/EMF/WMF/AI/DXF/DWG) from one. Registered in the DemoApp alongside the CDR/XAR/EPS viewer plugins. - **New readers**: DXF (`UltraCanvasDXFReader.cpp` — tables, ACI+true colours, LINE/CIRCLE/ARC/ELLIPSE/LWPOLYLINE with bulges/POLYLINE/ SPLINE/HATCH/SOLID/TEXT/MTEXT; piecewise-bezier splines reproduce exactly, general NURBS sample via de Boor), DWG (LibreDWG `dwg2dxf` delegation, `ULTRACANVAS_DWG2DXF` or PATH), EMF (`UltraCanvasEMFReader.cpp` — GDI object table, path records, immediate primitives, `ExtTextOutW` with `TA_UPDATECP` chains merging back into text spans) and WMF (`UltraCanvasWMFReader.cpp` — placeable header, 16-bit records, object table, text). - The XAR converter's import side was replaced: the legacy record processors used wrong tag numbers and a 28-byte "file header" struct where real XAR has an 8-byte signature, so they could not read a single real file. The new reader consumes the spec-correct grammar the writer emits (uncompressed; compressed Xara files remain the XAR plugin's). XAR dash patterns are also written and read now (define/reference records), replacing the export warning. - Two writer fixes the readers surfaced: DXF entities now carry a valid model-space owner handle (it was written empty, which made LibreDWG drop every entity when converting the resulting DWG back), and the DXF nearest-ACI fallback searches the full 256-colour palette instead of 10 classic entries — LibreDWG's DXF output carries only ACI, so this decides the colours a DWG round trip keeps. - `Tests/VectorFormatsPluginTest.cpp` drives the whole matrix through the registry: saves all ten formats via `SaveGraphicsFile` and validates each file, loads the readable ones back via `LoadGraphicsFile` and checks geometry, colours, dashes and text survive, and verifies the supported-format inventory. - **DXF and DWG files can be written now — the CAD formats complete the vector writer matrix.** Two new converters in `Plugins/Vector/UltraCanvasCADConverters.h`: - `DXFConverter` writes R2000 (AC1015) tagged ASCII per Autodesk's public DXF reference, with the full table set (linetypes, layers, styles, block records) under proper handles and ownership. Document layers map to real DXF layers. Fills become solid `HATCH` entities whose boundary paths carry exact spline edges (curves stay curves); strokes become `LWPOLYLINE` or, when curved, `SPLINE` entities with the exact piecewise-bezier NURBS form (degree 3, clamped knots with interior multiplicity 3). Colours are written as 420 true colour with a nearest-ACI 62 fallback for legacy consumers, stroke widths snap to DXF's discrete lineweight set, dash arrays become `UC_DASHn` linetypes, and text maps to `TEXT` entities with real alignment. DXF has no opacity, so it is reported through the warning callback, never dropped silently. - `DWGConverter` — DWG is proprietary and undocumented; rather than embed a reverse-engineered binary writer of uncertain fidelity, the converter produces the DXF output and converts it with GNU LibreDWG's `dxf2dwg` tool (`ULTRACANVAS_DXF2DWG` env or PATH). Without the tool it warns with that guidance and declines cleanly. - `Tests/CADWriterTest.cpp` validates the DXF through ezdxf — the reference DXF implementation: strict read, clean audit, exact entity/layer/colour structure — plus LibreOffice rasterization, and the DWG chain through dxf2dwg with dwgread acceptance when LibreDWG is installed. - **EMF, WMF and Adobe Illustrator files can be written now.** Three new converters in `Plugins/Vector/UltraCanvasMetafileConverters.h` complete the vector writer matrix (with SVG/XAR/EPS/CDR/PDF below): - `EMFConverter` writes Enhanced Metafiles per the public [MS-EMF] record layouts: MM_ANISOTROPIC mapping at 20 logical units per point, geometry through GDI paths with real beziers (BeginPath/MoveToEx/LineTo/PolyBezierTo/CloseFigure painted by Fill/Stroke(AndFill)Path), ExtCreatePen geometric pens with caps, joins and user-style dash entries, and ExtTextOutW text with SetTextAlign anchoring (TA_UPDATECP chains multi-style spans). - `WMFConverter` writes legacy 16-bit Windows Metafiles per [MS-WMF] with the placeable (Aldus) header in twips: Polygon/PolyPolygon fills, Polyline strokes (WMF has no bezier record, so curves flatten to polylines), TextOut text, and a faithfully modelled GDI object table. Dash patterns approximate as PS_DASH. - GDI metafiles have no alpha channel, so opacity flattens toward the white page in both, with a warning. - `AIConverter` writes Adobe Illustrator files: modern .ai IS a PDF (Illustrator's own editing data is an optional attachment), so the output is the PDF writer's under the .ai extension — valid for Illustrator and every PDF consumer; validation also recognizes legacy EPS-based .ai. - `Tests/MetafileWriterTest.cpp` checks structure (record walks that must land exactly on the terminating record, the placeable header's XOR checksum, header sizes against file sizes) and rendering through independent consumers — LibreOffice rasterizes the EMF and WMF, ghostscript the AI — with layout-order colour checks. - The new `Docs/UltraCanvas/UltraCanvasVectorConverters.md` documents the full eight-format converter matrix in one place. - **SVG can be read and written now — the declared `SVGConverter` exists.** `VectorConverter::SVGConverter` was declared in `UltraCanvasVectorConverter.h` but had no implementation anywhere; the new `UltraCanvasSVGConverter.cpp` implements both directions, and it is the one converter with lossless fidelity because the `VectorStorage` model is essentially SVG-shaped. Export keeps groups/layers as ``, transforms as `matrix()` attributes, gradients with every stop in ``, patterns with their content, multi-span text with `xml:space="preserve"`, dashes, opacity and ``/``/``. Import (tinyxml2) covers all basic shapes, paths, groups, presentation attributes and inline `style=""`, `url(#id)` gradient resolution with one level of `href` inheritance, tspans, entities and CSS-unit conversion; a document whose top level is all `` elements imports them as layers. Round-trip coverage in `Tests/SVGConverterTest.cpp` includes rasterizing the export through the framework's real SVG pipeline (librsvg) with pixel checks — an independent renderer accepts the output. - **Vector PDF can be written now — the declared `PDFVectorConverter` exists.** Also previously declaration-only vapor. Writes a self-contained, hand-assembled PDF 1.4: catalog/pages/page objects, a content stream of path and text operators (transforms baked through the shared `PathOps` walk, Y axis flipped to PDF's page space), base-14 Type1 fonts mapped from font families (bold/italic variants included), ExtGState entries for opacity, dash patterns, and a correct xref table. Gradients fall back to the blend of their end stops and centre/right text anchoring is approximated from an average glyph width — each warned. Export-only: reading PDF stays with the MuPDF plugin, and the converter's `CanImport()` now says so honestly. Validated in `Tests/PDFVectorWriterTest.cpp`: structural checks including xref offsets that really point at their objects, plus a ghostscript rasterization with pixel checks. - **SVG `matrix(a,b,c,d,e,f)` transforms parse correctly now.** `ParseTransformString` fed SVG's column-major b and c straight into the row-major `Matrix3x3::FromValues`, transposing every skew and rotation it imported; the two values now swap places, matching what `SerializeTransform` (which was already correct) writes. - **CDR files can be written now.** New `VectorConverter::CDRConverter` (`Plugins/Vector/UltraCanvasCDRConverter.{h,cpp}`) serializes a `VectorStorage::VectorDocument` as a version-7 RIFF CDR file. CorelDRAW's format has no public specification, so the writer targets the record layouts consumed by libcdr — the reference open-source reader and the engine underneath the framework's CDR plugin: `vrsn`/`mcfg`/`fild`/ `outl`/`trfd`/`loda` chunks, 32-bit coordinates in 1/254000 inch in a page-centred Y-up space, objects written topmost-first (CDR draws in reverse file order), geometry as line-and-curve point lists through the shared `PathOps` normalisation with transforms baked in, solid fills, outline width/caps/joins/dash patterns, and fill opacity. Gradients fall back to the blend of their end stops; text and bitmaps are skipped — each reported through the warning callback. `Tests/CDRWriterTest.cpp` round-trips a document through the writer and the CDR plugin (libcdr) and pixel-checks the rendered placement — that parse is the correctness contract. Export-only: reading stays with the CDR plugin. - **CDR rendering: one object's transparency no longer bleeds into the rest of the drawing.** The plugin's librevenge painter accumulated style properties across objects, but a librevenge style is complete — properties at their defaults are simply absent (libcdr emits `draw:opacity` only when it is below 1.0). A single semi-transparent object made every object drawn after it semi-transparent too, and a dash pattern could survive `stroke-dasharray: none`. Opacities now reset per style and `none` clears the dash pattern. - **EPS files can be written now.** New `VectorConverter::EPSConverter` (`Plugins/Vector/UltraCanvasEPSConverter.{h,cpp}`) serializes a `VectorStorage::VectorDocument` as an EPSF-3.0 PostScript program: DSC header with `%%BoundingBox`, shapes and paths as move/line/curve operators (sharing the writers' path normalisation, see below), solid fills, stroke width/caps/joins/mitre/dash, and text via `selectfont`/`show` with per-span fonts, centre/right anchoring done in PostScript with `stringwidth`, and proper string escaping. Transforms are baked into the coordinates with the Y-axis flip. PostScript has no transparency and the plain operator set has no gradients, so opacity flattens toward the white page and gradients fall back to the blend of their end stops — each reported through the warning callback. The writer emits only operators the EPS plugin's interpreter knows: `Tests/EPSWriterTest.cpp` round-trips a document through the writer and that interpreter (header fields, zero unknown operators, zero warnings, pixel placement), and ghostscript renders the same output identically. The converter is export-only — reading EPS stays with the EPS plugin. - The `%%Title`/`%%Creator` DSC values now unwrap a parenthesized PostScript string (`%%Title: (name.ai)`), the form Adobe Illustrator writes. - The path normalisation shared by the format writers (every `PathCommandType` down to absolute move/line/cubic segments, SVG arcs via endpoint-to-centre conversion, and the rounded-rect/ellipse builders) moved from the XAR writer into `Plugins/Vector/UltraCanvasVectorPathOps.h`, used by both converters. - **XAR files can be written now.** `VectorConverter::XARConverter::Export` (and `ExportToString`/`ExportToStream`) serializes a `VectorStorage::VectorDocument` into the XAR record grammar the spec-verified reader consumes: signature, `FILEHEADER`, the `DOCUMENT`/`CHAPTER`/`SPREAD`/`LAYER` tree, and per-object attribute children, with colours and fonts emitted as definition records referenced by record sequence number. Rectangles, rounded rectangles, circles and ellipses write as native shape records while the combined transform is axis-aligned and fall back to path records under rotation or skew; paths normalise every command type (including quadratics, smooth variants and SVG arcs) to the move/line/bezier verbs XAR stores; text writes as story, line and string records with per-span typeface, size, bold, italic and underline attributes; solid fills, linear and radial gradients, stroke colour, width, caps, joins and mitre limit, and flat transparency (from style opacity and fill alpha) all round-trip. Coordinates convert to millipoints with the Y-axis flip. The previous export code emitted a flat record list with wrong tag numbers (the legacy `XARTags` constants) that no reader could open. `Tests/XARWriterTest.cpp` round-trips a document through the writer and the XAR plugin's reader and checks structure, placement, colours and text; the export renders correctly through `XARProbeTest --render`. - **The filer thumbnails `.xar` files.** Xara files embed a preview bitmap (GIF/JPEG/PNG) in the uncompressed file head; the filer's thumbnail workers now extract those bytes directly — twelve lines of record walking, no XAR renderer involved — and decode them through the image pipeline like any photo. Files without a preview record (including ones this writer produces) keep the generic glyph. - **XAR renderer fixes surfaced by round-tripping written files:** - `ELLIPSE_SIMPLE` records drew at half size, offset toward the lower right: the renderer passed the centre and the radii where `FillEllipse`/`DrawEllipse` expect a bounding box. The repo samples never hit this (Xara writes circles as regular shapes). - Flat transparency now actually applies to simple shapes and paths. The generic fill path called `SetAlpha` after staging the fill paint, but filling re-applies the stored paint, overwriting the alpha; the transparency is now folded into the paint colours (and gradient stops) themselves, and the simple rectangle/ellipse/polygon nodes respect it too. - The Vector plugin (`ULTRACANVAS_PLUGIN_VECTOR`, off by default) compiles again after storage-model drift: `PathData` command-field renames, the `Point2Dd`/gradient API changes, two `Clone()` methods that mutated their source, and a duplicate legacy `XARConverter` declaration are fixed; the never-implemented legacy import attribute handlers are stubbed so the plugin links. #### 2026-08-26 *0.3.75* - **Filer widget: legible spreadsheet previews.** The thumbnail preview of a spreadsheet (ods / xlsx / csv / tsv) split the tile width evenly over the columns, so a sheet with several columns showed one or two characters per cell — a calendar previewed as a grid of first letters. Column widths now follow the content: each column is as wide as its widest shown cell, floored at about six characters so text stays recognizable, unless the column's own content is narrower (a column of one-digit values takes only what it needs). Columns that then no longer fit are clipped at the right edge — a few legible columns beat many unreadable ones — and when everything fits with room to spare the leftover is spread evenly so the grid still fills the page. - **UltraFiler: clicking a folder shows its content in the detail pane.** The pane to the right of the folder display — which used to open only for a previewable file and folded away when a folder was selected — now previews folders too: selecting one shows its content as a small-thumbnail folder listing (a second `UltraCanvasFilerWidget` sharing the pane with the media viewer). The peek is live — a subfolder double-clicked in it is entered right in the pane, an activated file opens with its OS default application, files can be dropped into it and its context menu offers the usual file commands (the hover icon menu stays off; subfolder prefetch too, the pane being a peek rather than a working view). Esc, the Preview toggle, the restored pane width and the selection-follows-delete behaviour all work exactly as for file previews, and moving the selection between a file and a folder swaps the pane's content in place instead of closing and reopening the pane. #### 2026-08-26 *0.3.74* - **EPS (Encapsulated PostScript) vector graphics support.** New `UltraCanvasEPSPlugin` (`Plugins/Vector/EPS/`) renders `.eps`/`.epsf`/`.ps` drawings. EPS files are PostScript programs — real writers define their own procedures in a prolog and draw through them — so the plugin embeds a PostScript-subset interpreter: scanner, operand/dictionary/execution stacks, procedures and control flow, the path/paint/transform/color/line operators, text through mapped system fonts, and sampled images including `ASCII85Decode` + `FlateDecode` data sources (zlib is the plugin's only dependency beyond the core). DOS EPS binary preview headers are unwrapped, `%%BoundingBox` sets the page, and `EPSDocument::GetDiagnostics()` reports unknown operators and approximations for triage. Verified against ghostscript renderings of the shipped samples, a cairo-generated vector drawing (~43k tokens) and a 5000×7501 ASCII85+Flate fallback raster — all agree to within antialiasing differences. - **`IRenderContext` gained `SetFillRule` (NonZero/EvenOdd).** The interface default ignores the call, the Cairo backend implements it, and the EPS plugin uses it for `eofill`/`eoclip`; the rule participates in the saved graphics state. - **`UltraCanvasEPSElement`** mirrors the sibling vector elements: scale + aspect-ratio viewport, `IsLoaded`/`GetLastError`, a white page behind the drawing, and File Loader registration (`RegisterEPSPlugin`). The demo gains an **EPS Images** page with the two new `media/eps/` samples, a fullscreen viewer and zoom controls, and **`Tests/EPSProbeTest`** prints the interpreter's triage for any file and rasterizes `--render` PNGs for ghostscript comparison (registered as a parse regression test). #### 2026-08-26 *0.3.73* - **Sliders over a small range reach every value again.** `UltraCanvasSlider` defaulted to a snap increment of 1.0 whatever its range, so a fractional slider offered only the whole numbers inside it: the media viewer's adjustment sliders (gamma 0.2..3.0, the colour channels 0..2) had three usable positions each, and a 0..1 ratio slider had two. A slider that is given no step now derives one from its range — whole units where the range spans at least 20 of them, continuous below that — and a step the caller states survives any later `SetRange()`, so integer controls (the export dialog's quality, effort and speed) keep whole steps. The media viewer's sliders are continuous, carry their live value in the caption (`Gamma 1.37`) and are put back by *Reset*, which used to leave every handle where the user had dragged it. - **Curves: per-channel tone editing for images.** New framework facility in three layers — `UltraCanvasToneCurve` / `ToneCurveSet` (the model: control points, monotone-cubic interpolation, 256-entry lookup tables, text serialisation), `UltraCanvasCurveEditor` (the element the points are dragged in, over an optional histogram) and `UltraCanvasCurvesDialog` (channel selector, preview toggle, reset / OK / cancel). Pixels are mapped by the new `PixelFX::Colour::MapLut()`, which applies per-channel 8-bit tables and passes alpha through untouched. - **The media viewer got a *Curves* button.** It edits `MediaAdjustments::curves` — a master (RGB) curve plus one per colour channel — over the histogram of the shown image, previewing live on the picture at full size; *Cancel* restores what was there. Curves are applied first in the colour pipeline, so the existing sliders act on their result, and *Save as* bakes them in like every other adjustment. This is the tool that reaches highlights, midtones and shadows separately; the sliders move the whole tone range at once. #### 2026-08-26 *0.3.72* - **The XAR renderer draws real Xara files correctly now.** Files written by a modern Xara (Designer Pro X19) displayed as scattered, unfilled fragments; the repo's `demo.xar` now reproduces its embedded preview — all four logos, correct fills, correct positions — and `backside.xar` renders as a real page instead of failing outright. Four defects fixed: - **The document tree was built wrong.** The Xar grammar is `object record, TAG_DOWN, child records, TAG_UP` — but containers pushed themselves at record time while every object's TAG_UP popped once more than was pushed, unwinding the stack until most objects attached to the root. TAG_DOWN now descends into the node the previous record created. - **Object attributes were lost.** An object's fill and line attributes arrive as its child records, after the object; the parser snapshotted attributes at object-record time and then reverted them at TAG_UP, so almost every path rendered unfilled. The enclosing object now re-snapshots the running context as its attribute children execute. - **Relative path coordinates decoded with the wrong sign.** The first coordinate of a relative path record is absolute; every following one stores the reverse delta (previous minus current). Adding instead of subtracting kept each subpath's shape but scattered the pieces — verified against the renderings Xara itself embeds in the file. - **One bad record could blank the whole drawing.** A singular transform matrix or a zero font size put the cairo context into an unrecoverable error state; both are guarded now. - **Bitmap and contone-bitmap fills render.** The embedded bitmap is decoded through `UCImage`, mapped onto the fill parallelogram and clipped to the path; a contone fill maps the bitmap's luminance between the fill's two colours — luminance 0 to the first, 255 to the second — keeping the bitmap's own (inverted) transparency, so the page shows through where the bitmap is transparent. The orientation and the preserved alpha are both verified against Xara's own renderings: the file-embedded preview of the `demo.xar` cogwheel, and the author's PDF export of a logo whose soft shadow is a flattened contone (which previously painted its whole bounding box black). Tinted results are cached per bitmap definition. Text also draws upright now (it was mirrored by the document's Y-flip). - **Regular shapes (QuickShapes) parse and render correctly.** The `TAG_REGULAR_SHAPE_PHASE_1/2` records (every square mosaic in a modern Xara drawing) were read with a guessed layout, producing garbage geometry — one such shape painted a full-page colour slab over everything. They now follow Xara LX's record layout (flags, side count, edge-midpoint axes, fixed-point matrix, stellation/curvature), and the on-disk MATRIX reader itself was wrong everywhere: 6 doubles instead of the spec's four 16.16 fixed-point values plus two millipoint integers. - **Embedded bitmaps keep their transparency the way Xara means it.** Xar-embedded PNGs store transparency, not alpha, in the alpha channel (255 = fully transparent); a standard decode premultiplies the colour of exactly the pixels such a bitmap wants shown down to black. `UCImageRaster::CreatePixmapAlphaInverted()` decodes with the channel inverted before premultiplication, and plain bitmap fills use it. - **Text stories lay their lines out.** Successive `TextLine`s step down one leading instead of overprinting on the story origin, a line's spans advance horizontally instead of overprinting each other, and the paragraph justification is honoured: the story origin is the anchor, so centred text centres on it and right-aligned text ends at it. Single-character records (`TAG_TEXT_CHAR` — dingbats, styled numerals) render as one-character spans, explicit kerns (`TAG_TEXT_KERN`) adjust the caret, and span boundaries are normalised against Xara's own line width (`TAG_TEXT_LINE_INFO`) so substituted fonts don't push spans apart. Verified page-by-page against the author's PDF export of a 13-slide pitch deck. - **Soft shadows draw as silhouettes.** `TAG_SHADOWCONTROLLER` used to be skipped entirely, silently dropping every shadowed object's shadow. The controller now parses its record (type, penumbra width, offset, darkness) and renders its children twice: first as a flat silhouette in the shadow paint — glows (type 3) as a symmetric halo, wall/floor shadows displaced by the stored offset — then normally on top. The penumbra is approximated with widened, fainter stroke passes around the silhouette. Calibrated against a MAGIX Photo & Graphic Designer 16 export where every bar of a logo carries a glow shadow. - **Text renders WYSIWYG against Xara's own output now** — verified line by line against a Designer Pro X19 text-formatting test and its author's PDF export: - *Measure and draw finally agree.* Spans were measured through the pango layout engine but drawn through cairo's toy text API, which resolves fonts differently — every caret position drifted. Spans now draw through the same layouts that measure them (`DrawSpanText` / `MeasureSpanText`), baseline-anchored. - *Glyphs were 4/3 too large.* The render context's font size is points at the text system's 96 dpi resolution while XAR page pixels are 72 dpi points; `ApplyTextFont` now converts. - *Xara's own line positions.* `TAG_TEXT_LINE_INFO` carries each line's baseline step; accumulating it replaces the leading heuristic, fixing line spacing everywhere (visible on the pitch deck's centred slides). - *Full justification.* Lines of a fully-justified paragraph spread their word gaps to Xara's stored line width — every line except the one carrying the paragraph's `TAG_TEXT_EOL`, which stays at natural width. - *Bullet and numbered lists.* The undocumented `TAG_TEXT_LIST_*` records (4404/4405/4410, written by modern Xara) mark list items; marker glyphs render at the marker indent, item text and wrapped continuation lines at the hanging indent. - *Font names parse correctly.* `TAG_FONT_DEF_TRUETYPE` starts with the typeface name; the old parser read panose bytes first and only recovered 5-character names ("Arial") by accident. - **Multi-page documents render page by page.** Every spread's coordinates restart at its own origin, so a multi-spread file (a pitch deck, a multi-page brochure) used to draw all its pages on top of each other. `XARDocument` now exposes the spread list as pages (`GetPageCount` / `GetPageWidth` / `GetPageHeight` / `RenderPage`), the XAR element gets `SetCurrentPage` / `onPageChanged`, and `XARProbeTest --render` writes one PNG per page. Verified on a real 13-slide Xara Designer Pro X19 pitch deck: all thirteen slides render individually. - **The demo's XAR page is active.** It shows both shipped samples (`media/xar/demo.xar`, `media/xar/backside.xar`) as clickable tiles with load-failure reasons in the status line, and the fullscreen viewer gained page navigation (prev/next with a page counter, via the element's new page API), zoom in/out and fit-page. The page's stale CorelDRAW wording is gone, and `UltraCanvasXARElement` now paints a white page behind the drawing (`IsLoaded()` added), so drawings read correctly on the dark fullscreen backdrop. - **`XARDocument` gained parse diagnostics** — records dispatched, unhandled record tags with counts, structural warnings — via `GetDiagnostics()`, and **`XARProbeTest`** (Tests/, needs `-DBUILD_TESTS=ON`) prints that triage for any `.xar` file and rasterizes it to PNG with `--render

      ` for comparison against reference screenshots. It doubles as a parse regression test over `media/xar/`. #### 2026-08-26 *0.3.71* - **CDR files can be saved as SVG.** The CDR plugin gains an export API: `UltraCanvasCDRPlugin::ExportToSVG(cdrPath, svgPath, pageIndex)` re-parses the CorelDRAW/CMX source through librevenge's `RVNGSVGDrawingGenerator`, so everything libcdr understands — paths, shapes, gradients, text, embedded bitmaps — is preserved in the SVG. `pageIndex` selects one page; `-1` exports every page (page N ≥ 2 to `-p.svg`, since SVG has no multi-page form). No new dependency: the generator ships in core librevenge. `ExportToXAR` exists as API but reports "not implemented yet" with the reason (the XAR writer exports only from the `VectorStorage` model, which nothing imports CDR into yet) instead of writing a broken file; results come back as `CDRExportResult{success, error, writtenFiles}`. - **The demo's CDR page shows its samples again, each with a "Save as…" button.** The `demo1.cdr` and `detailed.cdr` (zoom demo) tiles were commented out — re-enabled against the samples that ship in `media/cdr/`, alongside `demo.cdr` and the multi-page `logo.cdr`. Every tile gets a "Save as…" button: native save dialog offering SVG and XAR (marked "not finished yet"), exporting the page currently shown; the outcome — files written or the exact error — lands in the page's status label. The CMX tile stays disabled until a `.cmx` sample ships. #### 2026-08-26 *0.3.70* - **The CDR and XAR graphics plugins are actually registered now, so the File Loader can see them.** Both plugins compiled, and the demo displayed them by constructing the elements directly — but the one `RegisterCDRPlugin()` call had been commented out, so `UltraCanvasGraphicsPluginRegistry` stayed empty: the File Loader's supported-format inventory never listed `cdr`/`cmx`/`ccx`/ `cdt` (or `xar`), and extension-based dispatch (`LoadGraphicsFile`) returned null for files both plugins could parse. The demo now registers each plugin at startup under its `ULTRACANVAS_HAS_*_PLUGIN` guard, exactly as the docs' integration checklist prescribes. Verified end-to-end: with registration, `UltraCanvasFileLoader::GetSupportedFormats(Vector)` reports all four CDR extensions with the plugin as provider, and `LoadGraphicsFile()` parses every sample under `media/cdr/` into a loaded multi-page element. - **CDR plugin build cleanup.** The plugin's CMakeLists demanded libvips, vips-cpp and glib-2.0 as `REQUIRED` although the plugin never uses any of them (parsing is libcdr + librevenge; images decode through `UCImage`) — a stray hard dependency that broke the build on systems without libvips headers. Removed, along with the unused include paths. The plugin now builds against exactly what it links: libcdr, librevenge, and the UltraCanvas core. - Elements the CDR plugin creates for the registry are now named from a monotonic counter instead of `rand()`, so identifiers cannot collide. - Fixed the vector storage plugin's target name typo: `UltraCanvasVectorlugin` → `UltraCanvasVectorPlugin` (referenced only through exported variables, so nothing else moves). #### 2026-08-25 *0.3.69* - **Transparent images get their backdrop colours under the picture.** Until now the only way to change what shows through a transparent PNG or an SVG was a settings dialog in the host application - the viewer itself offered nothing. A file that really has transparency now shows a strip of swatches directly beneath the image: the checkered pattern first, then six greys, then twelve colours. Clicking one makes it the backdrop; the checkerboard swatch goes back to the transparency pattern. A file without transparency shows no strip at all, so nothing is given up where it would mean nothing. - `UltraCanvasMediaViewer::SetTransparencyPaletteVisible` turns the strip off for hosts with their own chooser, `GetTransparencyPalette()` hands out the element for different colours or metrics, and `onTransparentBackgroundChanged` reports what was picked. The strip also follows `SetTransparentBackground()` / `SetTransparentColor()` set from anywhere else, marking the matching swatch. - **`UltraCanvasColorSwatchBar`** is the new element behind it (`include/` + `core/`), for anywhere a full colour picker is too much furniture: a strip of colours, an optional leading checkerboard entry, hover and selection outlines, per-swatch hex tooltips, `onColorSelected` / `onCheckeredSelected`. It **sizes its swatches to the space it is given** - growing towards the preferred size, shrinking towards the minimum - so the same palette fits a narrow preview pane and a full window, which a fixed row of buttons cannot do. It never takes the keyboard focus, so a host keeps its own arrow keys. Ready-made palettes: `GrayscalePalette()`, `ColorPalette()`, `DefaultPalette()`. - **`UCImage::HasTransparency()`** answers whether anything behind an image can show through it: an alpha channel that is actually used, or a vector document (SVG), which paints over whatever is beneath it. The fully opaque alpha channel a PNG export routinely carries counts as opaque - the channel's minimum settles it, except on images too large to scan on the way to the screen, where its presence is taken at face value. Worked out once per image and kept. - **UltraFiler** saves a colour picked from the strip in the preview pane, so the next preview opens with it - the same setting as *Settings > Media Viewer > Transparent Images*. #### 2026-08-25 *0.3.68* - **The media viewer's PDF page zooms like a picture now.** The wheel over the page zooms about the pointer - the spot under the cursor stays under it - and the keyboard steps with `+` / `-`, fits the page with `0`, shows actual size with `1` and fits the width with `W`. The image surface has always zoomed on the plain wheel; the PDF view scrolled and left zooming to Ctrl+wheel, so the same gesture did two different things depending on the file. Both are reachable either way: `UltraCanvasPDFView::SetWheelAction` picks what the plain wheel does and Ctrl+wheel always does the other, the media viewer asks for `Zoom` (`SetDocumentWheelZoom(false)` puts scrolling back), and a bare `UltraCanvasPDFView` keeps its old default of scrolling. The info bar reports the PDF's zoom the way it reports an image's. - **The PDF page inventory can be a fixed width instead of a share of the view.** The thumbnail strip was always a quarter of the view width capped at 160 px, which is right for a viewer window and wrong for a preview pane, where the same document's inventory changed size with the pane. `SetThumbnailWidthMode` now chooses: `Relative` (the shipped behaviour, the share is `SetThumbnailWidthFraction`) or `Absolute`, an exact thumbnail width in pixels (`SetThumbnailWidth`) that only gives way when the strip would take more than half a very narrow view. The media viewer forwards both (`SetPDFThumbnailWidth` / `SetPDFThumbnailWidthFraction`) and remembers the choice for documents opened later. - **UltraFiler: Settings > Display > PDF Inventory.** A new settings page sets the width of the page thumbnails in the preview's PDF inventory - a slider from 32 to 120 px (56 px by default, the width the preview pane ships with) or, in relative mode, a 5-40 % share of the preview's width. Moving either slider selects its own mode, the choice applies to the open preview immediately and is saved to `config.ini` (`display.pdf.inventory.mode` / `.width` / `.percent`). - The demo app's PDF example gained a strip-width toggle (`Strip: 25%` / `Strip: 56px`) next to the page-number style toggle. #### 2026-08-25 *0.3.67* - **The Filer's folder watching is the operating system's now, not a timer.** It shipped as a poll: a worker re-fingerprinted the shown folder every 1.5 s by scanning it, which meant up to a second and a half of latency and a full directory scan every interval whether anything happened or not - on a folder with thousands of entries, forever, for nothing. The new `UltraCanvasFolderWatcher` asks the system instead: inotify on Linux and BSD, `ReadDirectoryChangesW` on Windows. A change is seen the moment it happens and an idle folder costs nothing. - The watcher is a small service of its own (`UltraCanvasFolderWatcher.h`), not Filer-private: `Watch(path, onChanged)` / `Stop()`, one directory, not recursive. `Stop()` joins the backend thread, so no callback can arrive after it returns - which is what lets a caller's callback capture the caller. - Platform code lives under `OS//`; the core file makes no operating system calls. Platforms without a backend (macOS, Android, WebAssembly) return false from `Watch()` and the Filer keeps the polling worker it already had, so nothing regresses there and adding a backend later touches nothing above the header. - `UltraCanvasFilerWidget::IsFolderWatchNative()` reports which of the two is running. The watch interval now governs detection only while polling; with a native watcher it bounds only how quickly the UI applies the change. - Everything the poll fed into is unchanged: the refresh is still held back while an open rename editor, a drag, a marquee, a context menu or a file operation owns the view. - **`FolderWatcherTest`** covers the contract on every platform - that changes are reported, that unwatchable paths are refused, that `Stop()` is final and repeatable, and that a watcher can be re-pointed at another folder. It builds from the watcher's own sources, so it needs no display and no library, and on a platform with no backend it asserts exactly that instead of being skipped. #### 2026-08-25 *0.3.66* - **Spell checking, and text areas that use it.** The framework had no spell checker at all. `UltraCanvasSpellChecker` adds one: a service owning a backend, a user dictionary, a session ignore list and a worker thread, so a dictionary lookup never happens on the render thread. Backends sit behind the UltraCanvas-owned `ISpellCheckBackend` and are picked at runtime — enchant-2 on Linux, ISpellChecker on Windows 8+, NSSpellChecker on macOS, Hunspell everywhere else and as the fallback. All of it is optional: with nothing installed the module compiles, reports zero dictionaries and does nothing, rather than failing the build. `UltraCanvasTextArea::SetSpellCheckEnabled(true)` is the whole integration for an application — misspellings get a squiggle, right-click offers suggestions plus Add to Dictionary and Ignore, and a chosen suggestion is applied as an ordinary undoable edit. One line puts the language menu in a menu bar: `UltraCanvasSpellChecker::BuildSpellCheckMenu()` lists only the dictionaries actually installed, as a radio group that shows which one is active, and rebuilds itself each time it opens so the host never has to. - **Text areas can say where a character range is on screen.** `TextArea:: GetCharacterRangeBounds(startByte, byteLength)` maps a byte range of the document to the rectangles covering it, accounting for soft wrap, both scroll offsets, the line-number gutter, sharded long lines and markdown mode (where rendered runs do not match source bytes one-to-one). One rectangle per visual line. Nothing equivalent existed, and it is what search-result highlighting, inline diff marks, comment anchors and collaborative cursors all need. `ReplaceTextRange(startByte, byteLength, text)` is its counterpart, replacing a range through the selection and undo machinery so the edit behaves like a typed one. #### 2026-08-24 *0.3.65* - **The numeric keypad's Enter finishes a text entry.** `UltraCanvasTextInput` only ever looked for `UCKeys::Return`, so the keypad's Enter — reported as a key code of its own (`UCKeys::NumPadEnter`) by the X11, macOS and WASM backends; only Windows folds both onto one code — fell through unhandled: the Filer's inline rename accepted the typed name and then did nothing on Enter, leaving the file under its old name, and every other field that ends on Enter behaved the same way. Both keys now commit (and both insert a newline in a multi-line input). The Filer widget itself follows: the keypad Enter opens the selected entry and commits the compress dialog, like the main one. - **Selected text is no longer washed out.** The selection band was filled *over* the glyphs, so a translucent `selectionColor` faded the text under it — most visibly in the Filer's rename editor, which opens with the base name selected and showed it in a pale gray. The band is now painted behind the text. - **The Filer's rename editor writes in a dark gray** (`FilerStyle::renameTextColor`, `Color(60, 60, 66)`), a step lighter than the near-black of a displayed name, so an entry being edited reads as being edited. The caret follows the same color. #### 2026-08-24 *0.3.64* - **Renaming and deleting a previewed file no longer fail the way moving one did.** The move path was taught to let go of its sources first (0.3.63); the rename and delete paths were not, and they need the file just as much - a rename is refused while another program holds it open, on Windows outright, and so is a delete. Rename is the most exposed of the three: the entry stays selected for as long as the editor is open, so a host preview pane is certainly showing it. Both now drop the entries out of the selection first, which closes that preview synchronously, and both put the selection back afterwards - rename onto the new name, delete onto the neighbour when `SetSelectNextAfterDelete` is on - so the commands that need a single selected entry (F2 and the Rename button above all) keep finding one. Only a sole selection can be previewed, which is also the only shape the rename path produces, so the release is scoped to exactly that case and a multi-selection delete is unaffected. - **"Open with" proposes real applications on Windows and macOS.** The submenu knew only what the Linux backend could enumerate; everywhere else it offered nothing but "Other application…". Both remaining desktop backends of `UltraCanvasFileAssociations` are now implemented, so the menu lists what the system actually registers for the selected files — with the default application first and each entry's own icon. - **Windows:** `SHAssocEnumHandlers(".ext", ASSOC_FILTER_RECOMMENDED)` — the very list Explorer shows — with names from `IAssocHandler::GetUIName` and the default marked from `AssocQueryString`. Picking one launches it through `IAssocHandler::CreateInvoker`/`Invoke` on an `IDataObject` built from the whole selection, the same path Explorer takes, so multi-select opens one window and per-application quirks stay the shell's problem; a handler that refuses the selection gets the files one at a time, and a plain executable falls back to a detached `app.exe file…` launch. - **macOS:** `-[NSWorkspace URLsForApplicationsToOpenContentType:]` with the default from `URLForApplicationToOpenContentType:`, bundle display names, and launching via `openURLs:withApplicationAtURL:` (macOS 12+; older systems keep the previous default-open-only behaviour). - **Application icons** are extracted once and cached as PNG files (`%LOCALAPPDATA%\UltraCanvas\openwith-icons`, `~/Library/Caches/UltraCanvas/openwith-icons`), keyed by icon source, so a menu open stays a cache read and the extraction survives restarts. The Windows extraction is the shell icon path the filer already uses for `.exe`/`.dll`/`.ico` files, now shared through `OS/MSWindows/UltraCanvasWindowsIcons.h`. - Both platforms resolve per file extension and expire their entries after a minute, so changing a default association shows up without a restart. - **The Filer's context menu opens with "Open with", and clicking it opens the file.** The submenu moved to the top of the menu — opening a file is what the menu is opened for most often — and the entry itself is now an action: it opens the whole selection with the OS default application, exactly like a double-click, while hovering still opens the application list. "Display" moved the other way, down next to "Settings", where the view options belong. - **A submenu item can carry its own action.** `MenuItemData::onClick` on a `Submenu` item was ignored — activating such an entry only opened its child list. It now runs the action and closes the menu, and hovering opens the child list as before, which is what makes the Filer's "Open with" clickable. Submenu items without an `onClick` are unaffected. #### 2026-08-23 *0.3.63* - **The Filer notices changes made behind it.** The shown folder was only rescanned when the widget itself changed something, so a file another program saved into it, a finished download or a deleted file simply did not appear. A background worker now re-fingerprints the folder every 1.5 s (its own modification time folded together with every entry's name, size and modification time) and the widget rescans when the number moves — never on the UI thread, and never in the middle of something: an open rename editor, a drag, a marquee, a context menu or a file operation waiting on its dialog all hold the refresh back until they end. `SetFolderWatchEnabled()` / `SetFolderWatchIntervalMs()` configure it. - **Compress and extract run in the background, with a progress window.** They ran on the UI thread, so packing a few hundred megabytes froze the window with no sign of what was happening. They now run on a worker behind the new `UltraCanvasProgressDialog` — a circular ring (`UltraCanvasCircularProgressChart` in SingleRing style) with the percentage in its centre, the file being handled, and Cancel. Cancelling a pack deletes the half-written archive; cancelling an unpack keeps what it already wrote and stops the rest of a multi-archive run. - **VirtualFS reports bytes while creating an archive.** `CreateArchive` fired its progress callback once per top-level source and never filled in any byte count, and `AddDirectory` ignored the callback it was handed — so packing one folder produced exactly one progress report with nothing in it. The manager now measures the sources up front for `grandTotalBytes` and forwards a callback into `AddDirectory`, which reports every file before adding it. That is what makes an honest percentage possible; extraction already reported bytes. - **UltraCanvasTreeView: `SetRootVisible(false)`** hides the root row and draws its children as the top level, so a tree can show a forest of sections instead of one node with everything under it. The root still owns the nodes and is kept expanded; it is never drawn, hit-tested or counted as a row. - **UltraFiler: "Pinned" sits above "Computer"** in the folder tree, opens expanded, and is hidden entirely while nothing is pinned (an empty section is a header over nothing). - **UltraFiler: the sort-direction button shows the direction.** It carried a fixed `sort-alpha-down` icon that never changed, so the only way to tell which way the list was sorted was the caret in the Details header. It now paints `sort-up.svg` while ascending and `sort-down.svg` while descending, repainted from the filer's own state - which also covers the direction being changed from the context menu, from a column header, or by a folder's stored view. The Sort dropdown reads "Date modified" / "Date created" instead of the ambiguous "Modified" / "Created". - **UltraFiler: per-folder display state.** The view type and sort order are remembered per folder and restored on entry, so a picture folder can stay on large thumbnails by date while a source folder stays on details by name; a folder with no stored state keeps whatever the previous one used, which is what makes browsing feel continuous. Stored as `folderviews.txt` (the 400 most recently entered folders, least recent evicted) and cleared from Settings > History & Favorites. #### 2026-08-23 *0.3.62* - **The PDF view no longer holds the file it is showing open.** MuPDF opens a document as a *stream* (`fz_open_file`) and reads pages from it on demand, so `UltraCanvasPDFView` kept an operating system handle on the PDF for as long as it was displayed — which is what made moving the previewed file fail on Windows, where an open handle refuses a rename. Nothing else in the media viewer does this: images are rasterized into a pixmap, and text, spreadsheets, 3D models and e-books are parsed out of a buffer, all with the file closed again. `LoadFromPath()` now reads documents up to `SetMaxInMemoryBytes()` (256 MiB by default) into memory through the new `IPDFDocument::OpenInMemory()`, so no handle survives the call; a file past the limit still streams, because holding hundreds of megabytes of PDF in RAM is the worse trade. Measured with `/proc//fd` while previewing: the file descriptor on the PDF is present when streaming and gone when loaded into memory. - `OpenInMemory()` keeps the real path as the document's source, so `Save()` and `GetInfo()` are unaffected; only `SaveIncremental()` is unavailable on a memory-opened document (appending to a file needs a document backed by it) and now returns `false` instead of writing a broken file. `OpenFromBytes()` no longer copies its buffer twice. #### 2026-08-23 *0.3.61* - **UltraFiler: dropping a file on a folder moves it, even while it is being previewed.** A move is a rename, and a rename is refused while another program still holds the file open — on Windows outright. The previewed file was exactly that: the media preview keeps the document open (MuPDF for a PDF), so dragging the previewed file onto a subfolder failed instead of moving it. The Filer now drops the sources out of the selection before it starts a move, which fires `onSelectionChanged` synchronously, and `UltraCanvasMediaViewer::CloseFile()` makes the preview release the document rather than merely stop playback — the file is free by the time the rename runs. A move whose rename fails also no longer leaves the entry in two places: when the copy + delete fallback cannot remove the original the copy is undone and the rename's own error is what gets reported. - **The "cannot move / copy" dialog shows the whole failure.** It was sized for its message alone, so the switches added below it squeezed the text down to a sliver and the reason was unreadable — the very thing the dialog exists to say. `UltraCanvasModalDialog::AutoSizeToContent()` now counts the elements `AddDialogElement()` put in the message column, so every dialog with extra controls (the paste conflict dialog too) grows to fit both. The Filer's dialog is titled "Cannot Move" / "Cannot Copy" and spells out the operating system's reason, the source path, the destination folder and the usual cause. Footer buttons are now as wide as their label needs, never narrower than the configured width — "Continue" used to reach the user as "Conti…". - **UltraFiler: Settings > Handling > Drag & Drop.** A new settings page with **Drop on folder: Move files / Copy files**, persisted as `handling.dragdrop.drop.on.folder` and applied live to every open tab. Ctrl at the drop always copies and Shift always moves, whichever way it is set. New widget API: `UltraCanvasFilerWidget::SetDropOnFolderCopies()`. - **UltraFiler: New > Folder, on Ctrl+F.** The file display's *New >* submenu opens with **Folder**, above the document kinds and separated from them, and Ctrl+F does the same from the keyboard. Both go through the new `UltraCanvasFilerWidget::CreateNewFolder()`, which the command bar's "New folder" button now uses as well, so all three create the folder, record it in the History and open the inline rename editor identically. #### 2026-08-23 *0.3.60* - **UCImageRaster::GetMetadataString** reads one embedded metadata field (EXIF capture time, camera make/model) by its libvips name, stripping the trailing annotation and returning "" when absent. Nothing in UltraCanvas exposed EXIF before. Callers must treat "" as *unknown*: every photograph in the reference album carried either no EXIF or a zeroed timestamp, so UltraCleaner's time gate only applies where both pictures actually know when they were taken. - UltraCleaner now carries its own version, read from `Docs/UltraCleaner/CHANGELOG.md`; its entries move there and no longer bump the framework. #### 2026-08-23 *0.3.59* - **UltraFiler: the folder tree's colours are settings.** The drives in the tree — the drive roots on Windows, "File System" and every mounted volume under `/media` and `/mnt` elsewhere — now carry a background colour of their own, so they read as the section headings they are rather than as four more folders. The colour, and the highlight of the selected folder, are configured under **Display > Treeview** in the settings window: each is a colour box showing the current value that opens the `UltraCanvasColorPicker` in a popup window, with the colour previewed live in the tree while it is being picked, kept by "Use colour" and put back by Cancel. "Restore default colours" returns both. They persist as `tree.drive.background.color` / `tree.selected.folder.color` in `config.ini`. Drive rows take white text when the chosen background is dark, so a deep colour stays readable. - **Cairo/Pango backend: plain text no longer disappears from a markup layout.** `UCTextLayout::SetMarkup` handed the string straight to `pango_layout_set_markup`, which rejects the whole thing when it is not well-formed markup and leaves the layout EMPTY. Widgets that render user text through the markup path — tree node labels among them — therefore showed nothing at all for a label containing a bare `&` or `<`; UltraFiler's own settings tree had an invisible "History & Favorites" row. The markup is now parsed first (`pango_parse_markup`) and a string that is not markup is laid out as literal text instead of vanishing. #### 2026-08-22 *0.3.58* - **TabbedContainer: the overflow tab list showed only part of the tabs, and picking one did nothing.** With more tabs than fit the bar (UltraTexter with 47 open files), the "▼ 47" dropdown opened a list that ran off the bottom of the window: `UltraCanvasAutoComplete` sized its popup as `itemCount * itemHeight` with no regard for the window, so everything below the window edge was clipped away — unseen, unscrollable and unclickable — and no scrollbar appeared, because the ListView believed it was tall enough for all its rows. The popup is now clamped to the room actually available (below the field, or above it when there is more space there, and kept inside the window horizontally, as the Dropdown already did), so the surplus rows are simply scrolled to and every tab is reachable. `AutoCompleteStyle::maxVisibleItems` is now an upper bound rather than a promise of height. - Clicking an entry in that list also did nothing at all. The tabbed container refills the search list from `Arrange()`, and opening the popup invalidates the layout — so on the very next frame `SetItems()` cleared the AutoComplete's filtered vector while the ListView kept rendering its 47 rows. Every click then resolved against an empty vector and was dropped on the floor by `SelectItem()`'s bounds check, which is why the popup just closed and the tab bar never moved. `SetItems()` now re-filters instead of clearing when the popup is open, so the visible list can never come adrift from the data behind it, and the tabbed container leaves the list alone while it is on screen instead of rebuilding it (and discarding the user's filter) on every layout pass. - **FilerWidget: resizing the folder display no longer loses the file you were looking at.** Every view reflows when the display area changes size — a thumbnail grid re-wraps into a different number of columns, the List view re-columns, the treemap is rebuilt — so keeping the pixel scroll offset through a resize left the viewport on a completely unrelated part of a big folder: dragging the tree | folder split pane, or the UltraFiler's preview pane opening or closing next to it, made the selected (previewed) file jump off screen. The scroll offset is now re-derived from a reference entry instead of kept: the entry the viewport is anchored to is noted before the relayout — the selected entry while it is on screen, otherwise the first visible one — and put back at the same place in the viewport afterwards, with the usual reveal in case the reflow changed its size. This runs inside the layout pass for every view type, so any host that resizes the widget gets it without calling anything; a relayout at an unchanged size (a rescan, a view switch) keeps its own scroll position as before. #### 2026-08-22 *0.3.57* - **PDF view: the page inventory is laid out from its width, and the stray page badge is gone.** Every thumbnail slot in the strip used to be a fixed `thumbHeight` (180 px) tall whatever the page was, so a page drawn to fit the strip's width sat in a slot far taller than itself — bands of empty space above and below each page, and only a couple of thumbs visible at a time in a narrow view (the media viewer / UltraFiler preview). The strip now derives everything from its width: the effective strip width (still capped at 1/4 of the view) minus `PDFViewStyle::thumbMargin` on both sides is the thumbnail width, and each thumbnail is as tall as *its own* page's aspect ratio requires, so pages fill their slots exactly and a document mixing page sizes gets a correctly-sized thumb for each. `thumbMaxHeight` caps extreme formats by narrowing them rather than stretching them. Page numbers — the translucent overlay and the caption alike — are sized from the thumbnail they belong to (`thumbOverlayNumberHeight`, and the new `thumbLabelHeight` for captions), so they shrink with it instead of dwarfing a small page. Thumbnails render at the size the layout asks for and re-render when the strip is resized; the page aspect ratios are cached per document, so a resize is arithmetic rather than a round trip to the engine for every page. The black "N / M" pill floating over the top-right of the page is removed: it was not interactive and only repeated what the host's status bar (media viewer, UltraFiler) already shows. `PDFViewStyle::thumbHeight` is gone, replaced by `thumbMargin` / `thumbMaxHeight`. The page's own margin to the edges of the display area (`pageMargin`) is halved, 24 px to 12 px, so a fitted page uses the space it is given instead of floating in it — most visible on a single-page document, where there is no thumbnail strip beside it. #### 2026-08-22 *0.3.56* - **New application: UltraCleaner.** Finds and removes the files macOS, Windows and Linux leave behind — temporary files, application and browser caches, logs, crash reports, thumbnail databases, package-manager downloads (npm/Yarn/pip/Gradle/Cargo/Go/Composer/Maven/NuGet), developer leftovers (Xcode derived data, simulator and IDE caches) and the trash — and shows exactly which paths it proposes to remove before touching anything. `Apps/UltraCleaner` builds two targets: `UltraCleanerEngine`, a headless static library, and `UltraCleaner`, the GUI on top of it. - **The rule table is the whole surface.** Every location the application can examine comes from a `CleanRule` in `engine/UltraCleanerRules.cpp`, written with tokens (`{HOME}`, `{CACHE}`, `{LOCALAPPDATA}`, `{WINDIR}`, …) rather than literal paths, so one row covers all three platforms and a root that means nothing on the running system is skipped rather than mis-resolved. Reviewing that one file is enough to know what the application can touch per OS. - **Two checks, not one.** `PathGuard` refuses anything that is not strictly inside a resolved rule root, is or contains a protected location (the home directory, Documents/Desktop/Downloads/Pictures, `.ssh`, `.gnupg`, `.config`, `.local`, `Library`, `AppData`, the cloud-sync folders, the OS roots), sits fewer than two levels below the filesystem root, resolves through a symlink that escapes its root, or is a socket, fifo or device node. It runs during the scan and again during the removal, rebuilt from the report's own allowed roots — so a report whose items changed in between still cannot reach outside them. - **Nothing goes by accident.** Removal defaults to Simulate; the other modes are move-to-trash (XDG `.trashinfo` records on Linux, `~/.Trash` on macOS, `FOF_ALLOWUNDO` through the shell on Windows) and permanent delete, which the GUI confirms and the CLI gates behind `--yes`. Categories whose removal costs something unexpected — emptying the trash, a Maven repository, old installers in Downloads — arrive unticked. - **UI.** Toolbar, a category panel of `UltraCanvasCheckbox` (three-state: a category is indeterminate when only some of its paths are ticked) plus `UltraCanvasBadge` sizes, and an `UltraCanvasTableView` naming every path with its size, age and originating rule; double-clicking a row keeps or drops that one path. Scanning and cleaning run on a worker thread and marshal back through a UI-timer queue, so the window stays responsive and Stop works. - **Headless too:** `--scan`, `--rules`, `--clean [--trash|--delete --yes] [--all]` run the same engine without a display. - Engine test suite in `Tests/UltraCleaner` (target `UltraCleanerEngineTests`, `-DULTRACANVAS_BUILD_ULTRACLEANER_TESTS=ON`): 49 tests over the path helpers, the guard, the rule table's structure, the scanner and the remover, all driven across temporary trees. - Documentation: `Docs/UltraCleaner/README.md`. #### 2026-08-22 *0.3.55* - **FilerWidget / UltraFiler: a folder of videos no longer makes a sound (Windows).** Opening a folder with video files in it could play a burst of a clip's audio, and the preview pane's "5 s clip" video mode ran with sound instead of muted. Both came from the same hole in the Media Foundation backend: `VideoDecodeOptions::disableAudio` and `SetMute` were realised through the renderer's stream volume service, which only exists once the Media Session has resolved its topology — so the mute a poster-frame grab (the Filer's video thumbnails) or a muted preview applies at open time was silently dropped and the streaming audio renderer played at full level. `disableAudio` now keeps the audio stream out of the topology altogether — no renderer to be heard, and the output device is never opened — matching what GStreamer already did, and any volume/mute requested before the renderer exists is replayed once it does (`MF_TOPOSTATUS_READY` / `MESessionStarted`) instead of being lost. The media viewer also decides the preview mute *before* opening the source, so the engine builds a muted session rather than muting one already wired for sound, and a finished preview clip now stays muted while it sits paused — un-muting at the pause could let the sound out when a backend was still deferring that pause behind an in-flight seek. The sound comes back on the user's own resume from the transport bar. A Media Foundation session now also reads its duration when it opens rather than when its topology resolves, so a poster grab asking for the frame "10% in" gets it instead of settling for the black first frame. #### 2026-08-21 *0.3.54* - **Filer widget: double-click runs applications on POSIX platforms.** Activating an executable used to go through the MIME machinery, which opens files but never runs them — so native binaries and AppImages did nothing on Linux (Windows always worked: ShellExecute's "open" verb runs executables). Now `FileAssociations::ClassifyExecutable` sniffs an execute-permission file's content — ELF (AppImages included) and Mach-O run directly via `LaunchExecutable` (detached, double-fork + setsid, the file's folder as working directory); a `#!` script asks Run / Open / Cancel first; a file whose execute bit lies (FAT mounts) still just opens with its default application. The widget's new `OpenEntryWithOS(entry)` bundles these Explorer semantics for hosts with their own `onFileActivated`; UltraFiler routes through it. - **Filer widget: embedded application icons on Windows.** `.exe`, `.dll` and `.ico` entries now show the icon embedded in the file — what Explorer shows — instead of the generic EXE/DLL glyph, in every view from the Details icon column to the largest thumbnail tiles. Extraction goes through the shell (`SHDefExtractIconW`, nearest embedded size up to 256 px, alpha-masked legacy icons handled) on the existing background thumbnail workers, so folders of executables stay smooth; files without an icon resource keep their glyph, and the Display > Preview switches are not involved — this is an icon, not a content preview. New `UltraCanvasNativeFileIcons.h` platform API (`NativeFileIconAvailable` / `LoadNativeFileIconPixmap`) with the Windows extractor in `OS/MSWindows/UltraCanvasWindowsFileIcons.cpp` and no-op stubs elsewhere, so other platforms are unchanged. - **Filer widget: the remaining "ask the user" gaps.** Every operation that silently invented " (2)" names or only logged an error now asks, in the same exclusive-switch dialog style. Drag & drop — inside the widget and drops arriving from other applications — runs through the paste machinery, so taken names raise the paste conflict dialog. An entry that *fails* to paste (locked, in use) asks **Try again** / **Skip** with the same one-silent-retry-then-ask "for all" semantics as delete. Renaming onto an existing name asks **Replace** / **Cancel** instead of refusing into the status bar. `ExtractSelection()` with a taken destination folder name asks **Keep both** (renamed folder) / **Extract into the existing folder** (merge) / **Skip this archive**, with a "do this for all remaining archives" switch. The exclusive-switch group and the two-choice problem dialog are factored into shared helpers, and a cut is now consumed by its own clipboard paste only — a drag-move no longer clears an unrelated pending cut. - **Filer widget: delete problem dialog.** A delete that runs into trouble no longer just logs to `onError`: a write-protected (locked) entry asks *before* the attempt — **Delete it anyway** (lifting the protection first, so it also works on Windows) / **Skip this file**, Skip preselected — and a failed delete asks *afterwards* with the failure reason ("may be locked or in use by another program") — **Try again** / **Skip this file**, Try again preselected. Both flavors carry a "Do this for all remaining …" scope switch and Continue / Cancel buttons in the same exclusive-switch style as the paste conflict dialog; Cancel keeps what was already deleted. A stored try-again-for-all grants each later failing entry one silent retry before asking again, so nothing can loop forever. Archive-batch deletions and the no-dialogs fallback keep the previous behavior, and `onFolderModified` now reports only folders that really lost an entry. - **Filer widget: paste conflict dialog.** Pasting an entry whose name is already taken in the target folder no longer silently invents a " (2)" name: the paste pauses on a conflict dialog whose choice is set by three exclusive switches — **Keep both** (the pasted entry takes the next free " (2)" style name; the default), **Replace the existing file**, **Skip this file** — plus a **"Do this for all remaining conflicts"** switch that decides whether the next conflict asks again (off, the default) or reuses the choice. **Continue** proceeds, **Cancel** keeps what was already pasted and drops the rest. Copy-pasting a file alongside its original never asks (the copy takes the next free name, like Duplicate), and with dialogs unavailable every conflict falls back to keep-both — the previous fixed behavior. The machinery is public as `PasteFilesInto(folder, paths, cut, onDone)` so hosts can aim a paste at any folder (UltraFiler's tree context menu now routes through it, gaining the dialog too); with `onDone` set the caller owns the post-paste work and learns whether anything changed. A folder can no longer be pasted into itself from the widget either — previously only app-side paste guarded against that. #### 2026-08-20 *0.3.53* - **Chart engine: themes and palettes.** The engine grew the theming home the proposal reserved (`Engine/UltraCanvasChartTheme.h`): a `ChartTheme` bundles the furniture colours (background, plot area, grid, axes, title, legend) with a `ChartPalette` of series colours, applied with `SetTheme(theme)` / `SetTheme("name")` / `SetPalette(palette)` and — for by-name creators — `SetProperty("theme", "Dark")`. Fourteen built-in themes, their palettes lifted from the definitions the pre-engine charts each carried privately (Light/Bright, Dark, Corporate, Vibrant, Pastel, Colorblind, Material, Classic, Tableau, and the Ocean/Sunset/Forest/Slate/ Monochrome ramps). Palettes answer `ColorAt(index)` (cycling, with wrapped cycles re-tinted so long runs never repeat exactly) and `ColorAt(index, count)` (ramps spread across their run when the element count is known); `ChartPalette::FromColormap(Viridis, n)` samples any `UltraCanvasColormap` map into a palette of the requested size. A theme change is repaint-only — no layout, no label re-solve — with an `OnThemeChanged()` hook for content that caches theme colours (legend entries). Pastel carries soft warm-grey furniture of its own, and is the Chart Engine demo's default look; the demo gained a Theme row and its bar edges follow the bar colour (darkened) instead of a fixed near-black. Model-layer tests cover the registry, cycling, count-aware selection and colormap sampling. - **Chart engine: the legend is the shared ChartLegend component.** The engine's private right-side-only legend is gone; `SetShowLegend` / `SetLegendEntries` now drive the shared component (`UltraCanvasChartLegend`), which brings the full option set: `SetLegendPosition` with 12 outside placements (Top/Bottom/Left/Right × Start/Center/End, each reserving its edge in the layout negotiation) plus 4 inset corners that float over the plot (reserving nothing, but riding the label plan as an obstacle), `SetLegendOrientation` (Auto/Horizontal/Vertical with row wrapping), `SetLegendTitle`, and `Legend()` for value text, interval entries, overflow capping and label formatting. The shared component gained the engine's paint-mirroring swatches (Outline, Hatched, Image, and a real Gradient ramp), so its swatch vocabulary now spans Square, Circle, Ring, Line, DashedLine, Marker, Glyph, Gradient, Outline, Hatched and Image — and the engine legend follows the active chart theme. This also removes the duplicate `ChartLegendEntry` type the engine declared in parallel with the shared one. New `SetCustomArea(size, draw)` reserves a host-drawn panel below the entries for keys richer than any swatch (an annotated confidence-ellipse diagram, a bubble-size scale); the demo's Inset mode keys the chart's limiter reference lines — a key describes marks the chart actually draws. The engine legend is interactive: hover highlights an entry, a click toggles it (dimmed) and notifies the chart via `OnLegendEntryToggled` — the demo hides the series, stacks re-solving without it. Tall vertical legends now wrap into further columns instead of silently clipping (a `ChartLegend` fix that also benefits the diagrams already using the component). The component grew the engine proposal's continuous modes — `SetMode(ColorBar)` draws a colormap ramp over a value range with tick labels (optionally quantized into bands), `SetMode(SizeLegend)` draws sample circles keying a bubble-size scale — and the legacy charts' private legends (polar, circular progress, funnel, pyramid, Mekko, dumbbell, cumulative flow, population, nested area, arc diagram, and the contour surfaces' colour bars) were migrated onto the shared component, retiring their incompatible position enums' private implementations while keeping every public API working. - **Chart engine: the highlight layer (proposal §8.2).** `AddHighlight` / `ClearHighlights` bring group washes to slot 200 (under the grid) and overlays to slot 700: explicit rectangles/ellipses in value space, value bands, and the computed shapes — **confidence ellipses** (covariance eigen decomposition at 50%/95% with a mean marker, the group-of-dots scatter convention), padded convex hulls, Chaikin-smoothed blobs and point halos. Highlight captions ride the label plan as `HighlightLabel`. The geometry (`ComputeConfidenceEllipse`, hull, expand, smooth) is UI-free in `Engine/UltraCanvasChartHighlights` and unit-tested. The demo gained a Highlight row (Band / Ellipse / Blob); the ellipse style brings the legend's 50%/95% ellipse key with it, since a key describes marks the chart actually draws. - **Chart engine: value labels survive the axis maximum.** The label solver was clamped to the plot area, so a bar reaching the axis limit had its value label pushed down onto the bar. The solver's bounds now also include the margins the chart content reserved for itself in `MeasureContent` (`SolveLabelBounds`) - the spill band above the bars (or right of them under the horizontal projection) - so the label sits just above the plot edge instead. Axis bands, the title band and the legend margin remain out of bounds. - **FilerWidget / UltraFiler: the context menu's Extract got the same dialog as Compress.** Extract used to unpack immediately with no way to pick the destination; it now opens the compress dialog's panel in extract mode: the archive's file-type icon with its name (or "N archives") beneath, an editable destination **folder** name — suggested from the archive's name without its suffix, so `sources.tar.gz` offers `sources` — and the location line. The icon can be dragged onto any folder in the view to retarget the destination, Enter / Extract unpacks, Esc / Cancel dismisses, and an existing folder name gets the usual " (2)" suffix instead of being written into. Several selected archives each unpack into their own subfolder of the named folder so their contents cannot collide. Unpacking still runs through the VirtualFS bridge (`UCVFSBridge::ExtractArchive`); the dialog-free `ExtractSelection()` remains for programmatic use, and the new `OpenExtractDialog()` is public for hosts. - **Audio player: a finished track no longer restarts itself.** When a non-looping source played to its end, the output device kept pulling frames and the playback cursor had been reset to 0 — so the track audibly started over from the beginning while the player reported *Stopped* and the transport showed the Play icon. This is what made the UltraFiler preview with auto-play look like "it plays but never shows the Pause icon": the first pass played with the correct Pause icon, then looped forever in the stopped state. `UltraCanvasAudioPlayer` now feeds silence after end of stream until the next transport call, `onEnded` fires exactly once, and `UltraCanvasAudioPlayerElement` stops the device when it learns the track ended. `Play()` after the end still restarts from 0:00. - **Audio player element: UI work moved off the audio thread.** The backend delivers position updates, end-of-stream and the resulting state change on its audio thread, and the element used to update its label, sliders and icons directly from those callbacks — racing the UI thread's layout, text-measurement and dirty-rectangle bookkeeping (reproducibly crashing in pango under load, and losing repaints such as the play/pause icon refresh). All player callbacks are now marshalled to the UI thread (`PostToUIThread`), the transport icons also re-sync from the UI-thread position timer, and the element disconnects its callbacks on destruction. - **Audio player element: narrow hosts no longer clip the volume slider.** All controls had fixed widths, so in a narrow host (the UltraFiler preview pane goes down to ~260px) the flex row overflowed: the seek bar collapsed to zero and the volume slider ran off the pane's right edge (the bug report's screenshot). The row is now responsive — when the width cannot fit everything beside a usable seek bar it hides the volume slider first, then the time label; the mute button stays so the sound can still be silenced, and everything returns as soon as the element is wide enough. #### 2026-08-17 *0.3.52* - **FilerWidget: balanced line breaks for wrapped tile captions.** A name that needs two or three caption lines was broken greedily — the first line took everything that fit and the rest became a stub, "CoderBox compiler" / ".png". A name that fits its lines completely is now re-broken at the smallest line width that still needs no extra line, so the lines come out near equal: "CoderBox" / "compiler.png", "Diagram" / "Wordcloud.png". The line count — and with it the caption band and tile height — never changes, and names too long even for `captionMaxLines` keep the greedy break with the leading-"…" last line, whose every line is full anyway. Applies to the thumbnail grids and the treemap in the FilerWidget and everything built on it (UltraFiler, file dialogs). - **UltraFiler: Pin / Unpin with state flags in the context menus, and "Open prompt" is back.** The filer context menus' Extras submenu ends with an app-provided block (the FilerWidget's new `extrasMenuProvider` hook): **Open prompt** — which had lost its home when the menu bar was dropped — then **Pin** and **Unpin** submenus, each with "To Treeview" / "To Favorites" entries acting on the current selection (or the shown folder while nothing is selected). The entries are check items whose flag shows whether the selection is pinned there right now — Pin is enabled while something is still unpinned, Unpin while something is pinned. The folder tree's context menu gets the same **Pin** submenu between the file commands and Unpin; there the "To Treeview" / "To Favorites" flags directly toggle the folder's pin in the tree's Pinned section / the Favorites view's Folders tab. All menus build their items when they open, so the flags always reflect the current pin state. #### 2026-08-17 *0.3.51* - **FilerWidget / UltraFiler: empty displays say so.** A folder with no content used to show only a small "(empty folder)" line; it now draws a vertically centered notice — an attention icon (a vector-drawn warning triangle, no icon assets involved) with **"Folder is empty!"** beneath it. An empty file-list display shows **"No entries"** the same way, which is what the UltraFiler's History and Favorites tabs show before anything was recorded or pinned (and a search without matches). A widget that never had a folder set keeps the plain "(no folder)" text. #### 2026-08-13 *0.3.50* - **PDFView / UltraFiler preview: five fixes to the PDF page view and its thumbnail strip ("page inventory").** **Stale thumbnails** — opening another PDF kept showing the previous file's thumbnails, because the thumbnail cache deliberately survives zoom changes and the document switch reused that same invalidation. `SetDocument` and every page-mutating operation (delete/move/insert/merge/replace-text/redact) now drop the thumbnail cache too (`InvalidateAllCaches()`), while zoom/resize keep it as before. **Single-page documents show no strip** — a one-page PDF needs no page inventory, so the strip only appears for documents with more than one page. **Wheel-scrolling reads through the document, with hard limits** — before, the view scrolled the one page endlessly into empty space and never advanced. Scrolling now stops once the page edge sits a page-margin inside the viewport; from that resting point the next wheel step continues at the top of the next page (and up past the top edge, at the bottom of the previous page), while on the last/first page the margin is the end of the line. Pages open at their top instead of vertically centered, the strip auto-scrolls so the current page's thumbnail stays visible, and its own scrolling is clamped to its content. **Page area at least 3× the strip** — the strip's effective width is capped at 1/4 of the view, so a narrow preview pane can no longer end up mostly inventory with a tiny page. **"Over the page" numbering in the viewer** — the MediaViewer (UltraFiler's preview pane) now uses `ThumbnailNumberStyle::Overlay`, the large translucent number over the thumbnail page, instead of the caption beneath; slot heights no longer reserve the caption row in that style. #### 2026-08-12 *0.3.49* - **FilerWidget / UltraFiler: hidden files are now the platform's notion, not just dot names.** "Hidden" was tested as `name[0] == '.'` everywhere, a test that never fires on Windows — so a profile folder listed the `NTUSER.DAT` registry hives, `AppData` and the localized hidden compatibility junctions (`Anwendungsdaten`, `Lokale Einstellungen`, `Startmenü`, …) that Explorer never shows, and on macOS `~/Library` was visible. The widget's scans now read the Windows `FILE_ATTRIBUTE_HIDDEN` attribute and the macOS `UF_HIDDEN` file flag inside the one metadata call each entry already paid for (on Windows via `GetFileAttributesExW`, which returns attributes, size and times together — replacing `::stat`, which cannot see attribute bits), so scan cost is unchanged. The UltraFiler's folder tree and recursive search use the same test through the new `UltraCanvas::IsHiddenFileSystemEntry(path)` (`UltraCanvasUtils.h`). - **UltraFiler: the Home tree section is curated like the Explorer / Finder sidebars.** Expanding Home now leads with the user's well-known folders — Desktop, Documents, Downloads, Music, Pictures, Videos (plus Public / Templates where the OS defines them) — each with its own icon, resolved through the new `UltraCanvas::GetWellKnownUserFolders()`: `SHGetKnownFolderPath` on Windows (follows folder redirection, e.g. a Documents folder moved into OneDrive), the fixed home subfolders on macOS, and `xdg-user-dirs` on Linux (localized folder names; entries pointing at `$HOME` are disabled per the spec). The remaining visible home folders follow alphabetically, with a well-known folder that physically sits in the home folder not listed twice; the Home node itself now wears the home icon. - **Changelog: resolved the duplicate *0.3.44* version number** left by the Breadcrumb merge — its entry (the newer of the two) is now *0.3.48*, so the first-line version the build derives moves forward again instead of regressing below the *0.3.47* beneath it. #### 2026-08-11 *0.3.48* - **Breadcrumb**: four fixes to the per-item dropdowns, all visible in the Filer's and the Media Viewer's path strip. **An empty list gets no control** — a folder with no sub-folders now shows no dropdown chevron and reserves no click area instead of opening a menu that only says "(no sub-folders)". Lazily filled dropdowns answer the question through the new `BreadcrumbItem::dropdownAvailableProvider`, a cheap "is there a first entry?" probe cached per item (`UltraCanvasBreadcrumb::RefreshDropdownAvailability()` clears it); `hasDropdown` with nothing behind it no longer draws a chevron either. **Hover no longer hides the label**: the current item keeps its emphasis text colour through hover and press, and its feedback background is derived from `currentItemBackgroundColor` (tinted towards the other end of the luminance scale) instead of the generic hover colour, which turned the Filer's blue current segment pale while its label stayed white. New `currentItemHoverBackgroundColor` / `currentItemPressedBackgroundColor` override the derived colours, and `minTextContrastRatio` (2.2 by default, 0 disables) redraws a label black or white when it cannot be read against its own opaque background. The `Steps` preset's hover/press label colours and the `Parallelogram` preset's current-item label were unreadable on their own backgrounds and were fixed at the source. **Dropdown entries sort alphabetically** (case-insensitive, by the displayed name — sorting full paths put every capitalised folder ahead of every lower-case one), opt-in per item via `BreadcrumbItem::sortDropdownItems`. **The dropdown click area is a full-height zone**, at least `BreadcrumbStyle::dropdownHitAreaMinWidth` (24px) wide, covering the chevron, the gap in front of it and the item's trailing padding — and, in the `Arrow` / `Parallelogram` styles, extending over the tip drawn past the segment's right edge, so the whole arrow head opens the menu. It never takes more than the trailing half of an item, so the label keeps a clickable area of its own. #### 2026-08-11 *0.3.47* - **macOS: a classic USB mouse wheel is responsive again.** `UCEvent::wheelDelta` is an integer notch count — the X11 backend emits ±1 per button-4/5 press, the Win32 one divides `WM_MOUSEWHEEL` by `WHEEL_DELTA` and guards the result against rounding to zero — but the macOS backend assigned `NSEvent.scrollingDeltaY` straight into it. macOS applies scroll acceleration to a classic wheel and reports it in *lines*, so a single slow notch arrives as a fraction (~0.1) and truncated to 0, while a trackpad or Magic Mouse reports *points*, tens per gesture, and always survived the truncation. Worse than losing the notch: a zero delta is not "no scroll" to widgets, most of which read `wheelDelta > 0 ? up : down`, so it landed in the down branch — in the 3D scatter / contour charts the wheel zoomed *out* whichever way it was turned. Wheel events now round to a notch, never report a real notch as zero, and are not delivered at all when there is no vertical movement (which also stops AppKit's zero-delta gesture / momentum phase events, and horizontal swipes, from registering as scrolls down). Trackpad scroll distances are unchanged. #### 2026-08-11 *0.3.46* - **macOS: double-click now works at all.** The Cocoa event conversion only ever produced `MouseDown` / `MouseUp`, so `UCEventType::MouseDoubleClick` was never raised on macOS and every handler waiting for it was dead code — double-clicking a folder or file in the Filer did nothing, and the same held for each of the ~37 double-click handlers across the framework. A mouse-down now consults AppKit's `NSEvent.clickCount` (which already honours the double-click interval from System Settings) and the doubled press is delivered as `MouseDoubleClick` *instead of* `MouseDown`, matching the X11 and Win32 backends — the first click selects, the second activates — with pairs counted (2, 4, 6 …) so a triple click's third press is an ordinary `MouseDown` there too. The unused hand-rolled click-tracking state (`MouseClickInfo`, `IsDoubleClick`, `UpdateLastClick` — declared, never defined or called) is gone with it. #### 2026-08-11 *0.3.45* - **macOS: frames rendered without a Cocoa event now reach the screen.** The content view is layer-backed, so `setNeedsDisplay:` only queued a layer display: `drawRect:` (the blit of the Cairo surface) and the CoreAnimation commit that puts it on screen both happened at the end of *AppKit's* event cycle, which this framework does not run — its loop blocks in `CFRunLoopRunInMode(..., returnAfterSourceHandled: true)` and returns as soon as the cross-thread wake-up source is handled, before the run-loop observers AppKit relies on fire. Any repaint not provoked by an input event therefore stayed invisible until the next mouse move: opening a folder in one of the Filer's thumbnail views showed no thumbnails at all until the cursor was moved, and the same applied to every other `PostToUIThread` result (video poster frames, network completions) and to timer-driven repaints. `InvalidateWindowNative()` now draws the view immediately and `UltraCanvasMacOSApplication::RunInEventLoop()` flushes the CoreAnimation transaction once per main-loop iteration, outside any AppKit display callback. Linux and Windows were unaffected — their surfaces present on flush. #### 2026-08-11 *0.3.44* - **UltraCanvasAlbum** *(1.7.0)*: video tiles now make their own covers. A `Video` item whose `thumbnailPath` is empty — or points at an image that does not decode — has one representative frame extracted from its clip on a background worker (`AlbumConfig::videoPosterFrames`, on by default, with `videoPosterMaxSize` / `videoPosterTimeSec`), cached in memory by media path and repainted in place, reflowing the aspect-driven layouts around the real frame. Nothing is written to disk, which is what fixes macOS: the previous approach cached poster files next to the clips, impossible inside a code-signed `.app` bundle (and equally in an AppImage or any read-only install), so every video tile in the demo's album fell back to the play-triangle placeholder. An explicit cover that decodes still wins, work is queued only by tiles that actually draw, and with no video backend (or an undecodable clip) the slot fails once and the placeholder stays. The DemoApp album example (2.18.0) dropped its `SaveVideoThumbnail` pre-pass, which also removes a synchronous decode per clip from building the page. #### 2026-08-09 *0.3.43* - **UltraSocial** *(Phase 3)*: the Tier-3 networks and media for Tier 2. **LinkedIn connector** — OAuth2 code flow for the user's own confidential-client app (secret in the form body, no PKCE; redirect port 17997), member id via OpenID `userinfo`, text posts through the versioned `POST /rest/posts` (post URN read from the `x-restli-id` response header), token refresh when the app has it granted. **Facebook Pages connector** — pasted Page id + long-lived Page access token (personal profiles have no posting API); text to `/{page}/feed`, one photo + caption to `/{page}/photos` as multipart (no public URL needed); Meta's `{"error":{...}}` shape added to the shared error surface. **X media** — images upload via the v2 media endpoint and attach as `media_ids` (4 × ≤5 MB), inside the same refresh-retry as text. **Telegram albums** — 2–10 photos via `sendMediaGroup` (`attach://` multipart, caption on the first). Wizard forms for both new networks. 10 new engine tests (47 total). #### 2026-08-09 *0.3.42* - **UltraSocial** *(Phase 2)*: the "automatically" part plus the Tier-2 networks. **Scheduling outbox** — "Post later…" opens a date + time dialog and queues one outbox row per selected account (UltraDatabase, raw draft stored so adaptation happens at send time); a scheduler timer flushes due entries through the same `UltraSocialPublisher` path as "Post now", with bounded retries on linear backoff (5 attempts, +5 min × attempt) before a failed history row; queued posts show as closable chips (with retry count) and go out at next launch when they came due while the app was closed. **Reddit connector** — OAuth2 "installed app" code flow built from the UltraNetOAuth2 blocks (Reddit has no PKCE; token exchange authenticates HTTP Basic `clientid:` with an empty password), self posts via `/api/submit` with the draft's first line as the title, hourly-token refresh on 401. **X connector** — OAuth2 code + PKCE public client via `UltraNet_OAuth2AuthorizeInteractive`, text tweets via `POST /2/tweets`, rotating refresh tokens persisted back to the vault. Both are bring-your-own-client-id (fixed loopback redirect ports 17995/17996); wizard forms added. Capabilities now express "media not supported" (`maxImages == 0`) — the composer drops attachments for such networks with a warning. 11 new engine tests (37 total). #### 2026-08-09 *0.3.41* - **UltraSocial** *(Phase 1 UI)*: the GUI application (target `UltraSocial`) on top of the engine — compose window with per-account target checkboxes and live character counters (per network's limit, switching to the caption limit when media is attached; amber badge + "will be shortened" warning when over), media chips through the file picker, add-account wizard (network picker with per-network fields and hints; Mastodon's browser OAuth or pasted token, Bluesky app password, Telegram bot token), post reporting per target, and the recent-history strip. Sign-in and publishing run on worker threads; results marshal to the UI through a main-thread timer queue, so the window stays live during the OAuth browser consent and slow uploads. #### 2026-08-09 *0.3.40* - **UltraSocial** *(new, Phase 1 engine)*: the cross-posting app's headless engine (`Apps/UltraSocial/engine/`, target `UltraSocialEngine`) — compose-once → adapt-per-network composer (code-point counting, word-boundary truncation with ellipsis, media trimming, caption limits), per-account credential vault (UltraMail's file-backend pattern), account + post-history store on UltraDatabase, and three connectors behind `ISocialConnector`: **Mastodon** (dynamic OAuth client registration + UltraNetOAuth2 interactive flow or pasted token; multipart media upload with 202-processing poll; statuses with `Idempotency-Key`), **Bluesky** (app-password session, `uploadBlob` + `app.bsky.feed.post` records, transparent `ExpiredToken` refresh that hands the rewritten credential blob back for the vault), **Telegram** (Bot API; `sendMessage` / `sendPhoto` with caption; `t.me` permalinks). 26 engine tests against scripted loopback HTTP fakes (`ULTRACANVAS_BUILD_ULTRASOCIAL_TESTS`). Design: `Docs/UltraSocial/Concept.md`. #### 2026-08-09 *0.3.39* - **UltraNet**: new OAuth 2.0 helper (`UltraNet/UltraNetOAuth2.h`) — the authorization-code flow with PKCE (RFC 6749 + 7636) for native apps: `UltraNet_OAuth2GeneratePkce` / `UltraNet_OAuth2ChallengeFromVerifier` (S256, verified against the RFC 7636 test vector), `UltraNet_OAuth2BuildAuthUrl`, a loopback redirect listener (`UltraNet_OAuth2WaitForCallback`, RFC 8252 style — binds 127.0.0.1/::1 only, answers stray requests with 404 and keeps waiting), `UltraNet_OAuth2ExchangeCode` / `UltraNet_OAuth2Refresh` (client secret via HTTP Basic or form body; server `error`/`error_description` surfaced in the result), `UltraNet_OAuth2ParseTokenResponse`, and the one-call blocking orchestrator `UltraNet_OAuth2AuthorizeInteractive`, which also resolves a port-0 redirect URI to the ephemeral port actually bound. SHA-256 is self-contained in the module, so no TLS-library crypto dependency. - **UltraNet** sockets: `UltraNetSocketOptions.bindAddress` restricts listeners / UDP binds to one interface (e.g. loopback), `UltraNet_TcpAccept` takes an optional timeout, and the new `UltraNet_SocketLocalEndpoint` reports the bound address/port — together they let a port-0 listener discover its ephemeral port. #### 2026-08-10 *0.3.39* - **UltraCanvasAlbum** *(1.6.1)*: a hover video preview no longer plays alongside the full video opened from its tile. Clicking a video tile (or its Play action) opens the player in its own window while the cursor still rests on the tile, so the album never received a MouseLeave and the muted inline preview kept decoding behind the player — two videos at once. Activating a tile (click, double-click, action icon, context-menu action) now stops the running preview before the app callback fires, and hover previews (video and GIF/WebP animation alike) only run while the album's window is the application's focused window — a preview that is mid-playback when another window takes the focus stops on its next frame tick, and a mouse move over the now-background album no longer re-arms one. - **UltraCanvasFilerWidget**: flexible tile widths in the thumbnail grid views. The column count still comes from the selected tile edge, but the leftover strip on the right — too narrow for one more column — is now distributed across the row's cells (Explorer-style), so the grid always fills the widget's width: resizing the window stretches the cells smoothly until the next column fits instead of growing an empty gap. Only the cell widens — captions get the extra room, so long names wrap later — while the image box keeps the square Small / Medium / Big / Maximized edge, centered, so thumbnails keep their size during a resize and the async decode cache is not churned. Controlled by `SetFlexibleTileWidths(bool)` (default on; off restores the fixed-width grid). #### 2026-08-10 *0.3.38* - **UltraFiler**: Favorites (pinning). A new heart button next to the History clock shows the Favorites view — the same Files / Folders / Apps tabbed layout, but listing deliberately pinned paths (`UltraFilerFavorites`, persisted as `favorites.txt` next to the settings) instead of recently used ones. The new menu bar **Pin** menu pins the visible view's selection (or the shown folder when nothing is selected): **Pin ▸ Favorites** into the tab the entry's kind belongs to, **Pin ▸ Treeview** — enabled only while the selection is a folder — into the folder tree's new **Pinned** section, whose entries navigate like bookmarks. The folder tree gained a context menu: **Copy / Delete / Paste** act on the folder under the cursor (Paste only when a folder is under the cursor and the clipboard holds files, Delete with confirmation and never on the top-level roots), **Unpin** (pinned entries only) removes the bookmark without touching the folder. *Settings ▸ Clear Favorites* empties the pins; Esc leaves the Favorites view like it leaves the History view. - **UltraCanvasTreeView**: `onNodeRightClicked` now fires only for the right mouse button (it used to fire on every mouse-up over a node) and passes the `UCEvent` along so handlers can place a context menu at the pointer; a right press no longer moves the selection to the node under the cursor. #### 2026-08-09 *0.3.37* - **UltraNet**: new `UltraNetApiStatus` tool (`Tests/UltraNet/ApiStatus/`, target `UltraNetApiStatus`, enabled by `ULTRACANVAS_BUILD_NET_TESTS`) walks the whole public UltraNet surface and reports each entry as **WORKING** (the probe drove the real code path and the result matched the contract), **IMPLEMENTED** (present and reached, but unverifiable in this environment), **NOT IMPLEMENTED** (documented stub / no-op / absent backend) or **BROKEN** (ran and contradicted the API). 108 entries across Core, URL, HTTP, Session, SSE, WebSocket, DNS, Socket, TLS, FTP, MIME and Plugins; `--format=text|markdown|json`, `--area=`, `--output=`, `--network`, `--strict`, and a `--serve` diagnostic that just holds the probe origin open. Registered with CTest; exits non-zero only on BROKEN (or, with `--strict`, on anything short of WORKING). - **UltraNet**: the status tool verifies offline by bringing its own peers — an in-process HTTP/1.1 + RFC 6455 WebSocket origin written on UltraNet's own TCP API (keep-alive, chunked bodies, `Expect: 100-continue`, redirects, cookies, Basic-auth challenges, `text/event-stream`, a slow route for cancellation, and a masked-frame echo endpoint with its own SHA-1 for `Sec-WebSocket-Accept`), loopback TCP/UDP peers, and an `openssl s_server` TLS peer whose throwaway certificate makes `UltraNet_TlsSetCABundle` / `UltraNet_TlsAddTrustedCert` checkable in both directions. No Python, no external service and no internet access required. - Docs: `Docs/Modules/UltraNet/ApiStatus.md` documents the statuses, the options, how each area is verified and how to add a probe; both UltraNet READMEs point at it from their Status sections. - **UltraNet**: the macOS TLS backend now honours custom trust anchors — found by the status tool's first CI run, whose trust-store probes came back BROKEN on macOS. `OS/MacOS/UltraNetTlsImpl.mm` stored the global CA bundle and `UltraNet_TlsAddTrustedCert` PEMs but `VerifyPeer` evaluated the peer against the system keychain only, so `UltraNet_TlsSetCABundle` / `UltraNet_TlsAddTrustedCert` (and the per-wrap `UltraNetTlsOptions::caBundlePath`, equally unread) were silently ignored. Wrap now parses the resolved PEMs into `SecCertificateRef` anchors and `VerifyPeer` applies them via `SecTrustSetAnchorCertificates`; a CA bundle replaces the system roots (matching the OpenSSL backend's `SSL_CTX_load_verify_locations` semantics) while added PEMs alone extend them (`SecTrustSetAnchorCertificatesOnly(false)`). #### 2026-08-09 *0.3.36* - **UltraCanvasFilerWidget** *(1.13.0)*: the compress dialog keeps the whole name and stays editable. The suggested archive name was `stem()` of the entry, which strips everything after the last dot — for a folder named `UCDemo-Windows-0.3.27-x86_64` that left `UCDemo-Windows-0.3`, because `.27-x86_64` looks like an extension to `std::filesystem`. A folder now keeps its full name (a folder has no extension, so every dot in it belongs to the name), and a file only loses a tail that is a plausible file type: short, alphanumeric and not a pure number, with `.tar` dropped along with the `.gz` / `.bz2` / `.xz` / `.zst` of a compound suffix. `CompressSelection()` picks the same name. The name field itself was a hand-rolled buffer fed by the dialog's own key handler: it could only append and backspace at the end (no caret, no selection, no clipboard — nothing could be corrected in the middle), and because it read the keyboard through the widget's own focus it went silent the moment anything else in the window claimed the focus, and stayed silent for every dialog opened afterwards. It is now a real `UltraCanvasTextInput` child, the same component the inline rename editor uses, opened with the suggestion selected so typing replaces it; the dialog additionally installs a window `KeyDown` filter for as long as it is up, so a keystroke reaches the editor whoever the window currently considers focused. Closing the dialog removes the filter and hands the keyboard back to the folder display. The committed name is stripped of path separators and trimmed before it becomes a file name. - **UltraCanvasFilerWidget** *(1.13.0)*: the compress dialog's Compress / Cancel are `UltraCanvasButton` children now, replacing a private `DrawDialogButton` painter and its own `okHover` / `cancelHover` flags and hit-testing. They carry the framework's hover, press and disabled painting, and a click reaches them as elements instead of being pattern-matched against rectangles in the dialog's mouse handler. - **UltraCanvasTimePicker** *(1.2.0)*: the editable field is a real `UltraCanvasTextInput` child (the picker is an `UltraCanvasContainer` now, like every other composite widget), replacing a private `editBuffer` / `caretPos` / `editing` triple and ~110 lines of hand-written key handling. Typing a time gains selection, clipboard, undo, double-click word select and multi-byte input, all of which the hand-rolled editor lacked; the popup spinners, the clock dial, the wheel-over-field nudge, Up/Down to open, Enter to commit and Escape to revert keep working as before. Two behaviours had to be re-pointed at the new field: the spinner and dial paths wrote `value` directly and so left the text stale, and the wheel guard tested `IsHovered()`, which the editor now absorbs by being the element under the pointer. - **UltraCanvasSpreadsheet** *(1.2.0)*: the cell / formula-bar editor is a real `UltraCanvasTextInput` child (the widget is an `UltraCanvasContainer` now), replacing an `editBuffer_` / `editCursorPos_` pair and its own key handling. One editor moves between the active cell and the formula bar depending on the edit mode. Cell text gains selection, clipboard, undo and multi-byte input; typing to start an edit, Enter to commit and step down, Tab to commit and step right, Escape to discard and formula entry all behave as before. `editBuffer_` survives as a mirror of the editor's text, so the live formula preview and the formula bar read it unchanged. With this the UI-reuse baseline is **empty**: every control in the tree is built from an UltraCanvas element. - **UltraCanvasDatePicker**, **UltraCanvasColorPicker** *(1.3.0)*: their typed fields are real `UltraCanvasTextInput` children too, on the same pattern as the time picker — both widgets derive from `UltraCanvasContainer` now. The date field keeps its calendar popup, arrow-key month navigation (the popup takes the keyboard off the field so the arrows drive dates, and hands it back on close) and is read-only in the range / week / multiple modes, whose text is a computed summary. The colour picker moves one editor between its hex box and the channel boxes, keeps the per-field character filter — now applied to paste as well as typing — and gains selection, clipboard and undo it never had. Clicking a value box selects its contents (type to replace) instead of placing a caret mid-value; a second click inside the editor places the caret as usual. `DragTarget::TextDrag` is gone: dragging out a selection is the editor's own gesture. - **UltraCanvasContainer** *(4.2.0)*: new `PlaceChildAt(child, rect)` for a self-rendered widget that positions a child itself (an inline editor over a field or a cell). `SetBounds()` alone is not enough — it writes only `finalBounds`, which the next layout pass overwrites, and `UltraCanvasTextInput::Arrange()` re-clamps its horizontal scroll whenever its width changes, so an editor placed that way ended up showing the tail of its own value ("F" instead of "#85FFFBFF"). `PlaceChildAt` writes the CSS position and size the engine resolves from, so the rectangle survives Arrange. The Filer's compress dialog uses it too. - **UltraCanvasFilerWidget**: fixed a build break in `ScanFolder()` — a merge kept the rename-reveal block that reads `renamedTo` but dropped the lines that declare it (and map the selection from the old path to the new one), so the file did not compile and the renamed entry lost its selection. - **Docs**: new `Docs/UltraCanvas/UltraCanvasUIElements.md` — a catalogue of every UI element the framework ships, grouped by what you are trying to build, with each element's defining header. The corpus had ~150 per-component documents and no index, so an element could only be found by someone who already knew its name; that is why controls kept being painted by hand instead of instantiated. AGENTS.md now carries the rule ("if it takes input, shows a picture or presents a value, it is an element") and points at the catalogue. - **Tooling**: new `scripts/check_ui_reuse.py` plus a `UI element reuse` CI workflow. It reports the two shapes that are almost always a reinvented element — a private edit buffer and caret fed from a `KeyDown` handler with no `UltraCanvasTextInput` in the file, and a `Draw*Button(IRenderContext*, …, bool hovered)` painter. The six controls that already existed (`UltraCanvasColorPicker`, `UltraCanvasDatePicker`, `UltraCanvasTimePicker` and `UltraCanvasSpreadsheet` edit fields, the Filer's `DrawDialogButton`) were recorded in `scripts/ui_reuse_baseline.txt` so they would not fail the build while only new ones did — and were then all ported in this same release, leaving the baseline empty. Self-rendered views that legitimately paint their own content opt out with a `// ui-reuse-exempt: ` marker. - **UltraCanvasFilerWidget** *(1.13.0)*: content previews are now **selectable per file kind**. The context menu grew a `Display > Preview` submenu with a checkbox for each of Bitmaps, Vector graphics, 3D, PDF, Text, Docs, Spreadsheets and Videos — all enabled by default — mirrored in code by `SetPreviewType()` / `SetPreviewTypes(mask)` / `IsPreviewTypeEnabled()` / `GetPreviewTypes()` over the new `FilerPreviewType` bitmask. Switching a kind off drops its entries back to the plain type glyph immediately *and* stops the widget from opening those files at all, which is what makes a folder of huge photos, videos or PDFs on a slow volume browsable. Three kinds gained a real preview producer, all running on the existing viewport-driven thumbnail workers so no preview ever blocks a frame: **PDF** files render their first page through the PDF plugin (outlined as a sheet of paper, since a page is white on a white widget), **STL** models are rasterized in software as a shaded three-quarter view (the GL viewer needs a window and a current context, which a background decode has neither of), and **text, documents and spreadsheets** preview as a miniature page of their own content — plain text and source code read directly, HTML stripped of its tags, RTF of its control words, ODT / DOC / DOCX through the shared rich-document reader, and ODS / XLSX / CSV / TSV laid out as a cell grid. Page-shaped previews are only drawn from roughly a 40 px box up, so the icon column of a Details or List row keeps its glyph and a folder listing does not read every document in it. `FilerFileCategory` gained `Model3D` and the type map learned the common 3D extensions (stl, obj, ply, 3ds, 3mf, gltf, glb, dae, fbx) plus `tsv`, so those files sort and colour as models / text instead of "File". #### 2026-08-08 *0.3.35* - Change Linux packager script, drop .appimage - Change GitHub build to produce package with all dependent libs #### 2026-08-08 *0.3.34* - **UltraCanvasFilerWidget** *(1.12.0)*: a dragged file can leave the widget again. The drag was handed to the native OS drag the moment the cursor crossed the widget's border, and that is where it visibly died: the badge is drawn by the widget and therefore clipped to it, the OS drag draws nothing of its own while the cursor is still over the application's own window (XDND refuses a drop back onto the window that started it), and on Windows and macOS `StartNativeFileDrag()` had no implementation at all, so the gesture was dropped on the floor. Crossing the border now keeps the drag running: the badge travels over the whole window on the new window drag overlay, a release over another element hands it the files as a `Drop` event (a second Filer pane, a folder tree, any drop-aware widget), and only leaving the *window* turns the set into the native OS drag. A platform without one keeps the window-wide drag alive instead of losing the gesture. - **UltraCanvasWindowBase** *(2.2.0)*: new `SetDragOverlay(owner, windowRect, renderer)` / `ClearDragOverlay(owner)` — window-level content painted above every element, for widgets that drag something across the whole window and cannot paint outside their own bounds. Moving it repaints the rectangle it leaves and the one it enters; the first owner keeps it until it clears it. - **Windows backend**: native file drags out of a window are implemented (`UltraCanvasWindowsWindow::StartNativeFileDrag`) with a CF_HDROP `IDataObject` plus an `IDropSource` driven by `DoDragDrop`, the counterpart of the `IDropTarget` that was already there. Files can now be dragged from a Filer widget into Explorer or any other application, and the accepted effect (copy / move) is reported back so a move rescans the source folder. macOS still has no drag-and-drop backend in either direction. - **UCTextLayout** *(1.1.2)*: a `TextWrap::WrapNone` layout no longer wraps onto a second line when it is also given an explicit height. Pango has no "never wrap" flag — a no-wrap layout is one that Pango is told to ellipsize, and the layout *height* decides when that kicks in: `-1` (the default) means "ellipsize the first line of each paragraph", but a positive height means "ellipsize once that many pixels are used up", so a box two lines tall lets the text word-wrap once before anything is ellipsized. Widgets set an explicit height purely to centre the glyphs vertically (`VerticalAlignment::Middle`), which silently turned single-line text into two lines whenever the box was at least twice the line height. `SetExplicitHeight` now keeps that value for the vertical alignment maths only and leaves Pango's own height at `-1` while the layout is in no-wrap mode; `SetWrap` re-applies it, since it may be called after the height is set. `GetExplicitHeight` reports the requested height rather than Pango's. - **UltraCanvasBreadcrumb / UltraCanvasLabel**: fixed as a result — the filer's and media viewer's path strips kept long folder names such as `UCDemo-Windows-0.3.24-x86_64 (1)` on one ellipsized line instead of breaking them at the space and drawing two cramped lines inside a one-line strip. Whether the break happened depended on the exact font line height against the strip's height, which is why it showed on Windows and not on Linux. Long names are still capped at `BreadcrumbStyle::maxItemTextWidth` (200px by default; set it to `0` for no per-item limit). #### 2026-08-08 *0.3.33* - **UltraFiler — Extras > Open prompt**: a new menu bar entry starts the operating system's command line program in the folder of the active tab. The launch lives in `Apps/UltraFiler/UltraFilerPrompt` *(1.0.0)*, which detaches the process (`fork` + `setsid` + `execvp` behind a reaped intermediate child on POSIX, `ShellExecuteExW` on Windows), so closing the file manager never takes the terminal with it and no zombie is left behind. Without configuration the platform default is detected at run time: `%COMSPEC%` on Windows, Terminal.app (started with `open -a `) on macOS, `$TERMINAL` or the first installed terminal emulator on Linux; when nothing is found the failure is reported in an alert instead of silently doing nothing. - **UltraFiler — settings**: the settings window gained an *Extras > Open prompt* page holding the application that menu entry starts (`extras.prompt.application` in the config file). The folder button next to the path field opens the file dialog filtered to this platform's applications, **Save app** persists the chosen program, **Use system default** clears the setting again and **Test** starts the program in the field to check the path. The dialog's buttons now come from one `MakeButton` helper instead of per-button styling. - **UltraCanvasCircleDiagram** *(1.0.0)*: new hub-and-spoke circle diagram infographic — a centre hub, a backbone ring, and equally sized labelled node discs threaded onto that ring, each with a fan of satellites on leader lines. It is the node-on-ring member of the circular family: every existing circular element subdivides the ring into sectors, while this one threads discrete discs onto it, so a node's radius is independent of ring thickness and it can carry children outside the ring. Presentation-only (no viewport, dragging, inline editing or undo); interaction is hover highlighting, tooltips and `onNodeClick` / `onSatelliteClick`. Structure and colour are independent presets — `CircleDiagramDesign` (`SatelliteWheel`, `BandedWheel`, `Custom`) and `CircleDiagramPaletteKind` (seven themes plus `Custom`) — and both work at any node count, because each palette is a hue ramp sampled at N points rather than a fixed list. Every layout quantity derives from the per-node arc of 360/N: the auto-fitted node radius is a share of the chord between neighbours, the satellite fan is narrowed to what one node's wedge can hold (shrinking auto-sized satellite discs when K of them will not fit side by side), and anything outside the backbone — fans, or labels placed with `CircleNodeLabelPlacement::Outside` — is reserved for by shrinking the backbone radius so nothing is clipped. Disc labels shrink to fit, testing the longest single word as well as the wrapped block, since a word too wide to break ellipsizes rather than wrapping. Node discs are all one radius and satellites another; `value` is tooltip/callback payload and never scales a disc. `SetNodeCount()` clamps to 3–12 rather than degrading silently. Docs in `Docs/UltraCanvas/UltraCanvasCircleDiagram.md`, survey and roadmap in `Docs/UltraCanvas/CircleDiagramInfographicVariants.md`, demo scene in `Apps/DemoApp/UltraCanvasCircleDiagramExamples.cpp`. #### 2026-08-08 *0.3.32* - **Version numbers** are now derived from the changelogs at build time, so the version the demo app's info window shows can no longer disagree with the version in the file name of the build it came from. The packaging scripts already parsed `#### YYYY-MM-DD *x.y.z*` off the first changelog line for the artefact names; the number compiled *into* the binaries was a separate hand-maintained copy that only moved when someone remembered to run `set-version.sh`, and it had fallen ten releases behind (the info window reported 0.3.21 against a 0.3.31 changelog). The new `cmake/UltraCanvasVersion.cmake` reads the same first line at configure time and feeds `project(VERSION)`, `ULTRACANVAS_VERSION` and `ULTRATEXTER_VERSION`; `UltraCanvas::versionString` and `UltraCanvasTextEditor::version` take their value from those defines instead of a literal. Adding a changelog entry re-triggers the configure step, so an existing build tree picks the new version up rather than baking in the one it was first configured with. `set-version.sh` now only writes the two Windows resource files that are read from disk by windres (`UltraTexter.rc`, `UltraTexter.manifest`) — a configure on any platform warns when those are stale. #### 2026-08-07 *0.3.31* - **UltraCanvasGLSurface** *(1.0.1)*: a surface built with a non-zero `(x, y)` now keeps that origin. The constructor delegated to the size-only base constructor, so the origin was written straight to `finalBounds` without a CSS position behind it: the first layout pass re-stacked the surface as an in-flow child and the GL content composited in the top-left corner of its container while every sibling widget stayed put. It now uses the `(id, x, y, w, h)` base constructor, which stamps an AbsoluteUI position for a non-zero origin; `(0, 0)` still leaves the surface in flow for flex/grid parents. The doc gained a "Positioning" section covering this and the bounds + CSS-box pattern needed to move or resize a surface at runtime. - **DemoApp — OpenGL 3D showcase**: all three tabs (3D Models, Shaders, Zarch) share one maximize control, `gldemo::AddMaximizeControl()`. The icon sits in the canvas's top-right corner with an 8px margin, and the button, a double-click on the canvas or Esc toggles between the normal layout and a canvas maximized over the whole tab. Maximizing works now that the zoom writes the CSS box as well as the bounds (`gldemo::PlaceElement`) — hiding the chrome invalidated the layout, which promptly restored the canvas's original size. A new `media/icons/minimise.svg` marks the restore state, the Zarch and Models panels match the Shaders tab's column geometry, and the Shaders info text was trimmed to what fits its panel. #### 2026-08-07 *0.3.30* - **UltraCanvasTooltipManager** *(2.3.0)*: structured tooltips gained three-column table rows and definable column alignment. `AddRow(label, value, value2)` (and the swatch overload `AddRow(color, label, value, value2)`) adds a three-column row next to the existing two-column form. Alignment is set per column and separately for each arity — `TooltipColumnAlign::Left | Center | Right` via `TooltipStyle::columnAlign2` / `columnAlign3` (theme level) or `TooltipContent::SetColumnAlignment(...)` (per tooltip, stored outside `styleOverride` so `SetStyle()` does not reset it); the defaults reproduce the previous look of labels left, values flush right. Two- and three-column rows are measured as independent tables, so a two-column "Total" row cannot disturb a three-column table above it. Column widths are natural when the row fits and otherwise cap the label column at 55 % and share the rest in proportion to the natural widths; surplus width is spread over the gaps so the last column stays flush with the tooltip's right edge. - **UltraCanvasTooltipManager**: `TooltipColumnAlign::Decimal` aligns a column of numbers on their decimal separator instead of on an edge. A cell's anchor is the last `.` or `,` followed by a digit, so a thousands separator never wins over the real decimal mark (`1,204.50` and `1.204,50` both resolve) and a trailing period cannot steal it; a cell with no separator anchors at its end, so integers meet the separator column. The column reserves the widest integer part plus the widest fractional part — wider than any single cell — so nothing is clipped to stay aligned, and a cell that still does not fit falls back to right alignment. Demo tiles cover the three-column table, custom alignment, mixed row arity and decimal alignment; the "Title + table" tile is now a label / value / unit table with capitalized labels. #### 2026-08-07 *0.3.29* - **UltraCanvasSequenceDiagram** *(1.0.0)*: new UML 2 sequence diagram feature, layered like the class diagram. `UltraCanvasSequenceModel` is the UI-free interaction model — lifelines (object / actor / boundary / control / entity / database heads), the seven message forms (sync, async, return, create, destroy, lost, found, plus self messages), combined fragments (loop / alt / opt / par / break / critical) with guarded operands, and anchored notes. Execution bars are computed from the message order (`ComputeActivations`), message numbers sequentially or hierarchically Visual-Paradigm style (`ComputeMessageNumbers`), and `Validate()` diagnoses dangling endpoints, lifecycle misuse, unmatched returns and fragments that cross without nesting. `SequenceTextExport` writes PlantUML and Mermaid, including activations, fragments, notes and inline create declarations. The rendering element solves lifeline spacing from head and label widths, attaches arrows to the deepest execution bar, drops created heads onto their create row, and offers six themes, zoom/pan/fit, hover + selection callbacks, an optional "sd" frame and foot boxes. Six sample models — among them the framework's own event pipeline — drive the new DemoApp tab; unit tests in `Tests/SequenceModelTest.cpp` (target `SequenceModelTest`). #### 2026-08-06 *0.3.28* - **UltraCanvasFilerWidget** *(1.9.0)*: the inline rename editor is now a real `UltraCanvasTextInput` overlaid on the item's name instead of a hand-drawn append-only field. Typing, caret movement (arrows / Home / End), click-to-position, Shift selection and Ctrl+C/X/V/Z all work; the editor opens with the base name selected (extension kept, Explorer-style; folders select the whole name), commits on Enter and on focus loss (a click anywhere else), cancels on Esc. Renaming a search-result entry now renames in the entry's own folder instead of resolving against the shown path. - **UltraCanvasFilerWidget**: rubber-band selection. Dragging from empty space draws a selection rectangle; every entry it touches becomes the selection, live while the band is dragged, and with Ctrl the rectangle adds to the selection held at the press. The band auto-scrolls at the viewport edge and Escape abandons it (restoring the previous selection); `WantsEscapeKey()` covers it. A plain click on empty space still clears the selection (a Ctrl click leaves it alone). - **UltraCanvasFilerWidget**: video files show their poster frame (grabbed via `CaptureVideoThumbnailPixmap` a short way into the clip) as their thumbnail in the thumbnail views, the Details/List mini icons, the drag badge and the delete-confirmation preview. Decoded on the existing background thumbnail workers, so the folder page never waits on a video; without a video backend the tile keeps its generic glyph. - **UltraCanvasTextInput** *(1.3.3)*: typed UTF-8 input is inserted instead of dropped — the printable filter on `event.text` kept only ASCII 32..126, so multi-byte characters (umlauts, accents, CJK, ...) typed into any text field vanished. - **UltraViewer app** *(1.0.0)*: new universal media viewer application (`Apps/UltraViewer`, target `UltraViewer`, `BUILD_ULTRAVIEWER_APP`, default ON). One full-window `UltraCanvasMediaViewer` displays bitmaps, vector graphics, video and audio (with the player elements' transport controls: play / pause / seek / scrub / volume), documents (PDF), e-books, spreadsheets (ODS/CSV/TSV), 3D models (STL), text / source / markdown and UltraCanvas Document containers (*.ucd). Command line takes a folder (browse it), a file (browse its folder with the file shown first), several files (exactly that playlist) or nothing (use Open / drag & drop). New app icon `media/appicon/UltraViewer.png`. - **UltraCanvasMediaViewer** *(1.4.0)*: two new media kinds. `MediaKind::Book` — e-books (EPUB / FB2 / MOBI / PRC / AZW / AZW3) open in an embedded `UltraCanvasEBookViewer` (chapter toolbar, TOC, reflowing content); the zoom toolbar drives the reading text scale and PageUp / PageDown switch chapters while Left / Right keep browsing the folder. `MediaKind::UCDoc` — UltraCanvas Document containers (*.ucd) are recognised by the UCD v2 fixed header: the viewer shows the embedded raw HEIC/PNG preview thumbnail (readable without parsing the body, as the format intends) on the image surface, or a header summary in the text view when there is none; the info bar labels the file `UC DOCUMENT` and the details popup lists the container fields (type descriptor, version, body encoding, compression, encryption, thumbnail). Full rendering arrives with the UCD v2 engine. New doc: `Docs/UltraCanvas/UltraCanvasMediaViewer.md`. #### 2026-08-06 *0.3.27* - **UltraCanvasMediaViewer** *(1.3.1)*: the `Still` video preview mode shows the first frame instead of staying on "Buffering...". The mode relied on the load-time preroll frame alone, whose single emission could be flushed away while the pipeline was still settling; the `Still` paths now request the frame explicitly (a paused seek to 0 re-prerolls and delivers it — the same mechanism as a paused scrub) whenever no frame of the current file has been shown yet. - **UltraCanvasVideoPlayerElement** *(0.1.7)*: loading a source resets the per-file frame state (shown frame, scrub throttle, time readout), so switching files updates the preview instead of keeping the previous video's last frame. New `HasVideoFrame()` reports whether a frame of the current source has been shown yet. - **UltraCanvasVideoPlayer** *(0.1.2)* / **GStreamer backend** *(0.1.11)*: a freshly opened session no longer receives a spurious playback-rate "change" to the default 1.0. Backends apply a rate through a flushing seek, and issuing one during the initial preroll discarded the prerolled first frame a paused session depends on; the GStreamer backend also skips the seek for any unchanged rate (as the MediaFoundation backend already did for 1x). #### 2026-08-05 *0.3.26* - **UltraCanvasMediaViewer** *(1.3.0)*: configurable backdrop behind transparent images. New `TransparentImageBackground` enum (`SolidColor` / `Checkered`) with `SetTransparentBackground()` / `GetTransparentBackground()` and `SetTransparentColor()` / `GetTransparentColor()` on both the surface and the viewer. The backdrop is drawn under the image's displayed rectangle (only its visible part, so a high zoom costs nothing extra) — a preset solid colour (default white) or the light/dark checkerboard familiar from image editors — and fades with the image during slideshow transitions. Transparent pixels now read against a defined background instead of the dark canvas colour. - **UltraFiler app** *(1.3.0)*: menu bar with a *Settings* menu opening the new settings window: a tree of settings pages on the left (main pages with sub pages — currently *Media Viewer > Transparent Images*) and the selected page on the right. The Transparent Images page chooses between the checkered pattern and a preset colour (colour picker) for the backdrop behind transparent images in the media preview; changes apply live and persist to the platform config directory (`~/.config/UltraFiler/config.ini` on Linux). - **UltraCanvasMediaViewer**: new `SetTopBarsVisible()` / `GetTopBarsVisible()` — shows/hides everything above the display surface (the folder breadcrumb, both toolbar rows and the adjustments panel). For hosts that embed the viewer as a plain preview pane and provide their own navigation. Default: visible. - **UltraCanvasTreeView**: new `SetFontSize()` / `GetFontSize()` for the row label font size (default 12, previously hardcoded). `UltraCanvasColumnsTreeView` uses it for its cell text, column headers and group headers too. - **UltraCanvasFilerWidget**: new `FilerStyle::folderIconScale` (default 1.0) — shrinks the folder glyph inside a thumbnail tile's image box, centered, so folders can read lighter next to image thumbnails. - **UltraCanvasFilerWidget**: file-list display for search results. New `ShowFileList(paths)` / `IsShowingFileList()` shows an explicit list of paths (stat-ed like scanned entries) instead of the folder listing, in the current view mode; `SetPath()` returns to the folder display. The Details view gains a `Path` column (the entry's containing folder) shown only in that mode, so normal folder displays are unchanged. The Open-Path context item now sits at the *top* of the menu followed by a separator, and `SetOpenPathMenuItemVisible(visible, label)` takes a caption. - **UltraFiler app** *(1.2.0)*: search field on the right of the path bar — searches the current folder recursively for names containing the text (case-insensitive, capped at 1000 matches) and shows the matches in the tab's current view mode, with the *Path* column after the name in Details view and "Open path (in new tab)" as the context menu's first entry. Clearing the field or navigating returns to the folder display; each tab keeps its own search. Every UI element now uses a single 9 pt font size (toolbar, breadcrumb, dropdowns, tabs, folder tree, file panel, status bar). The preview pane hides the media viewer's breadcrumb and toolbars — the filer provides the navigation, the pane shows only the media. Folder icons in the thumbnail views draw at 70% size. #### 2026-08-04 *0.3.25* - **UltraCanvasFilerWidget** *(1.7.0)*: entries are properly draggable. A press on an item captures the mouse and, past the slop threshold, picks up that item — or the whole selection when the press landed inside it. Inside the widget the drag is drawn by the widget (a badge with the entry icon and name / "N items" following the cursor, the folder under it highlighted) and a drop on a folder of the view **moves** the files into it, **Ctrl** drops a copy; Escape abandons the drag. Leaving the widget hands the same set to the native OS drag as before — which is also what fixes dragging files out: the capture makes a fast flick out of the widget start the drag instead of losing the move to whatever the cursor passed over. New `SetDragEnabled()` / `IsDragEnabled()` turn the gesture off. - **UltraCanvasFilerWidget**: a drag no longer changes the selection. What a plain press would select is applied on the release, so dragging a file does not fire `onSelectionChanged` and no longer re-targets a preview pane fed by it (UltraFiler loaded the dragged file into the preview mid-drag). - **UltraCanvasFilerWidget**: the selection now survives a rescan — it is remembered by path instead of by row index, so `Refresh()` after a file operation no longer leaves the selection pointing at whatever moved into those indices. New `onFolderRefreshed` callback fires after every scan of the shown folder, so hosts can refresh their folder description. - **UltraCanvasMediaViewer**: SVG files preview as the rendered image instead of their source code. `ClassifyFile()` matched the syntax tokenizer's SVG / XPM / XBM languages before the image formats, so markup-based image files opened in the read-only text area. New `IsImageFile()` is checked first. - **UltraFiler app**: the status bar follows the folder listing again — it is refreshed from `onFolderRefreshed`, so item counts stay correct after a drop or another file operation rescans the view. - Merge "UltraFiler font size standardization" - Add UltraFiler app icon #### 2026-08-02 *0.3.24* - **UltraCanvasMediaViewer**: selectable video preview behavior. New `VideoPreviewMode` (`Autoplay` — full playback with sound, the previous and default behavior; `PreviewClip` — the first seconds muted and then paused, the `UltraCanvasAlbum` hover-preview style; `Still` — the prerolled first frame, paused) with `SetVideoPreviewMode()` / `GetVideoPreviewMode()` and `SetVideoPreviewClipSeconds()` (default 5 s). Changing the mode applies to a currently shown video too. New `StopPlayback()` stops video / audio playback and the pending clip timer — for hosts that hide or detach the viewer, where the sound used to keep playing invisibly. - **UltraCanvasSupportedFormats**: new `CanImagePipelineLoad(extension)` — whether the raster/SVG image pipeline behind `UCImage` (libvips + the built-in SVG renderer, including the ImageMagick delegate fallback for known raster extensions) can decode files with that extension. The existing inventory reports plugin-provided formats (CDR, XAR, ...) as loadable, so it could not answer "may this file go to the image loader?". - **UltraCanvasFilerWidget**: thumbnail decoding is now gated on `CanImagePipelineLoad`. Vector-category files that only a graphics plugin understands (e.g. `.xar`, `.cdr`) were handed to the libvips loader by both the thumbnail worker and the delete-confirmation folder preview (which fed it *every* file type), producing `VipsForeignLoad "... is not a known file format"` warnings and wasted decode attempts; such files now keep their category glyph. - **UltraFiler app** *(1.1.0)*: tabbed browsing — a "+" button on the left of the toolbar opens additional tabs, each with its own folder view, history and sort/view settings (drag to reorder, closable except the last). The preview pane now folds away while nothing previewable is selected, giving the folder display the whole width; the Preview toggle enables / disables the feature. New "Video" dropdown selecting the preview behavior for video files: Autoplay (default), 5 s muted clip, or still image. #### 2026-08-01 *0.3.23* - **UltraCanvasDendrogram** *(1.5.1)*: fixed radial leaf labels rendering upside down in the upper-right quadrant. The left/right half test was `angle > -pi/2 && angle < pi/2`, which assumes angles in `(-pi, pi]`, but `DendrogramLayoutEngine::ApplyRadialLayout` produces `[0, 2pi)` — so every leaf between `3pi/2` and `2pi` failed the test, took the left-half branch and picked up an extra 180-degree rotation. Both the leaf labels and the group arc labels now test `cos(rotation)`, which is precisely the condition for "this text is not upside down" and does not depend on which angle range the layout uses. Audited the other radial components at the same time — Sunburst, Chord, RadialBar, CircularInfoGraphic and PolarChart all normalise correctly and were unaffected. #### 2026-08-01 *0.3.22* - **UltraCanvasNodeDiagram** *(2.2.0)*: organizational-network features. Node size can now be driven by the data - `NodeSizeMode::ByDegree` sizes a node from its connection count, `ByValue` from a new `NodeDiagramNode::value` field, both through a sqrt transfer so node AREA tracks the quantity rather than the diameter. Degrees can count total / incoming / outgoing links and are cached, so bulk-loading a graph costs one rebuild instead of one per `AddLink`. New `NodeDiagramGroup` cluster containers wrap a set of member nodes in an auto-fitted boundary box (solid or dashed, optional fill and corner radius, title in any corner) that follows its members through dragging and re-layout; boxes draw behind the links and titles after the nodes, both sized in screen pixels so they hold up at any zoom. `SetGroupCohesion()` adds a per-group centroid attraction to the force-directed layout - without it repulsion scatters a cluster and the boxes overlap into mush. New color legend overlay (`NodeDiagramLegendConfig`, `BuildLegendFromGroups()`) drawn in screen space in any corner. Groups and sizing round-trip through `ToJson()` / `FromJson()`, and group boxes are included in `ComputeContentBounds()` so `FitView()` and the minimap account for them. New demo tab (Diagrams > Node Diagram > Organization): a five-department company network with a control column for layout, sizing mode, degree mode, cohesion, link style, node shape, theme, and toggles for the boxes, legend, grid, minimap, controls and snap. - **UltraCanvasDendrogram** *(1.5.0)*: hierarchical edge bundling (Holten 2006). `AddRelation()` registers a leaf-to-leaf association that does not follow the tree's parent/child structure; each one is routed through the tree path source -> lowest common ancestor -> target, relaxed toward the straight chord by `SetBundlingStrength()` (beta), and smoothed with a clamped cubic B-spline. A radial dendrogram can now show its hierarchy and the cross-links between its leaves at the same time without becoming a hairball. Also new: area-proportional node dots via `DendrogramNodeSizeMode::ByValue` and `DendrogramNode::nodeValue`, normalised against the largest value in the tree - replacing the single global `style.leafNodeRadius` as the only leaf size available. - **UltraCanvasJitterPlotElement** *(1.3.0)*: per-point encodings. A parallel vector of size magnitudes plus `JitterPointSizeMode::ByValue` turns a beeswarm into a bubble beeswarm, with the packer receiving the real per-point radii so mixed sizes pack without overlapping. A parallel vector of color values plus `JitterPointColorMode::ByValue` samples any `UltraCanvasColormap` palette, including the diverging ones with a configurable midpoint, so a signed quantity reads correctly around zero. Fixes: `minScoreFilter` defaulted to `0.0` and silently discarded every negative value before rendering; `AddCategoryData()` did not invalidate the point-position cache, so a second call left the previous points on screen; `RenderJitterPoints()` always drew a plain circle and ignored both `SetPointShape()` and the point edge style. - **UltraCanvasMediaViewer**: the arrow keys now browse the folder as soon as the widget is on screen. The widget takes the window keyboard focus when it is attached to a window (`SetGrabFocusOnAttach(false)` opts out, `FocusForKeyboard()` requests it on demand) and installs a window key filter, so Left / Right no longer require a click into the picture first — with no focused element at all the key event never reached the widget before. The filter only steps in when the keyboard is unowned or held by one of the display views; toolbar buttons, sliders and the breadcrumb keep their own key handling. Where the active view uses the bare arrows itself (spreadsheet cell movement) `Alt+Left` / `Alt+Right` browse instead. - **UltraCanvasMediaViewer**: text / source / markdown files open display-only instead of read-only-but-focusable. The text area used to grab the focus and swallow Left / Right for caret movement, which stopped file browsing dead (and showed an editing caret in a viewer). The viewer now scrolls the text itself with Up / Down / PageUp / PageDown and copies the selection with Ctrl+C. - **UltraCanvasMediaViewer**: opening a single file through the Open dialog, or dropping one file onto the widget, now browses the folder that file lives in (with that file shown first) instead of building a one-entry playlist that the arrow keys and the slideshow had nowhere to move in. Multi-selections are still taken as an explicit playlist. - **UltraCanvasTextArea**: new `SetDisplayOnly()` / `IsDisplayOnly()`. Display- only implies read-only and additionally takes the area out of the keyboard focus chain — `AcceptsFocus()` returns false, no caret is drawn and no key event reaches it — so a hosting widget keeps the arrow keys for its own navigation. Mouse wheel / scrollbar scrolling and mouse selection are unaffected. #### 2026-08-01 *0.3.21* - **UltraCanvasSplitPane**: split lines can now carry an optional **handle**. `SplitterHandleShape` picks the form - `Square`, `RoundedSquare`, `Round` (a circle when square, a capsule when elongated) or `Image` - and `SplitterHandleStyle` sets its size across and along the line, corner radius, position along the line (0..1), colors, border and grip lines. The splitter strip widens to fit the handle while the painted line stays as thin as `splitterThickness`, so a 3 px line can carry a 22 px handle. `SetSplitterHandleShape()` is the one-liner; the handle drags exactly like the rest of the line. The shape enumerator for "no handle" is `NoHandle`, not `None`, because `` defines `None` as a macro. - **UltraCanvasSplitPane**: **image handles**. `SplitterHandleStyle::imagePath` takes an SVG or raster asset, with `SetSplitterHandleImage()` as the one-liner. With `SplitterHandleShape::Image` the asset is the whole handle; with any drawn shape it is centred on top of it and the grip is suppressed. Leaving `axisLength` at 0 takes the handle's proportions from the asset, so the ready-made `media/icons/scrollbar-handle-v.svg` (14x48) renders as a grip rather than a squashed square. `imageAsMask` re-tints a monochrome glyph with `imageColor`, or with the handle's own normal/hover/active color when that is left transparent, so an image handle can react to hover and drag like a drawn one. - **UltraCanvasSplitPane**: **action icons on the split line**. Any number of icons per splitter (`AddSplitterIcon`, `SetSplitterIcons`, `InsertSplitterIcon`, `RemoveSplitterIcon`, `ClearSplitterIcons`), each with an image or text glyph, tooltip, enabled/visible state and its own click handler, plus a pane-level `onSplitterIconClicked`. Icons are centred across the line and grouped along it, either inside the handle (which auto-sizes to wrap them) or at their own `SplitterIconStyle::position`. Pressing an icon does not start a drag: the click fires on release over the same icon, the cursor turns into a hand, and a disabled icon dims and swallows the press. Icons live on the split pane rather than on the splitter objects, so they survive pane insertion and removal. - **UltraCanvasSplitPane**: `SplitPaneStyle::splitterHitMargin` is wired up - it now widens the grab strip on each side of the line without thickening the painted line. New guide `Docs/UltraCanvas/UltraCanvasSplitPane.md` (the demo already pointed at it) and three new demo sections covering handle shapes, icons in a capsule handle, and icons on a bare vertical split line. #### 2026-08-01 *0.3.23* - **UltraCanvasDendrogram** *(1.5.1)*: fixed radial leaf labels rendering upside down in the upper-right quadrant. The left/right half test was `angle > -pi/2 && angle < pi/2`, which assumes angles in `(-pi, pi]`, but `DendrogramLayoutEngine::ApplyRadialLayout` produces `[0, 2pi)` — so every leaf between `3pi/2` and `2pi` failed the test, took the left-half branch and picked up an extra 180-degree rotation. Both the leaf labels and the group arc labels now test `cos(rotation)`, which is precisely the condition for "this text is not upside down" and does not depend on which angle range the layout uses. Audited the other radial components at the same time — Sunburst, Chord, RadialBar, CircularInfoGraphic and PolarChart all normalise correctly and were unaffected. #### 2026-07-31 *0.3.22* - **UltraCanvasNodeDiagram** *(2.2.0)*: organizational-network features. Node size can now be driven by the data - `NodeSizeMode::ByDegree` sizes a node from its connection count, `ByValue` from a new `NodeDiagramNode::value` field, both through a sqrt transfer so node AREA tracks the quantity rather than the diameter. Degrees can count total / incoming / outgoing links and are cached, so bulk-loading a graph costs one rebuild instead of one per `AddLink`. New `NodeDiagramGroup` cluster containers wrap a set of member nodes in an auto-fitted boundary box (solid or dashed, optional fill and corner radius, title in any corner) that follows its members through dragging and re-layout; boxes draw behind the links and titles after the nodes, both sized in screen pixels so they hold up at any zoom. `SetGroupCohesion()` adds a per-group centroid attraction to the force-directed layout - without it repulsion scatters a cluster and the boxes overlap into mush. New color legend overlay (`NodeDiagramLegendConfig`, `BuildLegendFromGroups()`) drawn in screen space in any corner. Groups and sizing round-trip through `ToJson()` / `FromJson()`, and group boxes are included in `ComputeContentBounds()` so `FitView()` and the minimap account for them. New demo tab (Diagrams > Node Diagram > Organization): a five-department company network with a control column for layout, sizing mode, degree mode, cohesion, link style, node shape, theme, and toggles for the boxes, legend, grid, minimap, controls and snap. - **UltraCanvasDendrogram** *(1.5.0)*: hierarchical edge bundling (Holten 2006). `AddRelation()` registers a leaf-to-leaf association that does not follow the tree's parent/child structure; each one is routed through the tree path source -> lowest common ancestor -> target, relaxed toward the straight chord by `SetBundlingStrength()` (beta), and smoothed with a clamped cubic B-spline. A radial dendrogram can now show its hierarchy and the cross-links between its leaves at the same time without becoming a hairball. Also new: area-proportional node dots via `DendrogramNodeSizeMode::ByValue` and `DendrogramNode::nodeValue`, normalised against the largest value in the tree - replacing the single global `style.leafNodeRadius` as the only leaf size available. - **UltraCanvasJitterPlotElement** *(1.3.0)*: per-point encodings. A parallel vector of size magnitudes plus `JitterPointSizeMode::ByValue` turns a beeswarm into a bubble beeswarm, with the packer receiving the real per-point radii so mixed sizes pack without overlapping. A parallel vector of color values plus `JitterPointColorMode::ByValue` samples any `UltraCanvasColormap` palette, including the diverging ones with a configurable midpoint, so a signed quantity reads correctly around zero. Fixes: `minScoreFilter` defaulted to `0.0` and silently discarded every negative value before rendering; `AddCategoryData()` did not invalidate the point-position cache, so a second call left the previous points on screen; `RenderJitterPoints()` always drew a plain circle and ignored both `SetPointShape()` and the point edge style. #### 2026-07-31 *0.3.21* - **UltraCanvasTimelineChart**: added the swimlane grouping mode (`TimelineLaneMode::Swimlanes`). The same date axis and the same entries, with rows given identity: one named band per workstream, a name column on the left and a tinted background per row. Rows are declared with `SetSwimlanes()` or derived from the distinct `TimelineChartEntry::swimlaneName` values in first-appearance order; milestones move inside their band with the label beside them; the axis is forced to the top. Each band sub-packs with the same packer and the bands share one height budget - rows are compressed before any sub-row is dropped, so a busy row can keep three sub-rows while quiet rows keep one. New `SwimlaneProgram()` / `ProgramSwimlanes()` samples and a Swimlanes demo tab with a row-grouping control. - **UltraCanvasTimelineChart**: the lane packer now tracks each row's full interval list instead of only its right edge. Point events are placed in importance order rather than date order, so with a single right edge an event earlier than everything already placed could not be inserted and ended up overlapping a bar. Affects packed mode as well as swimlanes. - New **UltraCanvasTimelineChart** element (`Plugins/Charts/UltraCanvasTimelineChart`): the chronological counterpart to the timeline diagram — milestones and spans placed to scale on a real date axis, with no task table and no dependency graph. Four entry kinds (milestone, span, era band, project bookend), eight marker styles, four bar styles, open-ended spans, uncertain dates, span progress, and five design presets (`Modern`, `Classic`, `Minimal`, `Roadmap`, `Dark`) over the usual palettes and dark theme. Spans and milestone callouts share one shelf packer per side of the axis, so a label is never drawn on a bar; when a side runs out of room the least important labels are dropped rather than overprinted, and the marker is always kept. Wheel zoom anchored on the cursor's date, drag pan, double-click to refit, selection, tooltips and callbacks. New demo page (Info Graphics > Timeline Chart) and guide in `Docs/UltraCanvas/UltraCanvasTimelineChart.md`. - New header-only **UltraCanvasTimeAxis** (`include/Plugins/Charts/UltraCanvasTimeAxis.h`): date<->pixel projection, automatic scale resolution (minutes through decades) from the current pixels-per-day, two-tier tick generation with Monday-based weeks and calendar-correct month/quarter/year stepping, and cursor-anchored zoom. Day serials match `GanttDate::serial`, so the axis, the Gantt chart and the timeline elements share one date representation. - New **UltraCanvasTimelineDiagram** element (`Plugins/Diagrams/UltraCanvasTimelineDiagram`): the narrative timeline infographic — an ordered list of events laid out along a decorative path, with nine design presets (`Bar`, `Line`, `Alternating`, `Cards`, `Vertical`, `Serpentine`, `Hanging`, `Chevron`, `Steps`). Items carry a period caption, title, paragraph and icon glyph; cards, boxes and bubbles size themselves to their text, and the `Hanging` design wraps bubble text to the circle and staggers bubbles over several tiers so they never collide. Palettes (`CorporateBlue`, `Vibrant`, `Pastel`, `Ocean`, `Sunset`, `Forest`, `Slate`, `Mono`, custom), `PerItem`/`Single`/`GradientAlongPath` color modes, a dark theme, side policies, reversible direction, a "current position" pending style, an independent scale-label track, hover/selection/tooltips with callbacks and node/content geometry queries. `TimelinePlacement::Proportional` positions items by real dates (serials compatible with `GanttDate`) while keeping the decorative design. New demo page (Info Graphics > Timeline Diagram) and guide in `Docs/UltraCanvas/UltraCanvasTimelineDiagram.md`; the research behind splitting narrative timelines from date-accurate ones is in `Docs/UltraCanvas/UltraCanvasTimelineDiagramProposal.md`. - Fixed duplicated Trim and base64 code. #### 2026-07-30 *0.3.20* - **UltraCanvasScatterPlotElement**: correlation / trend line display. The element can now fit a least-squares regression line over its data (`SetShowTrendLine`), styled solid, dashed or dotted with settable colour and width, plus an optional readout of the fitted equation and the Pearson correlation (`SetShowCorrelationInfo` draws `y = ax + b`, r and r² in the plot corner). `ComputeLinearRegression` and `GetCorrelationCoefficient` expose the fit programmatically. Points whose `ChartDataPoint::color` is set now render in that colour, so outliers or categories can be marked without extra elements. - New **UltraCanvasScatterPlot3DElement** (`Plugins/Charts/UltraCanvasScatterPlot3D`): a software-rendered 3D scatter plot for (x, y, z) point clouds. Perspective camera with drag-to-orbit, wheel zoom and view presets; perspective axes with ticks, titles and an optional ground grid that re-anchor to the corner nearest the camera; depth cueing (perspective point sizing plus optional fade towards the background); per-point colours; hover tooltips with X/Y/Z. An optional 3D correlation line — the principal axis of the cloud, i.e. the orthogonal least-squares fit from the covariance matrix's dominant eigenvector — is depth sorted into the points so it threads through the cloud with correct occlusion. `GetCorrelationLine` returns the fitted centroid and direction in data space. The Charts > Scatter Plot demo page now shows both the 2D trend line and the 3D cloud side by side, and a programmer's guide lives in `Docs/UltraCanvas/UltraCanvasScatterPlot3D.md`. #### 2026-07-30 *0.3.20* - New **UltraCanvasGitGraph** element (`Plugins/Diagrams/UltraCanvasGitGraph`): renders a Git commit history — a DAG of commits decorated with branch, tag and `HEAD` refs. Two layout families share one data model: **Lanes**, one lane per open line of development with the newest commit first (the gitk / GitKraken / SourceTree repository view), and **Swimlane**, one band per branch either side of a nominated trunk (the git-flow teaching diagram). Lane assignment is a single active-lane sweep with two strategies — `Stable` keeps a branch on one lane for its whole life (straight branches), `Compact` recycles freed lanes (narrow graphs) — plus trunk pinning so a nominated branch always holds lane 0. Handles merges, octopus merges, multiple roots, boundary commits whose parents lie outside the loaded window, and cherry-picks (dashed non-parent edges). Four orientations, four commit orderings (as-given, commit date, author date, topological), four edge routings (orthogonal with rounded corners, Bezier, arc, straight), ref chips with `+n` overflow, per-commit changed-file boxes with leader lines, callout annotations, an arrowheaded trunk baseline, six themes, virtualised rendering (only rows in the viewport are drawn), zoom/pan/selection/tooltips/keyboard navigation and SVG export. Ingest is programmatic, from `git log` output (`LoadFromGitLog` + `GitLogFormat`), or via the authoring API (`Branch`/`Commit`/`Merge`/ `CherryPick`/`Tag`) — the element is read-only and never executes git. The layout core (`UltraCanvasGitGraphLayout`) is headless and covered by `Tests/GitGraphLayoutTest.cpp` (11 cases, including 200 randomised DAGs checked against the placement invariants). New demo page (Diagrams > Git Graph), guide in `Docs/UltraCanvas/UltraCanvasGitGraphExamples.md`, research write-up and roadmap in `Docs/UltraCanvas/UltraCanvasGitGraphProposal.md`. - **UltraCanvasGitGraph**: second feature pass. **Lazy loading** — `IGitGraphDataSource` pages a history into the element as the viewport nears the end of what is loaded. **Mermaid I/O** — a headless `UltraCanvasGitGraphMermaid` unit imports and exports the `gitGraph` DSL (commit/branch/checkout/switch/merge/cherry-pick, the id/tag/type/msg/order/ parent attributes, `%%` comments, the `%%{init: ...}%%` header and the LR/TB/BT direction suffixes), reporting parse errors with a line number. **Native `.git` reader** — new `UltraCanvasGitRepository` (core) reads refs (loose, `packed-refs`, annotated tags peeled), loose objects and packfiles including `OFS_DELTA`/`REF_DELTA` chains, inflating with the vendored miniz; no git executable is spawned and no dependency is added, and `UltraCanvasGitRepositorySource` adapts it to the lazy loader. **Filtering** by text, author, path, branch, date range and merge status, with edges through filtered-out commits re-pointed at the nearest surviving ancestor and drawn dashed. **Crossing reduction** — lane columns reordered by a barycentre heuristic that keeps the best measured permutation (about a third fewer crossings over 300 randomised layouts, never worse), budgeted and off by default. **Commit table pane** row-aligned beside the graph with configurable columns and a date formatter, plus a row-alignment API for pairing an external `UltraCanvasTableView`. **Search** over sha, subject, author and refs with next/previous navigation. **Minimap** with a viewport rectangle and click/drag navigation. New tests: `GitGraphMermaidTest`, `GitRepositoryTest` (runs against a real repository) and six more `GitGraphLayoutTest` cases. - **UltraCanvasGitGraph**: third feature pass. **Time-proportional axis** — row position follows the commit timestamp, with a per-gap floor and ceiling so a burst of same-second commits stays readable and a multi-year quiet period costs one large gap instead of an unusable amount of empty axis; an optional date ruler prints one label per calendar day. **Collapsing** — a run of plain single-parent commits folds into one dashed pill labelled with how many commits it stands for, expandable by double-click; refs, merges, roots and cherry-picks are never folded. **Parallel rows** — commits made at the same moment share a row (mermaid's `parallelCommits`), but only when neither is the other's parent and they sit in different lanes, so no edge is ever flattened. **Badges** for GPG signature and build status, **stash chips**, **author avatars** (initials on a colour derived from the author) in the table pane, **label collision avoidance** so overlapping text is dropped rather than stacked, and **JSON export** of the laid-out geometry. Four more layout tests cover collapsing and parallel rows. Note: `GitGraphSignature` / `GitGraphBuildStatus` use `NoSignature` / `NoStatus` and `Passed` / `Failed` rather than `None` and `Success` / `Failure`, because X11's `X.h` defines `None` and `Success` as macros and the header reaches the window backend. - **UltraCanvasGitGraph**: fourth feature pass. **Explicit lane ordering** — `SetLanePriority()` puts named branches in the columns you choose, applied after crossing reduction so it always wins over the heuristic; a pinned trunk keeps column 0 and unlisted branches keep their relative order. **Diff pane** across the bottom of the element: a header naming the commit, the files it touched coloured by A/M/D status, and the patch for the selected file coloured by unified-diff prefix, each half scrolling independently. The element never computes diffs — `SetFileListProvider()` and `SetDiffProvider()` supply them. **Drag to author** — dragging a commit onto another adds a merge on the target's branch, dragging into empty space branches off it; a drag that never moved is just a selection, and a merge duplicating an existing parent link is refused. Also `GetCommitScreenPosition()` for anchoring popovers to a node. - **UltraCanvasGitRepository**: `ReadChangedFiles()` produces a commit's changed files by recursively diffing its tree against its first parent's (a root commit reports its whole tree as added), handling files replaced by directories and vice versa. Verified against `git show --name-status` on this repository, including merges. Blob-level diffing is not implemented. - **VirtualFS / UltraCanvasFilerWidget**: fixed archives always listing as "(empty folder)" on Windows. `VirtualFSPath::Resolve()` prefixed a slash to the real-filesystem part of every absolute path, turning a drive-letter path like `C:/Users/…/archive.zip` into `/C:/Users/…/archive.zip` — a path no provider could open, so double-clicking any ZIP (or other archive) in the filer showed an empty view. Drive-letter paths now keep their bare `C:` prefix through resolution, and `Normalize()` treats them as absolute so `..` components can no longer escape above the drive root. The filer also distinguishes an unreadable archive from a genuinely empty one: when the provider layer cannot open the archive (missing format provider, corrupt or password-protected file), it reports "Cannot read archive: …" through the widget's error callback instead of silently rendering "(empty folder)". New header-only regression test `Tests/VirtualFSPathTest.cpp` covers Unix, relative, backslash and drive-letter archive paths, including nested archives. - New **UltraCanvasCircularProgressChart** element (`Plugins/Charts/UltraCanvasCircularProgressChart`): the angle-encoded member of the circular chart family — every ring carries one independent value drawn as an arc whose sweep is proportional to that value within the ring's own range (concentric "activity rings"). Sub-styles cover the single thick progress ring and the progress pie (filled sector over a track disc). Rings take their colour from a palette or per-ring override, draw the remainder as an auto-tinted, explicit or hidden track, and end in round or butt caps. Labels: percentage/value callouts at each arc tip (optionally in a bubble), ring names inside the band at the arc start, or a stacked column of `label value` rows aligned with each ring's start point. A centre disc with title + subtitle, a numbered-chip or swatch legend on any side (with optional per-ring icons), configurable start angle and winding direction, hover highlighting, tooltips and click/hover callbacks complete the P1 feature set. New demo page (Charts > Circular Progress Chart), programmer's guide in `Docs/UltraCanvas/UltraCanvasCircularProgressChart.md`, plus the family-wide guide `Docs/UltraCanvas/UltraCanvasCircularCharts.md` and the previously missing `Docs/UltraCanvas/UltraCanvasCircularInfoGraphic.md`. - **UltraCanvasPieChartElement**: family-standard controls added — `SetStartAngle` / `SetClockwise` for orientation and winding, `SetCenterKPI(text, caption)` drawn in the donut hole, and `onSliceClick` / `onSliceHover` callbacks. #### 2026-07-29 *0.3.19* - New **UltraCanvasPolarChart** element (`Plugins/Charts/UltraCanvasPolarChart`): a general polar coordinate chart where every observation is an (angle, radius) pair. One element covers the whole family of round plots built on that system — polar scatter, line, spline, area, spline area and columns (the Nightingale rose / stacked polar column chart) — and the types can be mixed in a single chart. The angular axis works in numeric mode (each point carries its own angle, mapped through a configurable domain) or categorical mode (one evenly spaced slot per category, placed either on the grid spokes or between them), with settable zero angle, winding direction, sweep angle for fans and wedges, automatic or explicit tick intervals, horizontal/tangential/radial label orientation and an optional second ring of angular labels with its own interval and side. The radial axis supports linear, logarithmic and square-root (area-true) scales, automatic "nice" ticks or a manual range, negative minima, reversed direction, a donut hole and unit-suffixed labels on a selectable spoke. Circular or polygonal spider-web grids, minor rings, alternating ring shading, radial tolerance bands and angular sector bands render behind the data; column and area series support stacked and percent-stacked modes while unstacked columns group side by side inside their slot. Interaction covers hover highlighting, tooltips, click/hover callbacks, click-to-toggle legend entries in four positions and optional drag-to-rotate. New demo page (Charts > Polar Chart) with six examples and a live control panel, plus a programmer's guide in `Docs/UltraCanvas/UltraCanvasPolarChart.md`. #### 2026-07-28 *0.3.18* - **UltraCanvasMediaViewer**: the folder path strip now uses the same path mechanism as the filer. The shared builder `BuildFolderBreadcrumb()` (plus `ListDriveRoots()` and `FolderBreadcrumbOptions`, declared in `UltraCanvasBreadcrumb.h`) fills a breadcrumb with a leading **"Computer"** node whose dropdown lists every drive / mounted volume, the drive (or root) node, and one node per folder. The media viewer built its own strip before, by iterating the whole `std::filesystem::path`, which on Windows turned the root separator into a node of its own (`C:` → `\` → `Users` → …) and offered no way to reach another drive. The filer demo's private copy of the logic is gone — both now call the shared builder, so a path strip behaves identically wherever it appears (segment click browses the folder; the segment dropdown lists the sibling folders at that level). - **UltraCanvasBreadcrumb**: fixed long paths overflowing the strip instead of collapsing when an interlocking item style (`Arrow` / `Parallelogram`) was used — as seen on the media viewer, whose last folder was clipped at the right edge with no `...` menu. Those styles butt their segments together and add the notch depth (`arrowSize`) per neighbour, but overflow handling was still costing a separator plus its spacing (0 for both presets), so the strip under-measured itself by `arrowSize × segments` and decided everything fitted. Measurement, collapse and slot building now share one per-neighbour cost, and the `...` placeholder carves the same left notch as any other segment. - **UltraCanvasBreadcrumb**: min-content width in `Collapse` mode is now the collapsed floor (kept first item + `...` + the trailing items the style keeps) instead of the full uncollapsed path, so a parent layout can shrink the strip to its own width rather than being widened by a deep path. Other overflow modes keep every item and are unchanged. - New **UltraCanvasSWOTDiagram** element (`Plugins/Diagrams/UltraCanvasSWOTDiagram`): classic four-panel SWOT analysis infographic rendering four text item lists (Strengths, Weaknesses, Opportunities, Threats) in six design presets — corner-badge panels with a central SWOT circle, classic 2x2 matrix (optional internal/external and helpful/harmful axis captions), separated header-bar cards, central letter diamond, stacked rows with big letter blocks, and four columns with header chips. Light/dark theme, per-quadrant titles/badges/accent colors, hover tooltips, item selection with callbacks and built-in sample data. New demo page (Info Graphics > SWOT Diagram) with six tabbed designs and runtime theme/decoration/data controls, plus a programmer's guide in `Docs/UltraCanvas/UltraCanvasSWOTDiagramExamples.md`. A survey of the SWOT presentation styles found in the wild is in `Docs/UltraCanvas/SWOTDiagramDesignVariants.md`. - Fixed slow video thumbnail generation in MacOS in Album control #### 2026-07-25 *0.3.17* - **UltraCanvasFilerWidget**: double-click vs rename behavior corrected (0.3.16 had it wrong). Double-clicking an entry — name or icon — now always opens/activates it: folders and compressed archives are entered, files fire `onFileActivated` (executable start / open with the designated program). The inline rename is instead triggered Windows-style: a single click on the **name** of the entry that is already the only selected one opens the rename editor after a short delay (500 ms, longer than the double-click interval, so the first click of a double-click never starts a rename). A drag, a double-click, a key press, a refresh or a folder/view change cancels the pending rename. - **UltraCanvasFilerWidget**: double-clicking a compressed archive now really opens it like a folder (it always listed as empty before). Three fixes: - The widget listed archive interiors with `VirtualFS_ListDirectory()` without ever initializing VirtualFS, so no archive provider was registered and every archive came back empty. `ScanFolder()` now runs `UltraCanvasVirtualFSBridge::Initialize()` (idempotent) before listing. - VirtualFS entries carry archive-internal paths (`sub/file.txt`); the widget stored them as the entry path, so descending into a folder inside an archive navigated to a nonsense path. Entry paths are now built as `/`, giving full virtual paths (`/path/archive.zip/sub`) that also work for nested archives. - Without the VirtualFS module in the build, activating an archive now fires `onFileActivated` like any other file instead of navigating into a permanently empty view. - **VirtualFS (libarchive provider)**: archives that store no explicit directory headers (Python `zipfile`, several archivers) now show their subdirectories. The entry cache synthesizes a Directory entry for every path ancestor implied by a member (`sub/b.txt` ⇒ `sub`), so subfolders appear when listing the parent and can be descended into; an explicit directory header arriving later replaces the synthesized entry's metadata without duplicating it in the listing. - New **UltraCanvasQuadrantChart** element (`Plugins/Charts/UltraCanvasQuadrantChart`): interactive 2x2 strategic matrices with presets for SWOT, BCG, Ansoff, Eisenhower, Gartner magic quadrant, risk and priority frameworks plus fully custom quadrant labels/colors/axis captions. Data points support per-point color, radius (BCG-style bubbles), shape (circle/square/triangle/diamond) and outline; hover tooltips, click (multi-)selection, double-click callbacks and per-quadrant statistics utilities are built in. New demo page (Charts > Quadrant Chart) with six tabbed examples and runtime style/data controls, plus a programmer's guide in `Docs/UltraCanvas/UltraCanvasQuadrantChartExamples.md`. #### 2026-07-22 *0.3.16* - **UltraCanvasFilerWidget**: - Default display font reduced from 13 to 12 px (Windows standard 9pt @ 96dpi). - The inline rename editor now uses the same font size as the on-screen name for the current view (base size in the row views, the small size in the thumbnail / treemap captions) instead of a fixed larger size. - Double-clicking a file's **name** now starts an inline rename; double-clicking its **icon** (or, in Details view, another column) still opens/activates the entry. - eBook reader: the table-of-contents toolbar button now uses the `list-ordered` icon (drawn as a mask so it takes the button's text color) and highlights while the TOC pane is open (accent fill with a light icon), so its active state is visible. It reverts to the normal toolbar-button look when the pane is hidden. #### 2026-07-22 *0.3.15* - **UltraCanvasFilerWidget**: new **Display > Dataset** submenu with toggles for extra per-file facts shown under the name in the thumbnail views — Size, Edit date, Creation date, Attributes, Length (audio/video) and Dimensions (bitmaps). Each enabled field adds a caption line (Length/Dimensions only appear on the file kinds they apply to); tiles grow to fit and the grid stays aligned. Also available programmatically via `SetDatasetField()` / `SetDatasetFields()` with the new `FilerDatasetField` flags. #### 2026-07-22 *0.3.14* - **UltraCanvasFilerWidget**: picking a format from the context menu's "Compress" submenu now opens a modal compress dialog instead of creating the archive immediately. The dialog shows: - the archive's file-type icon on top, - an editable file name (with the format's extension shown as a suffix), - the destination folder as smaller, separate text. The icon can be **dragged onto any folder in the view** to retarget the destination path — the folder under the icon highlights while dragging, and dropping on it updates the "Location". Enter / the Compress button creates the archive; Esc / Cancel dismisses. Because the icon must be droppable onto the folders behind it, the dialog is an in-widget overlay rather than a separate top-level modal window. #### 2026-07-22 *0.3.13* - **UltraCanvasFilerWidget**: the right-click context menu now closes on a left click anywhere outside it. Previously the popup registered the whole Filer widget as its `popupOwner`, and the window's dismissal logic treats a click on the owner as "inside" the popup — so clicking in the file view left the menu stuck open. The context menu no longer sets an owner, so any click outside the menu bounds dismisses it. - **UltraCanvasFilerWidget**: the context menu's "Compress" entry is now a submenu listing the available archive formats — ZIP, 7-Zip, TAR, TAR+gzip, TAR+bzip2, TAR+xz and TAR+Zstd. `CompressSelection(extension)` takes the target extension (default `zip`) which selects the format. - **VirtualFS (libarchive provider)**: `CreateArchive` now recognises compound archive extensions (`.tar.gz`, `.tar.bz2`, `.tar.xz`, `.tar.zst`, `.tar.lz4`) when picking the format and filter. It previously inspected only the final token (e.g. `gz`), which selected the ZIP format and then layered a gzip filter on top, producing a corrupt archive for those names. - Fix two MOBI/KF8 eBook rendering bugs (seen with the DemoApp eBook demo on `media/ebooks/Game-of-rat-and-dragon.mobi`): - **Drop caps.** Mobipocket/Project-Gutenberg books set the decorative first letter of a section as a floated image (`
      P
      ` in front of the paragraph). The layout engine has no CSS `float`, so each big letter stacked as a centred block *above* its paragraph instead of leading it. The MOBI engine now folds such single-letter drop-cap figures into a large inline first letter at the start of the following paragraph, so "P" reads in front of "inlighting" as intended. Genuine illustrations (multi-character or empty `alt`) are left untouched as block images. - **Inline table of contents.** kindlegen/calibre append the book's own "Table of Contents" page as the last part of the file, so it appeared at the very end of the chapter list. It is now moved to the second page, right after the cover/title image, where readers expect it. - **UltraCanvasListView now supports variable row heights.** The delegate hook `IItemDelegate::GetRowHeight(model, row)` — previously declared but never consulted — is now wired into the view when variable mode is enabled via `SetVariableRowHeights(true)`. Every row can report its own height; the view keeps a lazily-rebuilt prefix-sum of row tops so scrolling, hit-testing (`GetRowAtY`), `GetRowRect`, `EnsureRowVisible`, `ScrollToRow`, culling and Page Up/Down navigation are all height-aware. Uniform rows stay the default and keep their original arithmetic fast path (no table). Call `InvalidateRowHeights()` when a custom delegate's sizing changes without a model signal (e.g. an async delegate finished measuring a row). - **UltraCanvasFilerWidget shrinks thumbnail rows to fit landscape images.** In the thumbnail grid views the tiles are square (the selected Small / Medium / Big / Maximized edge), which leaves a tall empty band above and below wide photos. A grid row whose images all display shorter than the tile edge is now shortened to the tallest image actually shown in it; a row that holds any full-height item (a folder, a generic-glyph file, a vector/portrait/square or not-yet-measured image) keeps the full edge. Natural image sizes come from the existing header-only probe (no decode), cached per file. Controlled by `SetShrinkThumbnailRows(bool)` (default on). #### 2026-07-21 *0.3.12* - Fix GIF export failing with `magicksave: libMagick error: NoEncodeDelegateForThisImageFormat 'gif'` (seen on the DemoApp bitmap performance-comparison page). When libvips has no native cgif `gifsave`, the previous fallback routed the write through ImageMagick's `magicksave`, but ImageMagick's GIF *coder* is itself an optional build-time delegate — on systems without it the save threw at run time. GIF export no longer depends on either cgif or ImageMagick: a bundled, dependency-free GIF89a encoder (`libspecific/Cairo/UltraCanvasGifEncoder.h`, median-cut quantisation + LZW, like the existing bundled BMP/QOI encoders) is used whenever native `gifsave` is unavailable. It honours the requested colour depth and interlacing and keeps 1-bit transparency for RGBA sources. Both `UCImageRaster::Save` and PixelFX `SaveGif` route through it. #### 2026-07-20 *0.3.11* - Make work VTracer/Vectorizer plugin. - Implement RemoveFromCache() method for images used for reload - OCR plugin now supports **all Tesseract languages**, not just the bundled English pack. The full upstream catalogue (~130 languages) is exposed via `UltraCanvasOCR::SupportedLanguages()`, and any language's `traineddata` is fetched on first use instead of having to be pre-bundled: - `EnsureLanguages({codes}, err, tier)` seeds each requested pack from a local copy when one exists, otherwise downloads it (via UltraNet), consolidates them into a single directory so Tesseract can load them together, and reconfigures the engine. - `DownloadLanguage(code, tier, err)` fetches a single pack; `OCRDataTier` picks the source repo (`Fast`→tessdata_fast, `Standard`→tessdata, `Best`→tessdata_best). - `InstalledLanguages()` / `IsLanguageInstalled(code)` report what is present locally; downloaded packs are cached once under `LanguageDataDir()` (per-user data dir) and discovered automatically by the Tesseract engine's data-path resolver. - On a build without network support, packs can be dropped into the per-user directory manually and are picked up the same way. - The DemoApp OCR screen gains a language dropdown populated from the full catalogue; languages that are not installed yet are marked and downloaded on demand when "Run OCR" is pressed. #### 2026-07-19 *0.3.10* - Fix GIF export failing with `VipsOperation: class "gifsave" not found` on builds whose libvips lacks cgif (the MSYS2/Windows package is built with `-Dcgif=disabled`). `UCImageRaster::Save` and PixelFX `SaveGif` now probe for the native `gifsave` operation and fall back to the ImageMagick bridge (`magicksave` with `format=gif`), which the Windows package already ships. - Image export errors no longer include stale libvips messages from earlier operations (e.g. recoverable HEIF "bad seek" noise appearing inside a GIF save failure): the libvips error buffer is cleared before each save. - `UltraCanvasLabel` now renders its text vertically centered by default (`LabelStyle::verticalAlign` and the `SetAlignment()` vertical default changed from `Top` to `Middle`), so labels line up with the text of neighbouring buttons/checkboxes in toolbar rows. Auto-sized labels are unaffected (their box hugs the text); labels that need top alignment can request it explicitly via `SetAlignment(h, VerticalAlignment::Top)`. Fixes the misaligned file-dimensions info label in the DemoApp codec comparison benchmark toolbar. - Filer widget: optional **"Compressed thumbnails"** mode (`SetCompressedThumbnails(bool)`, default off; toggle in the Filer demo). Finished thumbnails are held in memory QOI-compressed instead of as raw ARGB32 pixmaps (measured 6.4× smaller on the demo set; typically 2–4× on photos), with a 32 MB hot cache of decompressed tiles covering the visible + prefetch bands so scrolling still draws raw surfaces. The codec is a new Cairo-native QOI variant (`libspecific/Cairo/QoiPixmapCodec.h`, separate from the `qoi.cpp` file-format codec) that compresses the premultiplied ARGB32 buffer in place — bit-exact round trip (rendering is pixel-identical in both modes), ~140 µs encode / ~32 µs decode per medium tile, HiDPI device scale preserved. `GetThumbnailCacheStats()` reports stored vs. raw bytes for A/B comparison. - Filer widget: thumbnail decoding is now **viewport-driven with a one-screen prefetch**. Only files whose tiles are visible — plus at most one viewport height (width in the horizontal List view) ahead in scroll direction — are ever decoded, so slow scrolling almost always lands on already-decoded tiles. The decode queue is rebuilt every frame in priority order (visible tiles first, prefetch band after), and pending decodes that scroll out of both bands are dropped from the queue — a fast flick past hundreds of photos decodes only what you stop at, and the tiles you are looking at never wait behind tiles you scrolled past. - Filer widget: thumbnails are now loaded **asynchronously**. Opening a folder renders its content immediately (names, layout, info bar) with the generic category glyph in each tile; the real image thumbnails are decoded on background worker threads and fill in as they become ready, each batch posting one coalesced redraw via `PostToUIThread`. Previously the first frame of a folder blocked until every visible thumbnail was fully decoded on the UI thread, which froze the window for seconds on photo folders — in all views (the details/list icon column decoded images too). Decoded pixmaps land in the shared image/pixmap caches (so other consumers get cache hits), the widget's own bookkeeping is bounded by a 96 MB budget, decodes of the same file are serialized, and pending work is dropped on folder change / view change / widget destruction. #### 2026-07-19 *0.3.9* - Merge "JSON support in UltraCanvas API" - Merge "JMAP support for UltraNet" #### 2026-07-17 *0.3.8* - Fix missing method implementation SetIconMaskColor() in the Button - Fix crash in the PixelFX FloodFill demo #### 2026-07-12 *0.3.7* - Filer widget: in the thumbnail views (`ThumbnailsSmall`/`Medium`/`Big`/ `Maximized`) images smaller than the tile are no longer upscaled to fill it — they are drawn at their original size, centered in the tile (`ImageFitMode::ScaleDown`). Larger images still scale down to fit as before, and the other views keep their `Contain` icon fitting. - `UltraCanvasTabbedContainer`: the overflow dropdown is now disabled for vertical tab layouts (`TabPosition::Left`/`Right`). It rendered a faulty display there and was not usable for vertical tabs, so `CheckIfOverflowDropdownNeeded()` always returns `false` when tabs are vertical — the overflow button never displays or takes part in tab-bar layout. If the feature is accidentally switched on for vertical tabs (enabling the dropdown while vertical, or switching to a vertical position while the dropdown is enabled), a warning is emitted: "Overflow dropdown not supported for vertical tabs". - Rating: fixed the built-in **Circle** symbol never showing its filled/selected state, so a circle rating appeared to ignore clicks (the "Circle and Square symbols" demo row). The filled portion of each symbol is painted by re-drawing the shape inside a `ClipRect` (clipped to the filled fraction); the circle was drawn with `FillCircle`, whose arc-based fill is not rendered inside an active clip region on some back-ends, so only the unclipped empty base showed and the rating looked unselectable. The circle is now drawn as a filled polygon via `FillLinePath`/`DrawLinePath` — the same clip-honouring primitive the Star uses — so all three built-in shapes (Star, Circle, Square) render their fill through a consistent code path. The disc is visually unchanged. - Album demo: the video player window gained an info bar under the video surface showing the clip's title and its source link (clickable — opens in the system browser), and the Lola Lexy tile's link line now reads `youtube.com/LolaLexy`. - Animated images (GIF / animated WebP) now play in the lightbox image viewer (`UltraCanvasImageViewer`): the zoom / pan surface steps them with the shared `UCImageAnimationController`, so zoom and pan apply to the running animation — matching `UltraCanvasImageElement` and the media viewer. - Album demo: the seed list now leads with an animated GIF tile ("Charlie Chaplin run", `media/images/charlie-chaplin-run.gif`) that plays in the photo lightbox; removed the placeholder tiles "Brand Reel", "Chill Beats" and "Roadtrip". - Fixed Album demo video-window bugs (merged as PR #102): closing the player window while a clip is playing (title-bar close button included) now stops playback — the demo viewer hooks `onWindowClosed` to stop the player and release its retained window reference, so the decode pipeline no longer keeps playing audio after the window is gone. Replaying a finished clip shows video again: `UltraCanvasVideoPlayer::Play()` rewinds to 0 after end-of-stream (an EOS-parked pipeline produces no data), and `UltraCanvasVideoPlayerElement` no longer stops its frame timer on EOS (Play re-arms it), so frame uploads resume instead of leaving a frozen surface with audio only. #### 2026-07-11 *0.3.6* - Hover video preview for the album widget: resting the cursor on a Video tile plays a short muted inline preview of the clip in place of its static poster frame (opt-in via `AlbumConfig::videoHoverPreview`, with configurable dwell delay, duration, loop, start offset, mute and preview fps). The engine behind it is the new reusable `UltraCanvasVideoHoverPreview` (`include/UltraCanvasVideoHoverPreview.h`): dwell-delayed muted playback, frames delivered as a ready-to-draw `UCPixmap`, self-limiting duration, one decode session at a time, and a silent fallback to the static thumbnail with the null video backend. The demo's Album page enables it on its video tiles. See the "Hover video preview" section in `Docs/UltraCanvas/UltraCanvasAlbumExamples.md`. - Added "jump to last window": the application now keeps a most-recently-used window focus history and `JumpToLastWindow()` raises + focuses the window used before the current one, restoring keyboard focus to the input field that was active there; repeated triggers toggle between the two most recent windows. Bindable to a keyboard shortcut and/or a mouse button via `SetJumpToLastWindowKey()` / `SetJumpToLastWindowMouseButton()` (disabled by default; the demo binds F6 and the mouse Back button). The Linux and Windows back-ends now translate the mouse side/thumb buttons (X11 buttons 8/9, Windows XBUTTON1/2) as `UCMouseButton::Back`/`::Forward`. Click-to-focus now sends proper `WindowBlur`/`WindowFocus` events to the windows involved (previously the raw MouseDown event was re-dispatched to both). See `Docs/UltraCanvas/UltraCanvasJumpToLastWindow.md`. #### 2026-07-11 *0.3.5* - `UltraCanvasListView`: new cell-level callbacks `onCellClicked` and `onCellHovered` (row, column, cell-local position) plus the `GetColumnAt()` hit-test helper, so delegates can implement per-cell interactive regions (links, buttons) in multi-column views. Hover leaves are reported as (-1, -1) and the view now also resets its hover state on `MouseLeave`. - Demo: the Dependencies & Third-Party page is now interactive. Library names render as links — hovering underlines them, shows a hand cursor and a tooltip with the website / source repository / license; clicking opens a popup with "Website" and "Source code" entries (or opens the site directly for OS frameworks without a public repository). Each library additionally carries its license tag after the name — e.g. (MIT), (LGPL 2.1) — which is its own link to the license description page on spdx.org. `Docs/Dependencies.md` gained the matching License column and the previously missing OCR / Vectorizer / LaTeX plugin libraries. - Scrollbar: a custom handle image (`thumbImagePath` / `thumbImagePathHorizontal`) is no longer stretched to the thumb rectangle. The handle is now always scaled preserving its aspect ratio and centered in the thumb, so the grip keeps its shape regardless of thumb length. The `thumbImageFit` style field was removed accordingly. #### 2026-07-10 *0.3.4* - Demo: the "Networking (UltraNet)" page moved from Tools into the "ULTRA OS modules ▸ Ultra Net" tree entry and is now presented like the FileLoader module page — Overview / Details / Examples tabs, with the live remote-resource loader on the Examples tab. - UltraNet: fixed https:// requests failing with "Problem with the SSL CA cert (path? access rights?)". libcurl bakes the CA bundle path of the build machine into the library; when that path does not exist on the machine the app actually runs on, every TLS request failed. When no `UltraNetConfig::caBundlePath` is set, UltraNet now discovers the system trust anchors at runtime — `CURL_CA_BUNDLE` / `SSL_CERT_FILE` environment overrides first, then the well-known distro bundle locations (Debian/Ubuntu, Fedora/RHEL, openSUSE, Alpine/BSD) and the hashed certificate directory — and passes them to libcurl. - Rating: fixed half-step (0.5) values never displaying. `CreateHalfRating` applied the initial value before enabling half steps, so it was snapped to a whole number (e.g. 3.5 became 4) and the half-filled symbol never appeared. Half steps are now enabled before the value is set, so ratings like 3.5 render as three full symbols plus a left-half-filled one. - Implemented GIF (and animated WebP) animation support. Animated images now play in `UltraCanvasImageElement` (auto-play on load, with Play/Pause/Stop/SetAnimationEnabled control) and in the media viewer, where zoom/pan/rotate/mirror apply live to the running animation and colour adjustments freeze it on the current frame. Frames are decoded once through libvips' multi-page loader into a shared, cached `UCImageAnimation` (per-frame delays, loop count honoured, near-zero delays shown at 100ms); playback is stepped by the new reusable `UCImageAnimationController` on the same main-thread app timer the video player element uses for its frame ticks. Multi-page stills (TIFF/PDF) keep displaying as static images, and the info popup shows the frame count for animated files. See `Docs/UltraCanvas/UltraCanvasAnimatedImages.md`. - Fixed Tesseract OCR plugin #### 2026-07-08 *0.3.3* - ODT reader: real-world letter documents now render their letterhead sections. `draw:text-box` frames (sender/contact blocks) are parsed into regular blocks instead of being dropped; master-page headers and footers from `styles.xml` (bank details, register lines, region-left/center/right columns) are emitted before/after the body separated by a rule; page-anchored `draw:frame`s directly in the text flow (e.g. signature images) are handled; picture hrefs with a `./` prefix load correctly and external (linked) pictures are skipped; hidden sections (`text:display="none"`) and hidden text no longer leak into the output; text boxes anchored inside table cells flatten into line-broken cell text; named/automatic list styles in `styles.xml` are now honored. - Demo: the ODT Documents page now presents the loaded document as a full DIN A4 page (794 x 1123 px at 96 DPI) — a white page centered on a neutral desk background with letter-like margins; the demo display area scrolls to reach the rest of the page. - Implemented clipboard handling fort TextInput controls - Merged "UltraCanvas arrow-key value selector" - Merged "UC eBook renderer issues" - Merged "UltraCanvas demo treeview fixes" - Merged "Docusaurus integration for UltraWeb" - Merged "UltraCanvas ODT rendering gaps" #### 2026-07-06 *0.3.2* - Demo: the LaTeX Documents page now typesets every document **live** from its `.tex` source through the on-demand UltraCanvas LaTeX engine instead of showing a pre-rendered screenshot. Added a set of math-mode example documents (`media/LaTex/math-*.tex`) that render live, and removed the TikZ / pgfplots and document-mode (`tabular`, `figure`) examples — which the math engine cannot typeset — along with their reference images. A reference image fallback remains in the demo for any unsupported `.tex` dropped into the folder later. #### 2026-07-05 *0.3.1* - Merged "ODT/DOCX support for file elements" - Merged "UltraCanvas text document support" - Merged "UltraCanvas eBook file support validation" #### 2026-07-04 *0.3.0* - Implemented UltraNet networking module — full v1.0 master-registry surface plus v1.1 extensions (`UltraNet/UltraNetCore.h`, `UltraNetHttp.h`, `UltraNetUrl.h`, `UltraNetWebSocket.h`, `UltraNetFtp.h`, `UltraNetSocket.h`, `UltraNetTls.h`, `UltraNetDns.h`, `UltraNetCookies.h`, `UltraNetPlugins.h`, `UltraNetSse.h`) - HTTP / HTTPS sync + async via libcurl multi-handle worker thread; HTTP/3 (QUIC) via nghttp3 when libcurl was built with it - WebSocket client (libcurl native `curl_ws_*`; runtime capability check with a clear error when libcurl was built without `--enable-websockets`) - FTP / FTPS / SFTP download / upload / list / delete / rename / mkdir / rmdir; rich listings via MLSD (with a UNIX `ls -l` fallback) - Raw TCP / UDP sockets (POSIX sockets / Winsock) - TLS wrap on raw TCP — OS-native backends: OpenSSL (Linux), Schannel (Windows, `SCHANNEL_CRED`), SecureTransport (macOS) — no extra TLS deps on Windows / macOS - DNS: A / AAAA / PTR via getaddrinfo; MX / TXT / SRV / NS / CNAME / SOA via libresolv (Linux/macOS) and dnsapi (Windows); optional async c-ares backend (`ARES_OPT_EVENT_THREAD`) covering the full record set - Sessions with `CURLSH`-backed cookie + connection-pool sharing - Plugin system with `IUltraNetPlugin` + seven specialised category interfaces (mail, messaging, remote-access, directory, streaming, file-share, RPC); v2 host-vtable DSO contract (`UltraNet_PluginInit`) with v1 (`UltraNet_PluginRegister`) fallback; dynamic DSO loading via dlopen / LoadLibrary - **All 17 spec plug-ins ship** in `Plugins/UltraNet/`: - Mail: SMTP · IMAP · POP3 - Messaging: MQTT · AMQP - Remote access: SSH · Telnet - Directory: LDAP - Streaming: RTSP · RTMP · RTP (in-tree RFC 3550 receiver) · SIP (in-tree RFC 3261 UDP) - IoT: CoAP · SNMP - Discovery: mDNS (Avahi / Bonjour / DnsQuery_W) - Web modern: gRPC · WebDAV - SSE / chunked HTTP streaming (`UltraNet_SseStream` + parser) — for token-by-token LLM responses - Per-request progress callbacks alongside the global transfer-callbacks bag - Streamed HTTP upload from disk (constant memory) - `UltraCanvasApplicationBase::PostToUIThread(std::function)` for marshaling network completions back to the UI thread from background worker threads - `UltraCanvasFileLoader::LoadFile(pathOrUrl)` now dispatches `http://` / `https://` URLs to UltraNet automatically - Networking demo screen in the demo app (Tools → Networking) — loads a remote image via `UltraCanvasFileLoader::LoadFile(url)` - UltraNet test suite (`Tests/UltraNet/`, 102 tests, in-tree framework, CI wired via `ULTRACANVAS_BUILD_NET_TESTS=ON`) #### 2026-06-26 *0.2.32* - Implemented HiDPI and scaling for all platforms - Merge "LaTeX document renderer for UltraCanvas" - Merge "OCR and vectorize solution for UltraCanvas" - Merge "UltraCanvas heatmap demo presets" - Merge "Media viewer widget for UltraCanvas" - Merge "UltraCanvas Gauges demo fixes" #### 2026-06-26 *0.2.31* - Merge "UltraCanvas popup changelog link" - Merge "UltraCanvas Gauge demo layout" - Merge "UltraCanvas Slider demo layout" #### 2026-06-26 *0.2.30* - Demo: moved the **Waveform Chart** example out of *Audio Elements* and into the *Charts* category, where it belongs alongside the other data visualizations (a waveform is an amplitude-over-time plot, not a structural diagram). - Waveform: added a **Display range** control to the waveform demo and a backing `SetVisibleWindowSeconds()` API on `UltraCanvasWaveformElement`. The view can now show the whole track ("All audio") or a trailing window that scrolls with the playhead — "Last 10 seconds" or "Last 60 seconds". Rendering, click-to-seek and the playhead all map to the visible window. - Merge "UltraCanvas Album demo optimizations" - Merge "UltraCanvas Gauge layout overlap" #### 2026-06-24 *0.2.29* - Merge "Waveform chart UltraCanvas integration" - Merge "UltraCanvas Heatmap demo optimization" - Merge "UltraCanvas treeview auto-scroll" - Merge "Module infos MD diagram/image viewer" - Changelog link on startup info page - Merge "UltraCanvas demo Modules info pages" #### 2026-06-24 *0.2.28* - Fixed crash in the UltraCanvasDependenciesExamples demo screen - In the UltraCanvasListView change model pointer to shared_ptr instead of raw pointer, prevent possible crashes #### 2026-06-23 *0.2.27* - Fixes Video Player issues (freeze video/audio mainly in Linux) - Fix Video Player layout issues, wrong aligned text, use icons instead of manual drawing #### 2026-06-23 *0.2.26* - Docs: audited every implemented demo element for a wired-up Programmer's Guide / example doc and filled all the gaps. Added five new guides — `UltraCanvasScrollbarExamples.md`, `UltraCanvasSlideshowExamples.md`, `UltraCanvasQRCodeExamples.md`, `UltraCanvasSpreadsheetExamples.md` and `UltraCanvasXARExamples.md` — each documenting the real public API (no invented symbols) with runnable examples drawn from the matching demo source. - Demo: wired the **C++ source** and **documentation** header icons for the elements that were missing them — Scrollbars, Spreadsheet, Slideshow, QR code and XAR now point at their example `.cpp` and new `.md`; Video and Audio now point at their existing `UltraCanvasVideoExamples.cpp`/`UltraCanvasVideo.md` and `UltraCanvasAudioExamples.cpp`/`UltraCanvasAudio.md`; and Album now links its existing `UltraCanvasAlbumExamples.md`. #### 2026-06-23 *0.2.25* - Gauges: added a Programmer's Guide (`Docs/UltraCanvas/UltraCanvasGaugeExamples.md`) covering the full mode-driven API — all 17 `GaugeMode`s, the round-gauge (CircularRing) style system, decorations (ranges/thresholds/external pointers/sub-dial), live clock & stopwatch controls, and a runnable code example per gauge family. - Demo: the Gauges demo page now shows the **C++ source** and **documentation** header icons (wired its `demoSource`/`demoDoc` to `UltraCanvasGaugeExamples.cpp` and the new guide), matching every other element, plus the four tab variants (Round Gauges, Progress & Linear, Specialized, Analog) in the tree. - Fix Windows video playback: the player loaded a file and played audio but showed no picture, and the play/pause/stop transport misbehaved (multiple clicks to stop, no resume after pause). Media Foundation's sample-grabber video sink rejected the session's rate control (`MF_E_UNSUPPORTED_RATE`) and never delivered a single frame, which also corrupted the session and scrambled the transport state. `MFDecodeSession` now decodes video through an `IMFSourceReader` (RGB32, advanced video processing) paced to the audio clock, with an audio-only Media Session providing sound + the master clock (video-only files fall back to a wall clock). Also force opaque alpha on MF RGB32 frames — the unused "X" byte was 0, which rendered fully transparent in the premultiplied Cairo `ARGB32` pixmap. Linux/GStreamer playback is unchanged. - Video: added a cross-platform thumbnail / poster-frame API (`UltraCanvasVideoThumbnail.h`). `CaptureVideoThumbnail()` returns a single decoded frame, `CaptureVideoThumbnailPixmap()` a ready-to-draw `UCPixmap`, and `SaveVideoThumbnail()` writes a file (encoder chosen from the extension: `.qoi` → QOI, otherwise PNG — both need no libvips), e.g. for `UltraCanvasAlbum` `thumbnailPath`. Each takes a `VideoThumbnailRequest` (target time — or an automatic position — plus optional aspect-preserving `maxWidth`/`maxHeight`). - Video: new opt-in backend capability `IVideoBackend::GrabThumbnail()`. The GStreamer (Linux) backend implements it as a throwaway `uridecodebin` pipeline that prerolls to PAUSED, seeks accurately and pulls one preroll sample (no audio, no full playback). Backends without it (null / Media Foundation / AVFoundation) use a generic decode-session fallback, so thumbnails work wherever decoding does. #### 2026-06-23 *0.2.24* - Merge "Color picker widget for UltraCanvas" and fix layout errors. - Fix toolbar button width (make it auto) - Fixed incremental search in TextArea (stop advance on each typed matched character) - Refactor Audio element. Use composite widget instead manual draw. Use SVG icons for play/pause/etc.. buttons #### 2026-06-21 *0.2.23* - `UltraCanvasGLSurface` now resizes its render target / framebuffer to follow the element's actual bounds on every render, however the bounds were changed. Previously the framebuffer size (`surfaceWidth_`/`surfaceHeight_`) was only updated from the `SetBounds` override, so a layout-driven resize — flex/grid stretch, a parent resize, `SetElementSize`, a window resize — left the GL content stuck at its old size (it wrote `finalBounds` without routing through `SetBounds`). `Render()` now syncs the framebuffer size from `GetLocalBounds()` and forces a content re-render that pass, so GL surfaces resize correctly under any layout path (this is what made the Shaders-tab "maximize" need an explicit `SetBounds`; flexible/maximized GL surfaces now grow on their own). #### 2026-06-21 *0.2.22* - Fix the "maximize canvas" control in the OpenGL 3D Showcase "Shaders" tab. Three issues: (1) the toggle button was pinned to the tab content-area's right edge instead of the canvas's top-right corner, so once maximized it sat far from the (un-grown) canvas; (2) it showed a "⛶" glyph the demo font lacks (rendered as "…"); (3) clicking it did not enlarge the canvas. The canvas was being resized via `SetElementSize`, which only sets a CSS dimension — but the tab content is absolutely positioned (no layout pass re-applies it) and, crucially, `UltraCanvasGLSurface` only grows its GL framebuffer from its own `SetBounds` override. The maximize now resizes the surface with `SetBounds` (so the framebuffer actually grows to fill the tab), pins the button to the surface's current top-right corner in both states, and renders the new `media/icons/maximise.svg` icon (drawn as a white mask on the dark translucent button) instead of the missing glyph. #### 2026-06-21 *0.2.21* - Fix the per-effect parameter-slider captions ("Brightness", "Splat radius (px)", etc.) being partially obscured in the OpenGL 3D Showcase "Shaders" tab control panel. The sliders use an always-on `Number` value display, which `UltraCanvasSlider` draws just above the track — and since the slider pins its track to the bottom `handleSize` (16px) of its bounds, a short (22px) slider drew the value text ~10px above its own top edge, on top of the caption placed above it. The four per-effect slider groups (Ball Surface, Pulse, Fragments, Circles) now use 36px-tall sliders (handle + a value-text row + margin) and re-spaced rows, so each value number sits inside its slider below the caption with no overlap. #### 2026-06-21 *0.2.20* - Curated the OpenGL 3D Showcase "Shaders" tab effect list. Removed four effects together with their GLSL/source, formula headers, per-effect uniforms, state fields and parameter-slider groups: "Warp Starfield", "Rössler Attractor", "Mandala (12-wave)" and "ULTRA OS Logo". Reordered the remaining list so the three Twigl one-liners "Horizon", "Protostar2" and "Plasma Orb" appear first. The info text and `Docs/UltraCanvas/UltraCanvasGLSurfaceExamples.md` were updated to match; effects with live sliders are now Ball Surface, Pulse, Fragments and Circles. #### 2026-06-21 *0.2.19* - Fix the OpenGL 3D Showcase demo showing horizontal and vertical scrollbars inside every tab even when the window had room. The tabbed container was `980×690` with a 34px tab bar, so each tab's content area was only `980×656` — smaller than the tab contents, whose control panel reaches x≈986 and whose Shaders source viewer reaches y≈680. Since each tab's root is resized to the content-area bounds (`autoShowScrollbars` defaults on), the small overflow forced scrollbars. The showcase container (`1024×800`) and tabbed container (`1004×726`, content area `1004×692`) were enlarged so the content area clears every tab's children with margin; the three tab roots were updated to match. No scrollbars now appear in any tab. #### 2026-06-20 *0.2.18* - Breadcrumb: added a `Parallelogram` item style (`BreadcrumbItemStyle::Parallelogram` + `BreadcrumbStyle::Parallelogram()` preset) — interlocking slanted/skewed segments (outer edges of the first/last segment stay vertical) sharing the `arrowSize` skew depth with the Arrow style. - Breadcrumb: added an optional **level indicator** — a leading numbered badge per item. `BreadcrumbStyle::showLevelIndicator` enables it; `levelIndicatorBackground` selects the badge background (`Round`, `Rectangle`, or `NoBackground`); `levelIndicatorBorder` outlines it; plus `levelIndicatorSize`/`levelIndicatorColor`/`levelIndicatorTextColor`/`levelIndicatorBorderColor`/`levelIndicatorBorderWidth`. The new `BreadcrumbStyle::Steps()` preset combines Arrow segments with round numbered badges (dark "wizard step" strip). Works with any item style. - Demo: added "15. Numbered steps", "16. Parallelogram", and "17. Level indicators" (round / rectangle / none / bordered) rows to the breadcrumb demo page. #### 2026-06-20 *0.2.17* - Breadcrumb: added an `Arrow` item style (`BreadcrumbItemStyle::Arrow` + `BreadcrumbStyle::Arrow()` preset) — interlocking right-pointing arrow/chevron "steps". Each segment grows a pointed tip past its right edge that nests into the next segment's matching left notch (the first segment is flat-left, the last one's tip trails off), with the current step highlighted. New `BreadcrumbStyle::arrowSize` controls the tip/notch depth. Added a "14. Arrow steps" row to the breadcrumb demo page. #### 2026-06-20 *0.2.16* - Fix the Breadcrumb demo: item text was not vertically centered within the strip/pills. The element hand-rolled its vertical centering as `centerY - (int)textHeight / 2`, truncating the half-height to an integer (drifting the glyphs ~1px off the center line shared by the separators and icons) and, more importantly, never routing through the text layout's automatic centering — so it never compensated for the font's top line-leading. On fonts that split external leading above the baseline (e.g. Segoe UI on Windows) this pushed the visible text several pixels low. Breadcrumb item/overflow text now centers via the text layout's `VerticalAlignment::Middle` over the slot height (full sub-pixel precision), and `UCTextLayout::GetLayoutVerticalOffset` re-enables the top-leading compensation for Middle alignment, computed in Pango units to keep the sub-pixel offset (a previous int-pixel version had regressed Segoe UI 12pt). The compensation is a no-op where `baseline == ascent` (DejaVu/FreeSans on Linux), so other platforms are unaffected. #### 2026-06-19 *0.2.15* - Fixes Gstreamer build in Linux - Fixes Gstreamer (video player) and native dialogs crash in Linux - Merged "Add Radar Chart element" - Fix Radar Chart rendering and animation - Show ULTRA OS overview page when "ULTRA OS modules" tree node is selected - Merge "Add rounded corner label examples and fix resource paths" - Merge "Add digital clock, segmented ring, centre content, and faded colours to gauges" #### 2026-06-19 *0.2.15* - Added intro description for different Modules #### 2026-06-19 *0.2.14* - Merge "PDF Text demo page layout" fix - Merge heatmap implementation - Merge "Shader graphics for demo" (more shader examples) - Merge "UltraCanvas module demo content" #### 2026-06-18 *0.2.13* - Fix the Gauge element's linear/progress "rounded bar" rendering at low values. `RenderLinearBar` drew the value fill with a corner radius of half the bar's thickness regardless of the fill length. `FillRoundedRectangle` does not clamp the radius, so once the fill became shorter than the bar's thickness (roughly under 10% on a wide bar) the four corner arcs overlapped and collapsed the fill into a perfect circle. The radius is now clamped to half of the fill's smaller dimension, so short fills render as a proper pill that stays inside the track. #### 2026-06-17 *0.2.12* - Fix two bugs in the OpenGL "Zarch" 3D demo: - The application could not be closed while an animated (Continuous) GL surface was on screen, and the debug log kept spinning. `UltraCanvasGLSurface::Render` re-posted a full-window `Redraw` event every frame; it now invalidates only the surface's own region and stops re-arming once the window is closing/closed/hidden, so sibling widgets no longer repaint at the animation frame rate and the event loop can shut down. - The release build crashed when opening the Zarch tab (before any 3D was drawn) while the debug build did not. The terrain/tree hash (`Hash2`) multiplied signed `int`s past `INT_MAX`, which is undefined behaviour that an optimised (`-O3`) build is free to miscompile; it now uses well-defined unsigned arithmetic. The Models/Shaders tabs do not use this hash, which is why only Zarch was affected. - Merge "Groupbox border alignment bug" fix - Merge "DatePicker demo layout bug" fix - Merge "Spreadsheet save button with format options" fix - Merge "Texter unsaved tab indicator bug" fix - Merge "UltraCanvas audio layout improvements" #### 2026-06-17 *0.2.11* - Spreadsheet demo: added a "Save…" button to the toolbar that offers every format the engine can write (OpenDocument `.ods`, `.csv`, `.tsv`). Choosing a CSV/TSV name opens a new "Text Export" options dialog (character set, field separator, text delimiter, quoting policy, line ending, optional BOM) with a live text preview; `.ods` saves directly. - Spreadsheet engine: added `SaveCSVWithOptions`/`ExportCSVToString` plus a `CSVExportOptions` struct and a `CSVEncodeFromUtf8` charset encoder (UTF-8/UTF-16/Latin-1/Windows-1252, optional BOM). Fixed `.tsv` saving to actually use a tab separator. - Merge "PDF support", implemented PDF demo (viewer in the demo app) - Merge "Datepicker multi-month display" - Merge "UltraCanvas album widget improvements" #### 2026-06-15 *0.2.10* - Fix the spreadsheet component, editing, keys navigation, cell size changing, scrolling, loading files #### 2026-06-14 *0.2.9* - OpenGL surface support enabled on Windows: implemented the WGL context manager (hidden helper window + legacy-context bootstrap to load `wglCreateContextAttribsARB`, then a requested core/compatibility context). Modern GL entry points are resolved via GLEW (`mingw-w64-x86_64-glew`), since `opengl32.dll` only exports OpenGL 1.1. `ULTRACANVAS_ENABLE_GL` now defaults ON for Windows when GLEW is found. - OpenGL surface support enabled on macOS: completed the CGL context manager (honors the requested GL version/profile and color/depth/stencil config, real extension querying via `glGetStringi`) and let `ULTRACANVAS_ENABLE_GL` default ON for macOS as well as Linux. - Merged with the "UltraCanvas date picker widget" code - Merged with the "UltraCanvas Album widget" code - Merged with the "UltraCanvas Demo Slideshow layout" code - Merged with the "UltraCanvas QR code demo page" code #### 2026-06-12 *0.2.8* - Slideshow demo: reworked the options panel into a labelled-row grid (label column on the left, wrapping option buttons on the right) grouped Controls / Indicator / Indicator edge / Fade style / Panel layout / Image / Letterbox fill. Each group now behaves like a radio with the active choice highlighted, the panel-layout split/overlay/off positions are grouped under sub-labels, and crop focus dims unless the Cover fit is selected - Slideshow demo: retitled the page to "UltraCanvas Slideshow widget" and removed brand-specific references throughout the slideshow widget and its demo - Slideshow demo: framed the live widget with a captioned "Slideshow widget" box and added a "Slideshow widget options" heading above the controls, so it's clear which part is the actual widget and which are programmer-facing options - Slideshow: the widget now takes keyboard focus on click and supports manual navigation with the arrow keys — Left/Down go to the previous slide, Right/Up to the next (numpad arrows too) #### 2026-06-09 *0.2.7* - Merged and fixed the "# Spreadsheet support for UltraCanvas" #### 2026-06-09 *0.2.6* - Merged and fixed the "OpenGL 3D showcase demo" #### 2026-06-09 *0.2.5* - Slideshow: added comprehensive info-panel layouts via `SlideshowInfoLayout` — split on any of the four sides (`SplitLeft/Right/Top/Bottom`), edge overlays on the image (`OverlayLeft/Right/Top/Bottom`), corner overlays (`OverlayTopLeft/TopRight/BottomLeft/BottomRight`), `OverlayCenter`, `OverlayFull`, and `Hidden` - Slideshow: indicators can now hug any edge via `SlideshowIndicatorEdge` (Top/Bottom rows, Left/Right stacked columns) - Slideshow: added `SlideVertical` and `ZoomFade` transition styles - Slideshow: configurable image fitting for mismatched images — `imageFit` (Cover auto-crop, Contain, Fill, ScaleDown, NoScale), an `imageFocus` focal point that picks which part survives a crop, and `gapFill` for letterboxed images (background color, dedicated letterbox color, or a zoomed image backdrop) - Slideshow demo: added pickers for info-panel layout, indicator edge, image fit, crop focus and letterbox fill, plus the new transitions #### 2026-06-07 *0.2.4* - Fix QRCode examples page (fix wrong character and change the default QR Code generation) #### 2026-06-07 *0.2.3* - QR code decoder fixed (installed missing lib and configured github build) - Merged branch "UltraCanvas QR demo field visibility bug" - Merged branch "Barcode widget for UltraCanvas" and fix barcode widget and its demo #### 2026-06-04 *0.2.2* - Added Gauges, Compositor diagram, QR code - Fixed Tabbed container and inner tabs layout - Implemented SortChildNodes method and autoSortChildren property in the TreeView - Fix label layout resize bug - Make Arc diagram and Architectural Adjacency Diagram use Grid layout instead of fixed elements positions #### 2026-06-03 *0.2.1* - Major update. Implemented CSS Flex/Grid/Absolute layout support. #### 2026-05-20 *0.1.39* - Show cursor and allow selection in the TextArea in read-only mode - Autodetext syntax highlighting rules by filename with auto fallbask to extension - Fix possible bug in menu and tooltips rendering (wrong color) #### 2026-05-19 *0.1.38* - Implemented the SplitPane element - Fix bug when scrollbar of outer container overlap with inner container's elements or scrollbar (mouse events incorrectly goes to inner container instead of outer container's scrollbar) - Implemented the Barcode widget with 1D symbology encoders: Code 39 / 39 Extended / 93 / 128 (A/B/C/auto), GS1-128, EAN-13/8, UPC-A/E, ISBN-13, ITF, ITF-14 (with bearer bars), Standard 2 of 5, Codabar, MSI Plessey (4 check-digit modes), Pharmacode. From-scratch C++20 encoders, no external dependencies; live editor + gallery in Tools → Bar code #### 2026-05-18 *0.1.37* - Arc diagram improvement - Change layout of "Bitmap elements" screen in the Demo app - Use TextArea instead of Markdown element in the "Text Document/Markdown" screen in the Demo app - Replace DejaVu default embedded font by Ubuntu font #### 2026-05-17 *0.1.36* - Change the Breadcrumbs element demo - Make more Docs for different controls - Added Slideshow example in Widgets/Slideshow section of Demo app - Fix for Pie Chart labels #### 2026-05-15 *0.1.35* - Remove JXL from Image Performance test as JXL format does not supported by currently used libvips - Fix buttons size to fit text - Fix bug when element is deleted but capturing the mouse or focused then app may crash - Implemented Breadcrumbs element demo #### 2026-05-15 *0.1.34* - Use TextArea to show Markdown info in the Modules section - Add more modules description to Modules section - Implement Breadcrumb demo - #### 2026-05-14 *0.1.33* - Implemented new Image performance demo - Fix problem with AltGr+key in Windows - Fix ordered list content offset in MD-mode in TextArea - Implemented Pie Chart element - Implemented Adjacency Diagram element - Implemented Arc Diagram element - Implemented Breadcrumb element #### 2026-05-12 *0.1.32* - Implemented UltraCanvasFileLoader - Implemented OS Recent files support (add opened files to OS Recent files list) - Fix wrong calculation of mouse coordinates and bounds in the some diagrams #### 2026-05-10 *0.1.31* - Fixed font rendering, now Windows and Linux will rendered using same included DejaVue fonts #### 2026-05-09 *0.1.30* - Implemented more different diagrams - Implemented JitterChart - Attempt to fix menu crash on MacOS #### 2026-05-06 *0.1.29* - Refactor Checkbox code, split to Checkbox/Redio/Switch - Implemented more visual styles for Switch - Attempt to fix crash on MacOS #### 2026-05-06 *0.1.28* - Implement support tooltips for menu itmes - Implement maxWidth option for menu and ellipsize mode for menu items #### 2026-05-04 *0.1.27* - Attempt to implement MacOS HiDPI support #### 2026-04-30 *0.1.26* - Fix cursor position in Markdown mode in TextArea #### 2026-04-28 *0.1.25* - Major rework in the UltraCanvas rendering, implemented optimized rendering. Now popups/tooltips rendered in own surfaces (does not need to repaint main content after show/hide) Implemented partial rendering using dirty rectangles (for any content) #### 2026-04-23 *0.1.24* - Fix tooltips rendering #### 2026-04-23 *0.1.23* - Some color fixes for Dark mode #### 2026-04-20 *0.1.21* - Refactored elements rendering/events coordinate system to use element-based coordinates where 0,0 is top-left element's corner instead of container-based where 0,0 was container top-left corner. Set clipping to element's bounds and save/restore state in container's rendering loop instead rely on element Render(). Don't render invisible elements (hidden by scroll position) #### 2026-04-20 *0.1.20* - Shard very long lines to speed up TextArea on big files. Lines longer than 4000 codepoints are split at a break char (space/tab/punct) or force-split at 12000 during SetText. Known problems: if line has no break chars (very rare case) it will splitted at 12000 char boundary, attempt to glue that lines (backspace or delete) will cause reshard (resplit) again and it will splitted at same boundary, visually it will looks like delete/backspace did not work - Show current line marker (red box) for cursor position - Calculate and show real logical line numbers for split lines. #### 2026-04-17 *0.1.19* - UpdateGeometry for visible childs in container only #### 2026-04-16 *0.1.18* - Revert back to the Sans font for Windows insetad of detecting default font. It detected the "Segoe UI" and this shit font can't be vertically centerted without special patches especially for that font, it always shifted down a little (even in browsers) - Fixed bug with high CPU usage and slow cursor movement on the big files with very long lines. #### 2026-04-16 *0.1.17* - Add on-the-fly submenu regeneration to UltraCanvasMenu - Fix Windows high CPU usage when app is idle. - Fix main-row digit/punctuation key mappings on Linux and macOS - Fix vertical text position issues in Windows (was shifted down a little) #### 2026-04-16 *0.1.16* - Full rework of display and rendering the text. Use Pango layout to format text. Allow to use variable height lines #### 2026-04-06 *0.1.15* - Add platform-native system font detection, replace hardcoded "Sans" defaults