345 Commits
Author SHA1 Message Date
devmobasa 70d2e70f44 feat(configurator): make the language row easy to recognize
Put the language picker first in General UI, with a globe and a title
that includes every supported language. Derive its title and search terms
from shared language metadata.

Add regression coverage for the title, icon and row order, and format the
additions with the pinned Rust toolchain.
2026-10-19 11:39:54 +02:00
devmobasa dcaf924420 Merge pull request #416 from devmobasa/fix/untranslated-config-values
Keep saved font and color values independent of the UI language
2026-10-18 10:36:54 +02:00
devmobasa d9165ca50a fix(configurator): keep font and color config values untranslated 2026-10-18 10:15:24 +02:00
devmobasa 3177c5529b docs(i18n): expose language choices and document README synchronization 2026-10-18 09:14:01 +02:00
devmobasa d3f583e15a feat(i18n): search translated command text without changing English scores 2026-10-17 21:42:09 +02:00
devmobasa afbdbd83b9 feat(i18n): make the language setting easy to find 2026-10-17 21:42:09 +02:00
devmobasa b972b32462 fix(i18n): keep service labels and recorded keys unambiguous 2026-10-17 21:41:48 +02:00
devmobasa c907eef09b Merge pull request #411 from weimona/main
Add multi-language UI support (zh-CN first)  「using deepseek-V4.1-flash」
2026-10-17 17:28:49 +02:00
devmobasa fcbc437816 feat(cli): choose and list the interface language from the command line
`--language CODE` sets the interface language for one run of --active,
--freeze, --daemon or --about without changing config.toml. It accepts
the spellings `[ui] language` does and takes precedence over both
WAYSCRIBER_LANGUAGE and the config. A daemon started with it gives it to
the About windows it opens, and to its overlays as an optional
environment input that an older overlay ignores. The process broker
strips that input from every other helper, so a configurator opened from
an overlay keeps previewing its own Language row. With --daemon-toggle or
an overlay action the flag is an error, since a running daemon's
overlays cannot be changed from outside.

`--list-languages` prints each language's code and name, the configured
language, and the language in effect with the setting that decided it.

Languages now carry their native and English names, and the
configurator's Language row takes its labels from them. The help text,
README, CONFIG.md, I18N.md and config.example.toml describe both options.
2026-10-16 18:42:33 +02:00
devmobasa 38fea7c91f Stop advertising system icon roots as the tray icon theme path
The tray advertised <prefix>/share/icons as its IconThemePath, so a
package installed under /usr reported /usr/share/icons. Tray hosts
already search that directory, and some index the advertised path
recursively: Noctalia walks every installed icon theme each time its
tray drawer opens (noctalia-dev/noctalia#4338).

- Leave the installed path out when it is the icons directory of a data
  directory a spec-following host searches: the XDG data home and the
  system data directories, with the spec defaults and with relative
  values ignored. Custom prefixes such as /opt and Nix store paths keep
  advertising their own icons.
- Advertise the installed path only while the tray names its symbolic
  icon. Colored pixmaps need no theme path.
- WAYSCRIBER_ICON_THEME_PATH still overrides the path, and an empty
  value advertises none. docs/CONFIG.md now documents both.

Refs #412
2026-10-16 18:10:19 +02:00
devmobasa 98b6f54c15 docs(i18n): describe per-process languages, previews and override limits
docs/I18N.md still said the configurator was untranslated, that the
language was set once at startup, and that a translation could reorder
the {} placeholders; the guard rules and the steps for adding a language
were incomplete. It now covers which processes draw translated text and
when each picks up a change, the configurator preview, how auto resolves
in a systemd user service, the fixed placeholder order, the guard's call
forms and its derived-label audit, the limits on local catalog overrides,
search under translation, and every place a new language has to be added.

CONFIG.md, config.example.toml and the catalogs README state the same
timing, override and placeholder rules.
2026-10-16 18:04:08 +02:00
Mona 32bf7901bf Add multi-language UI support (zh-CN first)
Wrap every user-visible string in the overlay and the configurator with
tr()/trf(), make the UI language selectable at runtime, and ship a
complete Simplified Chinese catalog.

Core:
- src/i18n.rs: process-global language state, tr()/trf() lookup, and
  locale resolution (WAYSCRIBER_LANGUAGE > [ui] language > desktop
  locale > English)
- src/domain/language.rs: Language enum with serde spellings
- locales/zh-CN.toml: 2247 entries, embedded via include_str!
- config: [ui] language = "auto" | "en" | "zh-CN"

Coverage:
- overlay: help overlay, context menus, board picker, status bar,
  toasts, command palette chrome, properties panel, about window,
  onboarding hints, top-toolbar menus (wayland + GTK), input HUD,
  arrow-style labels, navigation hints, all action labels
- wayscriber-configurator: shell, sidebar tabs, every settings page,
  dialogs, and status messages; it loads the language at startup and
  again when the Language row changes, so its chrome follows the
  overlay

Guards and tests:
- tests/repository_guards/translations.rs: every tr()/trf() literal in
  src/ and configurator/src/ must have a catalog entry (call sites are
  reported); a value may not repeat its key; placeholder counts must
  match
- tests/i18n.rs: catalog loading, override, and detection
- configurator suite: 519 pass; full lib suite unchanged apart from
  pre-existing/environment failures

Deliberately untranslated: --help/--version, log messages, keyboard key
names, TOML config keys shown in validation errors, gsettings/systemctl
command arguments, CSS class names, and product names.
2026-10-16 11:37:15 +08:00
devmobasa 7ac885f9d2 Keep fractional pen ink and add direct zero-level meter dots
Pen, Marker and Shape Pen strokes now keep fractional pointer positions
from press to commit instead of rounding every sample to whole canvas
units, which left visible stairs on fractional-scale outputs.

- Freehand, pressure and marker points are stored as f64. Motion samples
  closer than 0.75 canvas units to the last kept sample are skipped, the
  release position is always kept, and a skipped tablet sample still
  raises the last kept width to its pressure peak.
- Smoothing runs in floating point with pinned endpoints. Smoothed
  interior points are committed on a 1/1000 canvas-unit grid to keep
  sessions compact.
- Session format 8 stores fractional points and validates them on save
  and load. Integer points from older sessions load exactly.
- Shape recognition, laser and eraser geometry keep integer paths
  through one rounding adapter.
- Level meters and steppers gain an accent dot that sets Off (or Precise
  for shape detection) in one click, and each bar now selects its exact
  level. Built-in and GTK toolbars share the dot painter, and meter rows
  widen by the dot slot so bar widths are unchanged.

Validated with ./tools/lint-and-test.sh and tools/test-gtk-widgets.sh.
2026-10-10 21:22:06 +02:00
devmobasa 8459442fdf Fix capture preflight during late output discovery 2026-10-09 15:38:24 +02:00
devmobasa a9e324b156 Harden session recovery, input handling, and persistence 2026-10-06 00:02:42 +02:00
devmobasa e190a47d67 docs: state when session reports are read and what a remembered file loses
The protocol doc said reports are read only at retirement and removed
either way. It now says the visible-target guard also reads the current
report, and that nothing in a report directory that is not private is read
or removed. CONFIG.md says that a moved or deleted remembered file opens
home even when a backup or recovery copy is left beside it, unlike a
startup --session-file, and its session command list renders as one tight
list again.
2026-10-05 02:02:49 +02:00
devmobasa e3c20f856e docs: describe session reports and keep the session list whole
The hide-and-show paragraph in CONFIG.md had landed inside the list of
session CLI helpers, cutting the last helper off from the list, and its
unquoted <session> was eaten as an HTML tag. It now follows the list, quotes
the button names as code, mentions a remembered file replaced by something
other than a regular file, and states the --no-resume-session decision: a
daemon started with it still continues a named session the overlay switched
to, since a named session file always persists, while its default home
stays unsaved.

The v2 protocol doc now describes the optional launch variables, the
overlay-targets/ reports, when the daemon reads them, and why leftovers do
not reach an older daemon. The codebase overview credits the report writer
and reader to the files that hold them.

The no-Python guard reads the first line of every file for a Python
shebang, including file types it otherwise checks only by name; its header
and the tools README now say so.
2026-10-05 01:34:17 +02:00
devmobasa d5baaaddce feat(session): return to the home session from the Session menu
The Session popover gains a way back to the home session: "Back to
<session>" when the overlay or its daemon started with a session file, or
"Default session" without one. It is disabled while home is already
active, and the side.session.home toolbar item hides it. The label and
destination come from the launch's home input, not from the session the
overlay is in.

Returning home is an explicit session command. Like Open, it saves dirty
current data first, refuses if the canvas or interaction changes while it
is pending, and replaces nothing unless home loads. Home loads the way a
launch would, from its own options for the current output, so recovery,
clear-boundary and tool-restore rules apply, and its load errors stay
those of a startup session file rather than becoming an empty session. A
home with persistence disabled continues unsaved on an empty canvas.

Once home is committed the overlay reports it, so the daemon forgets the
session it remembered. The overlay keeps whether it is home with its
reported target, so the menu needs no file system check on redraw.
2026-10-05 00:21:53 +02:00
devmobasa f4109dd5ba feat(daemon): continue the overlay's last session after hide and show
The daemon now remembers the session its overlay last reported and starts
the next overlay there. The remembered session is two-valued, home or a
named file, lives only in daemon memory, and is resolved when the child is
spawned, so a report read after a request was queued still applies.

The child owner records the start identity proved at readiness and reads
the child's final report inside retirement, before it releases that
identity, on natural exit, on stop and on forced reap. A report must be a
private regular file carrying exactly that generation, PID and start time;
anything else is logged and ignored, and the report is removed either way.
Startup removes reports an earlier daemon left without restoring anything
from them.

Launches carry optional variables: a capability marker, the daemon's home
session file, and the remembered session as the one to continue. The
command line is unchanged, so an older overlay still starts at home. A
--daemon-toggle --session-file request outranks the remembered session for
its activation; a request for home itself starts at home and forgets the
remembered session. The visible-target guard uses the session the running
overlay last reported. The broker allows the new variables and strips them
from helpers that are not wayscriber relaunches.
2026-10-05 00:10:52 +02:00
devmobasa 36a83dc9e6 refactor(tooling): remove Python from tests, guards, and release tools
- Run the daemon overlay fixture as the test binary itself: a startup hook
  runs the production readiness handshake, and a child that exits before
  readiness is the same binary launched under a dedicated name. The launch
  error text stays as it was; per-candidate failures stay on a typed error.
- Port the GTK popup Wayland proxy to Rust, reading message layouts from the
  protocol tables compiled into wayland-client and wayland-protocols.
- Move the process-site, config-writer, and shared-dependency guards to Rust
  tests in tests/repository_guards, run by every cargo test, retire the C#
  copies, and add a guard that keeps Python out.
- Make version check, bump, and release tags C# only, keeping the
  packaging-hotfix rules, and restore the code-health report's status lines.
- Require the .NET SDK for tools/lint-and-test.sh, which runs the same plan
  as ci lint-and-test, and drop Python from Nix inputs and CI packages.
2026-10-04 18:38:34 +02:00
devmobasa 6cd5c37d95 test(session): verify real UI seeds and shutdown persistence across pending phases 2026-10-02 20:11:09 +02:00
devmobasa 8c788d5b87 docs(session): describe background explicit operation phases 2026-10-02 20:10:11 +02:00
devmobasa f627003aca fix(config): enforce finite float policy across all loaded settings 2026-10-02 19:42:18 +02:00
devmobasa c875f590ce docs(config): explain active-session autosave timing and backoff 2026-10-02 19:42:18 +02:00
devmobasa e5629e0f60 refactor(config): move runtime adapters and stable values to their owners 2026-10-02 19:42:18 +02:00
devmobasa 439fd9aee3 fix(config): normalize non-finite click highlights and share numeric bounds 2026-10-02 19:42:18 +02:00
devmobasa 65b6410dcf Fix KDE portal Freeze and Zoom capture on mixed-scale displays (#400)
* Fix mixed-scale portal raster cropping for Freeze and Zoom

* Retry stale portal layouts once with a fresh capture barrier

* Check live Freeze topology before interpreting portal crop errors

SCTK can advertise a new output before the geometry refresh callback runs. Freeze now checks the live count before accepting a worker result or interpreting its raster error, matching Zoom. The regression failed before this repair and now retains the original acquisition for one retry.

* Pace capture barrier rendering while buffers are unavailable

Do not request an immediate dispatch wake while every swapchain slot is owned by the compositor. A buffer release already wakes Wayland dispatch. When a slot is available, honor the no-vsync frame cap before the hidden frame attempt. The pacing regression failed on the unconditional zero timeout and now passes.

* Separate portal desktop changes from active output source validity

Use a full desktop generation for portal preflight, worker results and pending portal images. Direct capture and installed modal sources retain an active viewport generation, so another monitor cannot invalidate their pixels. Preflight retains its backend to select the correct guard; Zoom now names that backend explicitly. Regression coverage checks direct Freeze activation and installed Zoom source validity while portal admission goes stale.

* Wait for portal layout updates to settle before retry admission

Keep the original request suppressed while waiting for complete geometry and 100 ms without a desktop change, bounded to one second. Pace incomplete metadata checks at 50 ms instead of polling with a zero timeout. Share this admission and deadline policy across Freeze and Zoom. Extract runtime retry coordination from barrier.rs so the presentation barrier stays below its size review threshold; controller tests cover lost suppression, output switches, settling failure, activation-time retry and exactly one completion.

* Remove obsolete Freeze cropping and split image activation ownership

Portal workers already return a completed output crop. Remove the raw desktop setter, cropped flag and test-only activation crop branch; retain the installation guards and move raster acceptance coverage to portal_raster. Add marked-pixel checks for rotated output dimensions and unknown multi-output bounds. Split activation and direct attempt resources from state.rs; frozen state and capture barrier now remain below 500 production lines. Restore logical blank-line groups in the touched raster and retry functions.

* Document KDE capture support and revised module ownership

Give Plasma/KWin an explicit support-matrix entry explaining that Freeze and Zoom require its screenshot portal. Update scoped guides for portal raster validation, separate generations, retry settling, image activation, and direct capture resources.

* Preserve retry terminal categories and production import scopes

Return typed retry admission errors so an output switch remains StaleLayout for Zoom rather than becoming a generic failure. The coordinator regression failed on that category change and now passes. Limit the removed Zoom wrapper imports to tests so strict production builds remain warning-free.

* Type preflight failures and restore desktop-wide PDF layout checks

- Replace string-matched capture terminals with CapturePreflightError.
  Stale layouts and layouts that never settle end as StaleLayout in both
  Freeze and Zoom, so region capture does not fall back silently.
- Share active-output/desktop generation bookkeeping. Board PDF desktop
  captures use the full-desktop generation again.
- Abandon unowned Freeze preflight failures and clear pending retries.
- Move Zoom request, waiter and terminal ownership to zoom/state/source.rs
  and select the Zoom backend with ZoomCaptureBackend.
- Compute the frame-cap delay once for render and capture wakes.
- Cover buffer availability, packed mixed-density crops, desktop PDF
  contexts and typed terminal categories.
- Update capture invalidation docs and module guides.
2026-09-30 00:57:26 +02:00
devmobasa a2bb723ab4 Fix rendering, capture, input, and daemon bugs (#399)
* fix: remap each damaged pixel once under a render profile

The render-profile post-pass walked every damage rectangle on its own
and rewrote each pixel it covered. The damage merger keeps overlapping
rectangles apart on purpose (diagonal strokes, stacked UI footprints),
so pixels in an overlap were remapped twice. A profile that swaps
colours, such as the documented black and white print profile, then
turned those pixels back to their original colour, and a chained
profile moved them one step further. Diagonal strokes and moving chrome
left un-remapped patches exactly where consecutive damage overlapped.

Both remap entry points now split the clamped regions into disjoint
row bands and merge each band's column spans, so every damaged pixel is
visited exactly once. One shared span iterator serves the full remap and
the changed-from-baseline remap.

* fix: keep redraw requests raised while a frame renders

After a committed frame the event loop overwrote the redraw flag with
"still animating or history pending". Any request the render pass made
itself was lost: the Spotlight magnification warnings are pushed as
toasts while the canvas is painted, the input HUD asks for one more
frame to clear its last chip, and the properties panel asks for one to
settle its hover after a refresh. The animation clock was scheduled
before those requests existed, so a warning toast could sit frozen in
its first frame until an unrelated input event arrived.

The loop now consumes the request that starts a frame before rendering
and ORs whatever the pass raised back in afterwards. The decision lives
in a small pure helper with tests, since the event loop itself needs a
compositor to run.

* fix: repaint every buffer slot in full after a failed frame

Preparing a frame drains the acquired slot's damage before painting,
and painting clears the damaged area first. When paint or submit then
failed (a background surface that could not be created, a buffer that
could not be attached), the error only cleared the redraw flag. Nothing
recorded that the slot had lost its damage, so the next time it was
acquired it repainted only newer damage, and areas the failed attempt
had cleared or half-drawn could stay transparent or stale on screen.

Any error after a buffer is acquired now marks full damage on every
slot, with a new render_failed reason in the damage diagnostics.

* fix: stop panicking on screen images a token cannot place

Mapping a captured-image rectangle onto the screen ended in an expect
that the result was non-empty. A source token with an empty surface
side, a zero or non-finite Zoom scale, or coordinates past the i32
range collapses both corners onto one point, so the region picker,
the window snap targets and the OCR sweep could panic the event loop
while syncing a selection or painting a frame. The native-extent
mapping next to it already refused those cases.

The image-rectangle mapping now reuses that guarded mapping and
returns an Option. Callers treat None as having no screen geometry:
the review outline and the OCR sweep are skipped, a window snap
target is dropped, and a review move does not start.

* fix: derive the UI effect count and guard its bitset width

UiEffect::COUNT was a hand-written 16 next to sixteen variants, and the
per-frame effect flags lived in a u16, so the set was exactly full. A
seventeenth effect would either index past the damage history array
or, with COUNT bumped, shift past the u16: a panic in debug builds and
a bit that silently aliases the toast effect in release builds.

COUNT now derives from the last variant, the flags use a u32, and a
compile-time assertion keeps every effect inside the bitset. Tests
check that each effect owns one index and one independent flag bit.

* fix(capture): stop scaling Hyprland active-window geometry

The Hyprland active-window screenshot read the window's `at` and `size`
from `hyprctl activewindow -j`, looked up the monitor scale with a second
`hyprctl monitors -j` call, and multiplied the geometry by that scale
before passing it to `grim -g`. Hyprland already reports those values in
layout coordinates, which is the space `grim -g` reads, and the
selection path and the window-snap query treat them that way. On a
scale-2 monitor a window at 100,100 800x600 was captured as 200,200
1600x1200: the wrong region, which could reach onto another monitor.

The geometry now passes through unscaled, the monitor lookup is gone,
and the geometry string is built by a small pure function with a test
for a window on a scaled monitor.

* fix(capture): never overwrite an existing screenshot file

The screenshot file name was chosen by probing `exists()` on the
template name and up to 100 numbered alternatives, and the file was then
written with a replacing rename. Another writer could take the chosen
name between the probe and the write, and once all 101 names were taken
the save logged "overwriting" and replaced the original file without
asking.

Each candidate name is now written with a create-only rename, so a name
that is taken at the last moment moves on to the next suffix instead of
being replaced. When every name is taken the save fails with a clear
error and the existing files stay as they were.

* fix(capture): explain capture failures from a typed kind, not text

A failed capture reached the overlay as a flat message string, and the
backend guessed its meaning by searching for words such as "grim",
"cancelled" or "permission". The guess ran in a fixed order, so on GNOME
or KDE without grim a portal permission denial read as "Hyprland capture
failed: ...failed to run grim... Portal fallback failed: Screenshot
permission denied" and the user was told to install grim. Every broker
failure to run grim, not only a missing binary, also read as a missing
grim, and a file save refused with EACCES was reported as a screen
sharing permission problem.

A failure now carries a kind, set where the error is created. A helper
that the broker cannot spawn with ENOENT is a missing tool; the portal
call is mapped by its D-Bus error name (portal Cancelled and NotAllowed,
DBus AccessDenied) instead of its message; a portal error response and a
file save have their own kinds. The compositor fast path and the portal
fallback keep both errors, and the portal's reason wins unless it has
nothing specific to say. The overlay picks its message from the kind.

A user cancelling the portal dialog after the fast path failed is now a
cancellation like any other, instead of an error toast.

* fix(capture): fail a file-only save that has no save directory

A file-only screenshot or canvas export with an empty
`capture.save_directory` skipped the save and still reported success.
The overlay then saw an image that was neither saved nor copied and
showed "Could not copy to clipboard" with a "Save to file" action,
although no clipboard copy was ever requested and nothing was saved.
That "Save to file" action, like any fallback save with an empty
directory, would also have written the file into the working directory
of the process.

A file-only delivery now fails with "no save directory configured", as
the board PDF export already did, and the overlay says the screenshot
was not saved. The file save refuses an empty directory itself. The
clipboard failure path is taken only when the requested destination
included the clipboard.

* fix(capture): stop waiting forever on a silent screenshot portal

A portal screenshot waited for the portal's Response signal with no time
limit, and the overlay stays hidden until the capture finishes. If the
portal backend accepted the request and then never answered, for
example after a crash or a hung dialog host, the overlay stayed
invisible and every later capture was refused as busy until the process
was killed.

A non-interactive portal request now waits at most 10 seconds, the same
limit the freeze and zoom portal fallbacks use, and an interactive one
at most two minutes, like an interactive slurp selection. When the
limit expires the portal request is closed, so its dialog does not
linger, and the capture fails, so the overlay comes back and reports
the portal problem.

* fix: skip the startup portal probe when direct capture exists

Every overlay start blocked for up to 500 ms on a fresh session-bus
connection to ask whether the screenshot portal exists, even on
compositors with wlr-screencopy or ext-image-copy, where the portal is
only a fallback for freeze. The answer was then fixed for the whole
session.

With a direct capture backend the probe is now skipped and the portal
is simply tried if a direct freeze capture fails, which is what zoom
already does. Startup still probes when the portal would be the only
freeze backend, and builds without the portal feature never use it.

* fix: check the image pixel budget inside the decoder

Only clipboard paste compared an image's dimensions with the embedded
image pixel limit. Images stored in a session file were decoded at
render time with no budget, and so was the desktop backdrop. A small,
highly compressible PNG can declare 20000x20000 pixels, and the decoder
then allocated a full frame buffer plus a second RGBA copy, several
gigabytes in all. Opening such a session file could crash the overlay
with an out-of-memory abort.

decode_rgba now takes a pixel budget and compares it with the PNG IHDR
or the JPEG frame header before it allocates any frame buffer. Board
images and clipboard paste use the existing embedded image limit, and
the whole-layout desktop screenshot uses a wider 16384x16384 budget.
The renderer also skips an embedded image whose recorded width and
height disagree with its header, and draws the missing-image
placeholder.

* fix(session): bound every session read and parse it only once

The shared session loader read files with an unbounded read_to_end
unless the caller passed a limit, and only gzip payloads were checked
against the expanded-size cap. Session inspection (--session-info)
passed no limit and had no size check before the read, so it read a
file of any size into memory. The runtime paths checked the size
from metadata first, but the read itself had no limit.

The loader also held up to three full serde_json::Value copies of the
document at once: the parsed original, a working clone, and a second
clone passed to from_value. Base64 image data was duplicated in each
copy, so a large but valid session used several times its size in
memory.

Every read now stops at a limit. When a caller does not pass a limit,
the loader uses the larger of the expanded-size cap and the configured
file size, because uncompressed saves may use the whole configured
size. A file over that limit is reported as too large and left in
place, not treated as corrupt. The parsed document now moves into
deserialization. The retry without history parses the retained bytes
again, and the compressed copy kept for newer-version preservation is
dropped once the version check passes.

* fix: create the session catalog and daily logs private

Session files are written 0600 so that other local users cannot read
them. The session catalog and the daily log files used the umask
default, which is usually 0644. The catalog holds session names,
absolute paths and open and save times, and the logs name session
files and paths. Other users on the machine could read which sessions
exist, where they are and when they were used.

The catalog temp file and new log files are now created 0600. The
catalog and log directories, and any missing parents, are created 0700,
as the XDG base directory spec asks. Existing directories keep their
mode, and an existing log file keeps its mode until the next daily
file.

* fix(ocr): keep the recognition input out of shared /tmp

Copy text from screen wrote the selected screen region as a PNG to the
system temp directory, usually a shared and sometimes disk-backed
/tmp, and then fsynced it, which gained nothing because Tesseract reads
it straight back. The file was removed only by the temp file's Drop.
When the overlay watchdog ended the process with exit_group during a
recognition, no destructor ran, and the screen crop stayed on disk
after the process was gone.

The PNG now goes to $XDG_RUNTIME_DIR/wayscriber, which is created 0700
when missing and is inside the per-user runtime tmpfs. The shared temp
directory is only a fallback for sessions without a runtime directory.
The fsync is gone. Before each recognition, inputs that an earlier
overlay left in the runtime directory are removed. The controller runs
one request at a time and the overlay lock admits one overlay, so no
live request can own one of them.

* fix(update): stop the wget fallback from following redirects

The update check fetches the release manifest with curl, or with wget
when curl is missing. curl limits redirects to HTTPS. The wget command
relied on --https-only, but GNU wget applies that option only to
recursive retrieval, so a single download followed a redirect to plain
HTTP. The comment still said HTTPS was enforced across redirects. If
the endpoint or a CDN ever redirected to http://, wget users fetched
the manifest in cleartext, and an attacker on the network could forge
an update notice.

The manifest is a static file served directly, so wget now runs with
--max-redirect=0 and fails on any redirect. The comment now says what
each client enforces.

* fix(config): keep board persistence on when [session] omits it

The three persist_transparent/whiteboard/blackboard fields used a bare
serde default, which is false, while SessionConfig::default() and the
docs say true. A [session] table that set any other key, such as
storage = "config" or per_output = false, therefore turned off drawing
persistence for every board, and sessions stopped resuming without any
warning. The configurator then showed the switches as off.

The fields now share one default function with the Default impl. A new
guard parses every config table as an empty table and checks that it
resolves to that section's Default after validation, so the next field
whose serde default drifts from its Default fails a test. The empty
[tablet.stylus_button] table is exempt because the docs define it as
"leave the button unbound".

* fix(config): name the fields a save would correct

When validation would change a value on its way to disk, the save was
refused with one fixed sentence about values "outside their allowed
ranges". The configurator showed it against a pseudo-field called
"config", so a user who typed an upper-case board id, or any other
value only core normalizes, got no hint which field to fix.

validate_for_save now diffs the authored and validated trees down to
the smallest changed path and returns each one with its authored and
corrected value. Lists are compared entry by entry, so a board id is
named as boards.items[1].id, and a list whose length changes is
reported as a whole. The configurator turns every path into its own
form error, in the same "path: message" shape its other validators
use.

* fix(configurator): check board ids and counts before saving

Core lowercases and deduplicates board ids, re-inserts an Overlay board
when no transparent board is left, and drops boards past max_count. The
configurator mirrored none of that. Typing the id "Math", removing the
Overlay board, or adding a board past the maximum turned every Save
into a correction that core refuses to write, with no hint which board
to fix.

The boards draft now refuses those shapes itself and names the field:
an upper-case id with the lowercase id to use instead, a duplicate id
with the board that already has it, a list without a transparent
board, a list longer than Max boards, and a maximum of 0. The config
guide and the configurator README now state the rules.

* fix(config): salvage unreadable values in the editor like the overlay

The overlay loaded a file whose TOML was valid but held one value serde
could not map, such as undo = "Ctrl+Z" instead of a list, with only
that section on defaults. The configurator loaded the same file as an
all-defaults repair draft, said the configuration "could not be
parsed", and saving that draft stripped every authored setting from
the file. The recommended recovery path was the destructive one.

Both loaders now deserialize through one salvage pass in
config/salvage.rs, so they agree on the values and on the entries that
fall back. The editor document keeps the salvaged config and records
the entries that failed. Its save removes only their known content and
then merges the draft as usual, so comments, unknown keys and every
other authored value stay, and no migration revision is stamped. A
strict document load still refuses the file, a save's own output must
still map completely, and a file that is not TOML still gets the
defaults-based repair draft.

The configurator's load status now names the settings that fell back
instead of calling valid TOML unparseable. The config guide, the
configurator README and the codebase overview describe the new repair.

* fix(config): drop only the failing value when salvaging a config

Salvage probed only top-level entries, so one wrong enum string such
as theme = "drak" reset every setting in [ui], and one mistyped
shortcut list reset all of [keybindings]. The overlay ran that way, and
the configurator's repair save then removed the whole section from the
file.

A failing table is now split: each entry is probed on its own, and only
the entries that fail fall back, recursively. Lists, including arrays
of tables, fall back whole because their entries are positional. So do
tables whose empty form does not map, such as a preset slot with
required keys, and drag_tools, whose hand-written Deserialize ties its
keys together. The failure is still reported as a ConfigSectionError,
now with the dotted path of the entry. A dropped shortcut list counts
as omitted rather than authored.

Section checks that must fail closed, such as the destructive session
commands, now match any failed entry inside the section through
ConfigSectionError::is_within, in LoadedConfig and in the overlay's
load report. The config guide's "Invalid Values" list now describes
per-value fallback instead of claiming a parse error ignores the whole
file.

* fix: drop the old board's selection when switching boards

Shape ids restart at 1 on every page, but a board switch kept the
selection, the open context menu, and the per-id hit-test bounds of the
board it left. After selecting a shape on the overlay and switching to
the whiteboard, Delete, a nudge, or the properties panel acted on
whichever whiteboard shape had the same id, and a click on that shape
could miss because it was tested against the overlay shape's bounds.

A board switch, a board-slot switch, and a duplicate now run the same
cleanup as a page change: the selection is cleared, the context menu
closes, and the hit cache is invalidated. The hit cache also remembers
which frame its bounds came from and drops them when asked about
another frame, so it no longer relies on every caller to invalidate it.

* fix: land a held drag before a key action records history

A move, resize, text-resize, or magnification drag records nothing
until its release, then commits one entry measured from snapshots taken
when it began. Only a bend drag was settled before a key action ran, so
a nudge, Delete, or Undo pressed while dragging a selection recorded its
own entry first and the release then committed a second one from the
stale snapshots. Undoing twice after dragging and nudging a rectangle
did not bring it back to where it started.

Every action except Exit now settles any held snapshot drag first,
through the same commit the release uses, and the release that follows
finds nothing left to finish. A press settled this way is not counted
as a click, so it cannot complete a double-click. Escape still cancels
the drag.

* fix: write panel preset saves without waiting for a keypress

A preset slot saved from the properties panel is a pointer release,
which has no immediate drain, and the event loop's runtime pass did not
take preset edits either. The save was not written to the config file,
and its toast did not appear, until the next keypress, toolbar event,
or exit. Pending preset and quick-color edits were also coalesced by
kind, so saving slot 1 and then slot 2 before that point dropped slot 1
from the file while the running overlay still showed it saved.

The runtime pass now writes preset edits through the same helper as the
other drains, and a later edit replaces only the pending write for the
same preset slot or quick-color index.

* fix: let every function key reach its shortcut while typing

While a text box was open, only a fixed list of keys could reach the
keymap without Ctrl or Alt. The list predates the F6 Light mode
default, so F6 did nothing while typing, and F3, F5, F7, and F8 were
swallowed even when the user had bound them.

Now only keys that type text need Ctrl or Alt to reach a shortcut.
Every other key checks the keymap first, so each default function-key
binding and any user-bound function key works in text mode.

* fix: make the context menu's Clear All match the toolbar clear

Clear All in the canvas context menu removed the shapes directly
instead of running the clear action. It left laser ink on screen, gave
no "locked shapes remain" warning when locked shapes stayed behind,
and did not offer the Undo toast that the toolbar's clear button shows.

The menu entry now closes the menu and clears through the same path as
the toolbar button, so both mouse paths behave the same way.

* fix: clear the active preset marker for every preset style change

Presets store the marker opacity, fill, eraser mode, font size, and
polygon side count, but only the color, size, eraser size, and tool
setters cleared the active-preset marker. Changing one of the others
left the preset highlighted on the toolbar although the style no
longer matched it.

Those setters now end through one helper that clears the marker along
with the damage, redraw, and session updates they already made, and
the font-size step goes through the font-size setter, so it no longer
repaints when the size is already at its limit.

* fix: start keyboard text entry at the centre of the visible canvas

The Enter Text Mode and Enter Sticky Note Mode actions placed the new
draft at the screen centre's pixel coordinates, but a draft lives in
canvas coordinates. On a panned or zoomed board the draft started away
from the middle of the view, often off screen, and the edge clamp then
pushed it to the nearest visible edge once typing began.

Both actions now start the draft through one helper that maps the
screen centre to the canvas.

* fix: keep a preempted toast ahead of its peers after a re-prioritize

A toast pushed aside by a more urgent one is re-queued at the front of
its class so it resumes first, but it gets a new, highest seq. When a
queued toast changed class through a same-key push, the queue re-sorted
every entry by seq, which moved the preempted toast behind the peers it
had been showing ahead of.

Only the entry that changed class moves now: it is taken out and put
back at the end of its new class, and every other entry keeps its
place.

* fix: parse board picker colors with the shared hex parser

The board picker kept its own copy of the hex color parser, and unlike
the shared one it sliced the string by bytes without first checking
that every byte was an ASCII hex digit. Six bytes of non-ASCII text,
such as "€abc", made it panic on a character boundary. Only the key
filter in front of the color fields kept that input out.

The board picker now parses through the shared parser, which checks
the digits first, and still refuses translucent colors because board
colors are opaque.

* fix: keep font families that end in a style word intact

Font descriptions were joined into a string such as "Roboto Condensed 17"
and handed to Pango's string parser, which reads style, weight, and
stretch words off the end of a family that is not closed with a comma.
"Roboto Condensed" became Roboto with condensed stretch and "Archivo
Black" became Archivo at black weight, so a family installed only under
its full name fell back to the default face on the canvas, in exports,
and in the font picker's preview rows. A numeric weight such as 700
joined the family name ("Sans 700") instead of applying, and every size
was rounded to a whole point.

FontDescriptor now builds its pango::FontDescription field by field:
family set as a name, weight and style mapped to Pango's enums, and the
size scaled by pango::SCALE with its fraction kept. The text renderer and
sticky-note layout use that description directly, and the picker builds
its family-only descriptions the same way. The string form, still the
measurement cache key and the input of the caret helpers, is now Pango's
own serialization of that description, which closes such a family with a
comma and so parses back to the same font.

* fix: make the toolbar popover and action tables exhaustive

popovers_for_event is the one place a toolbar control declares which
popover it lives in, so that using it does not close that popover. Its
match ended in a wildcard that gave 47 events no popover, so a control
added to a popover without an entry closed it the first time it was
used, which is the mistake the table was written to prevent.
action_for_event ended the same way, so a new event with a keyboard
equivalent silently lost its shortcut badge, tooltip label, rebind
capture, and shortcut coaching.

Both matches now list every event. The popover table groups the events
hosted in no popover (the style pill, the tool strip, and the strip's
chrome) and records why the board picker, although it sits in the
Canvas popover's Boards row, still closes Canvas: it opens a modal over
the overlay, as the command palette does from Settings. The action
table lists the events with no keyboard equivalent, including the
zero-step nudges and the next-arrow style cycle, which is not the
selection-aware keyboard action.

The only behavior change is that SetPolygonSides now keeps the shapes
popover open, like the sides stepper and Fill it sits beside. No
control emits it today, so nothing visible changes. Every other event
keeps its popover and action.

* fix: follow the seat's key-repeat rate and delay

Held keys repeated after a fixed 400 ms and then every 40 ms, whatever
the compositor asked for. The keyboard handler never implemented
update_repeat_info, so a user who set a faster rate, a longer delay, or
no repeat at all got different behaviour inside the overlay than in
every other app, and a seat with repeat disabled still repeated.

The handler now converts the seat's repeat_info into a delay and an
interval and hands it to the manual repeat state, which arms and
reschedules from it. A rate of 0 turns repeat off and stops a key that
is repeating. The old constants remain the timing until the seat
reports its own, and stay the input HUD monitor's cadence. The interval
is held to at least 1 ms so that an absurd reported rate cannot turn
the event loop into a busy loop.

The protocol callback cannot run without a compositor, so the
conversion is a small pure function with its own tests, and the
repeat state's use of the timing is tested directly.

* fix: keep typed characters out of the key logs

Every key press and release was logged at debug level as "Key pressed:
Char('p')". With debug logging on, which is what a user turns on to
report a bug, the persistent daily log held everything typed into an
annotation, one character per line.

Both lines now log at trace level, and a character key is written as
Char(..) at any level. Named keys such as Escape and Backspace stay
readable, so a log still shows which kind of key arrived.

* fix: release seat devices and resize once per layer configure

The seat handler dropped the wl_keyboard that get_keyboard returned, so
it was never released, and it dropped wl_touch without release when the
seat withdrew touch. Dropping a proxy does not destroy the object: the
compositor kept both alive for the life of the connection, and a
keyboard or touch capability that came back stacked a new object beside
the old one.

Keyboards and touch devices are now kept per seat and released (from
version 3, where the request exists) when that seat withdraws the
capability or goes away. They are kept per seat because releasing
another seat's device would silence that seat, and a seat that vanishes
without first withdrawing its capabilities is covered too. The protocol
callbacks cannot be driven without a compositor, so the per-seat
bookkeeping and the release-version rule are small pure pieces with
their own tests.

Each main layer-surface configure also ran the frozen/zoom resize and
the screen-modal check twice. The first check ran before the capture
geometry was refreshed, so it compared an active region selector or
eyedropper against the old output identity and layout generation; only
the repeated check afterwards saw the new ones. The sequence now runs
once per configure in the order the xdg path uses: resize, refresh the
geometry, then check the screen modals against it.

* fix: keep the GTK toggle handlers free of a panic path

The GTK top strip turned a toggle click into an event with
event_for_toggle_state, which took the whole TopToolbarControl enum and
ended in unreachable! for anything that was not a toggle. It ran inside
the clicked and closed signal handlers. A panic there cannot unwind
through GTK's C trampoline, so a mismatch would have aborted the whole
overlay instead of falling back to the built-in toolbar, as a panic in
the update task does.

Each factory now names its toggle when it builds the widget, as a
TopToggle value (shapes picker, highlight, pin, overflow, layout menu,
highlight ring), and the handlers call its event method, which has no
case to reject. The emitted events are unchanged, and the adapter test
now covers the layout menu toggle as well.

* fix: raise hint and tertiary text to WCAG AA contrast

Two text tokens sat below the WCAG AA minimum of 4.5:1 for body text
on the surfaces they are drawn on. The light theme's text_tertiary, at
alpha 0.4, measured 2.83:1 on its panel. The About window uses it for
the version line, card details, meta lines, and the key-hint footer.
The popups' TEXT_HINT_DIM, at alpha 0.7, measured 3.36:1 on the modal
background behind the color picker's and precise entry's key hints.
Hint text is where users learn the shortcuts.

The light text_tertiary is now alpha 0.58. That is the lowest value
that stays above 4.5:1 on every light surface, including a hovered
About card (4.83:1). TEXT_HINT_DIM is now opaque (5.32:1) and still
dimmer than TEXT_HINT.

Nothing measured contrast before, and relative_luminance weights
gamma-encoded channels, so it cannot give a WCAG ratio. A new test
computes the real ratio: it composites each translucent text token
over its surface as Cairo paints it and linearizes sRGB. It covers the
theme text tokens on every theme surface in both variants, and the
popup hint tokens on the modal background.

* fix(configurator): preview font families that end in a style word

The configurator's font catalog previewed each row with
FontDescription::from_string(family). Pango reads trailing style,
weight, and stretch words off that string, so "Roboto Condensed"
previewed in Roboto with a condensed stretch, "Archivo Black" in
Archivo at black weight, and a family installed only under its full
name fell back to the default face. The overlay stopped building fonts
this way; the configurator's preview rows still did.

Rows now build their description with set_family, so the preview uses
exactly the family the row names.

* fix: tolerate leftover atomic-write temporaries in the v2 ledgers

An atomic write names its temporary `.{file}.{pid}.{stamp}.{seq}.tmp`,
but the v2 command root only accepted names starting with
`.admission.json.tmp-`, and child-proof recovery split every name on its
last dot and demanded a protocol id. A client interrupted while writing
`admission.json`, or an overlay killed during a proof fsync, therefore
left a file that made every later `--daemon-toggle` fail before it
published, and made each daemon start fail recovery. Under
Restart=on-failure the daemon restart-looped until the file was deleted
by hand. The legacy rollback path failed the same way on an action
record temporary.

durable_io now owns the temporary-name shape through
`temp_file_target` and `is_temp_file_for`. The command root tolerates
admission temporaries and collects them while the admission lock is
held. Child recovery removes temporaries of proof records, and the
rollback quiesce removes action-record temporaries under the journal
lock. Temporaries for anything else still fail closed.

* fix: refuse overlay requests while the spawn backoff holds starts

After a failed overlay spawn the daemon backs off for 1 to 30 seconds,
and during that window show_overlay returned Ok without starting
anything. A typed `--daemon-toggle` was committed and reported success
with no effect. A typed overlay action was committed and never
delivered. A tray action stayed eligible in the v2 journal and ran when
the overlay next opened, so a "Capture full screen" clicked during the
backoff could fire minutes later. The launch request also outlived the
attempt, so its mode and session file could shape a later, unrelated
start.

show_overlay now returns a ShowOutcome that says whether the start was
held back. Typed v2 requests check the backoff before they commit and
are rejected with no effect, so the client sees why. A tray action
whose start is held back is abandoned instead of left for replay, and
a legacy action fails before it is queued. Every start attempt now
drops the pending request, whether it succeeded, backed off or failed.

* fix: log panics through the logger before the default hook runs

No panic hook was installed, so a panic reached only the default hook,
which writes to stderr. The process broker starts the daemon's overlay
with stderr on /dev/null, so an overlay crash under the daemon left no
message, location or backtrace in the journal or the daily log file.

logger::init now installs a hook that logs the thread name, location,
payload and a captured backtrace as one error record, then chains to
the hook it replaced, so unwinding and the stderr report are unchanged.

The logger also stops a record logged from inside another record's
write on the same thread. That happens when a panic is raised while a
record is being formatted: the hook would wait forever on the lock
that thread already holds. The nested record is now dropped instead.

* fix: exit the daemon once its process broker is unusable

A failed broker exchange retires the broker for good, but nothing
outside the broker read that state. After a broker crash, or a wedged
helper that ran the client into its socket timeout, a daemon with the
overlay hidden kept its tray alive and could never show the overlay
again. It only warned every 30 seconds, and systemd did not restart it
because it had not exited.

The daemon loop now checks the broker before it waits and exits with
an error once the broker is unusable, so Restart=on-failure starts a
fresh daemon. Dropping an unusable broker's guard asks it to stop but
kills it after three seconds, so a stuck broker cannot hang that exit.

The wedge itself came from run_bounded. After killing the helper's
process group it joined the pipe threads with no deadline, and a
descendant that had left the group could hold the pipes open for as
long as it lived. The pipe threads now poll and give up two seconds
after the helper is reaped, and the request fails with a clear error
while the broker stays usable.

* fix: stop the v2 overlay replaying the legacy tray-action file queue

Every overlay tray scan drained the protocol v1 action queue before the
v2 journal. Under v2 nothing writes that queue: tray actions go through
the journal, which binds each action to a daemon generation and applies
it once. A `*.action` file left by a pre-upgrade daemon or a crashed run
was still replayed on the next overlay start, for example a stale
"Capture full screen", which bypassed the journal's rules. The scan also
read the directory on every pass.

The overlay now takes file-queued actions only when the build runs
protocol v1, the source-level rollback selector. A v2 build leaves the
queue unread.

* fix: say the daemon is not running before reading its v2 record

The v2 client read the daemon runtime record before anything else and
returned early on a v2 record, so the check that probes the daemon lock
and prints "wayscriber daemon is not running" only ran for legacy
records. Pressing the shortcut with no daemon printed "failed to read
daemon runtime record ...: No such file or directory". A stale v2
record left by a killed daemon was never cleaned up by clients, and a
request against it only failed after the response window.

The client now probes the single-instance lock first. With the lock
free it clears the stale runtime files and reports that the daemon is
not running. With the lock held and no record yet, it reports that the
daemon is still starting. Tests that drive a v2 harness through the
client now hold the lock as a running daemon does.

* fix: keep internal process markers out of helper environments

The process broker runs with the environment of the process that
started it, and its helpers inherit that environment. A
daemon-launched overlay starts its own broker, so its internal markers,
the overlay generation, the detached flag and the resume-session
override, reached every helper it spawned. From there they reached
xdg-open's browser, the configurator, tesseract and curl, and a
wayscriber started anywhere in those trees took itself for a daemon
child.

The broker now removes those markers, and the watchdog descriptor
variable, from every helper except the two that relaunch wayscriber
itself. The overlay launch sets the markers it needs explicitly, and
the initial detach carries the caller's session choice across.

detach_from_tty also closed stdin, stdout and stderr when they were a
terminal. That freed descriptor 2, and the logger's stderr writes could
then land in whatever file the next open returned. It now points those
descriptors at /dev/null with dup2.

* fix: escape specifiers and control characters in the systemd unit

The unit renderer escaped only backslashes and double quotes. systemd
still expands `%` specifiers in ExecStart= and Environment=, and a line
break in the install path ended the setting and started a new
directive. An install path with `%` or a line break produced a broken
or misdirected unit, and setup still reported success. Background setup
also wrote the running executable's path as the kernel reports it,
which after an in-place upgrade ends in " (deleted)".

The renderer now doubles `%` in both settings and writes control
characters as C-style `\xNN` escapes that systemd decodes back. A `$`
stays as written: systemd does not substitute variables in the command
word, so doubling it would name an executable that does not exist.
Background setup refuses an executable that was replaced or is no
longer a file, and asks the user to restart wayscriber and run setup
again.

* fix: create the signal self-pipe close-on-exec and pin the daemon pid

The signal self-pipe was created with pipe() and marked close-on-exec
with fcntl afterwards, so a process spawned between the two calls could
inherit both ends. Every other descriptor in the app is created with
its close-on-exec flag set. The pipe now comes from pipe2 with
O_CLOEXEC. Only the write end becomes non-blocking, as before, because
the listener thread blocks in its read.

DaemonRuntimeRecordV2::validate compared the recorded start ticks and
then opened a pidfd that it dropped at once, so the pidfd proved
nothing: the pid could be reused between the two steps. It now opens
the pidfd first, reads the start ticks, and then checks that the pidfd
still names a live process. While that process lives its pid cannot be
reused, so the ticks belong to it.

Also add the missing SAFETY comments on the flock call in session
locking and the localtime_r/gmtime_r wrapper.

* fix: stop the configurator holding the daemon's single-instance lock

Clearing, duplicating or moving an inactive session in the configurator
held the daemon lock for the whole operation. A daemon that started
meanwhile, for example a service restart, found the lock taken and
exited as already running with status 75. The unit's
RestartPreventExitStatus=75 then left the service down until someone
started it by hand.

The configurator now only probes the daemon lock and releases it at
once. It still refuses the operation while a daemon runs, and it keeps
the overlay lock for the whole operation. Overlays are what write
sessions, so no overlay can start mid-operation, including one a new
daemon launches.

A probe still takes the lock for an instant, and so does the client
check that reports "daemon is not running". A starting daemon now
retries its lock for 250 ms before it decides another daemon holds it,
so a probe cannot make it exit.

* fix: do not apply a v2 command that was rejected at its deadline

ClaimedCommand::commit checked the authorization deadline and, when it
had passed, rejected the command but still returned Ok with an empty
effect id. The daemon took that as a commit: a typed toggle still showed
or hid the overlay, and an action request still started it, while the
client had already been told nothing happened. The toggle path then
tried to finalize a command that was never committed, and the error
from that ended the daemon's command loop.

commit now returns None when it rejects at the deadline. The daemon
leaves the overlay alone in that case, and the action path abandons its
prepared journal entry so it cannot be delivered later.

* fix: scope key repeat timing to each Wayland keyboard

* style: simplify punctuation in UI messages and tooltips
2026-09-29 19:24:47 +02:00
devmobasa 112914294a Improve annotation controls and selection editing (#397)
Make everyday annotation controls easier to use and make shape editing easier to discover.

- Fix fractional width stepping and GTK slider shortcut/focus handling. Add curved thickness sliders, reference ticks, and wheel adjustment.
- Give zoom actions explicit centres, add Ctrl+wheel zoom, and stabilize the Reset/Lock chip layout.
- Make Replay Tour reuse the first-run cards while preserving usage history, including when automatic hints are disabled.
- Add status-bar tooltips, show tool-relevant sizes, reduce the default font size, clarify board capacity, and add Board Picker access to the canvas context menu's Boards submenu.
- Open Properties by double-clicking shapes, retain text/note editing, and simplify shape context menus with an Arrange submenu and Delete near the bottom.
2026-09-27 17:08:59 +02:00
devmobasa a43a8ac48a feat: expand selection editing in the properties pane (#396)
* fix: hide the tool preview while a click would only dismiss a popup

While the properties panel or the context menu is open, a press on the
canvas closes it and draws nothing, so the pen's cursor preview promised a
stroke that never came. The preview now waits until the popup is gone.

* fix: make Move to Front reach the top and add one-step ordering

Frame::move_shape read its target as "insert before the shape at to",
which can never reach the top of the stack: Move to Front left a shape one
below it, and did nothing at all to the shape just beneath the top. The
target is now the index the shape ends at, in the move and in its undo.

The shape context menu gains Move Forward and Move Backward, which step
the selection past its nearest unselected neighbour and move a selected
run as one block. The four ordering entries dim when the selection is
already as far that way as it goes.

* feat: order, duplicate, and delete from the properties panel

The panel gains an actions area under its rows: four ordering buttons
(to back, back one, forward one, to front) and Duplicate and Delete, run
through the same selection edits as the context menu. Buttons that would do
nothing dim: ordering at the end of the stack, Duplicate and Delete for a
locked selection. Duplicating keeps the panel open on the copies, and
deleting closes it with nothing left to show.

The area has a fixed height the layout fits around, so short screens still
fall back to columns or scrolling rows.

* feat: opacity for every shape and sliders in the properties panel

The panel gains an Opacity row for every colored shape, stepped in 5%
increments from 5% to 100%; a marker stays at or below 90%, as the marker
tool keeps it. Opacity has its own control now, so an opaque swatch or
color key changes the hue and keeps each shape's opacity, while a
translucent swatch still sets its own. The color picker's OK sets exactly
the color shown, opacity included.

Thickness and Opacity are sliders: pressing the track jumps to the
pointer, the shapes follow the drag live, and the whole drag lands as one
undo entry on release, or when the panel closes mid-drag. The keyboard and
the wheel still step them. The thickness stroke preview gives way to the
slider's filled track. Opacity stays off the toolbar's style pill.

* feat: give fills their own color, with a no-fill swatch

Rectangles, ellipses, and polygons gain an optional fill color. Without
one a fill paints with the border color, as every fill did before, and the
field is written only when set, so sessions read the same either way.
Scaling, grid snapping, and copies carry it along.

The properties panel's Fill switch becomes a swatch row: a leading "no
fill" swatch, then the quick-color palette. A swatch fills the selection
with that color and leaves the border alone; no fill turns the fill off and
keeps its color, so turning it back on restores it. A shape has one
opacity: an opaque fill swatch takes the shape's, and the Opacity row moves
border and fill together. The keyboard and the toolbar pill still toggle
the fill on and off.

* feat: apply and save tool presets from the properties panel

The panel's actions area gains a preset row: one chip per preset slot,
showing its color, and a Save button. A chip applies that preset's style to
the selection as one undo entry: its color (a marker keeps its own
translucency unless the preset sets one), its size as a stroke width, fill,
font size, text background, and arrow head. The tool in use stays as it
is; this restyles shapes rather than picking up the preset's tool.

Save arms saving: the next chip clicked stores the style of the first
editable selected shape a tool draws, with that tool, through the same
path as the Save Preset actions, so it is written to the config. Escape
disarms it before it closes the panel. Empty slots dim until saving is
armed, and Save dims for a selection no tool draws, such as text.

* fix: check selection ordering in one pass

Whether the selection can step forward or back scanned the stack from every
selected shape and looked each neighbour up in the selection list, so with a
whole board selected it grew with the cube of the shape count: seconds on a
board of thousands. The properties panel asks twice on every refresh,
slider drags included. It now holds exactly when the lowest selected shape
sits below the highest unselected one (or, backward, the highest selected
above the lowest unselected), which one pass with a hash set finds. The
step itself uses a hash set for membership too.

* fix: land a slider drag before any other edit records history

A slider drag records nothing until its release, then commits one entry
measured from snapshots taken when it began. A keyboard step pressed
mid-drag recorded its own entry first, so the drag's landed on top of it
with a stale "before", and two undos from 3 to 50 to 49 ended at 50
instead of 3. The drag now commits first whenever another edit would
record history: any selection edit, a panel key, a panel click, or an
undo or redo.

* fix: keep a preset's own fill color when saving and applying it

A preset saved from a rectangle, ellipse, or polygon filled with its own
color kept only whether it was filled and the border color, so applying it
filled the target with the border's color: a red rectangle with a green
fill came back filled red. Presets gain an optional fill_color, written
only when set. Saving from the panel captures it, applying restores it on
selected closed shapes (a preset without one resets a separate fill to
follow the border), and the configurator carries it through when it
rewrites a slot.

* fix: give the preset slots room of their own

The five preset chips and Save shared the button column after the label,
leaving each chip about 38 px for a color dot and a number, which touched
its edges. The presets now take two lines: a label line with a wider Save
at its end, then the slot chips across the panel's full width, with more
space between dot and number.

* feat: pick any fill color from the properties panel

The Fill row gets a + that opens the color picker for the fill alone.
OK fills the selection with exactly that color, opacity included, as
one undo entry; the border keeps its color.

* feat: point out Alt+click selection in onboarding

The first-run quick-access card mentions that Alt+click selects a shape
without switching tools. The guided tour's menus step, now "Menus &
Selection", adds Alt+click, Alt+drag to move or box-select, and the
properties shortcut from the live keymap.
2026-09-27 13:03:41 +02:00
devmobasa 6f7ca3d753 fix: hold the toolbar through first-run onboarding
The idle-fade pause keyed off the replayable tour, so the first-run
cards still let the strip fade out: "Toolbar and exit" appeared with no
toolbar on screen, and the strip vanished again right after "Tour
complete". Keep the strip up while the first-run tour is active and for
the rest of the first session, unless onboarding hints are off. The
idle_fade preference is left unchanged.
2026-09-27 12:04:56 +02:00
devmobasa 0323cc84bb fix: guard against rapid Escape exit after dismissing UI 2026-09-27 10:56:12 +02:00
devmobasa 34e4e4d913 fix: pause toolbar idle fade during the guided tour 2026-09-27 10:50:58 +02:00
devmobasa e849384d82 feat: polish the drawing UI and add a fading laser pointer (#394)
* feat: label the style pill steppers

Pen smoothing and Shape Pen sensitivity were bare "− 3 +" steppers; with
Shape Pen two identical ones sat side by side and the value text was dim.
The shared model now gives the tool steppers a short caption ("Smooth",
"Detect"), and both toolbars draw it before the − half in the caption
tone. The readout between the halves uses the primary foreground at the
numeral buttons' weight, centered in its slot. The builtin tree reserves
the caption slot, so the width planner budgets it for both frontends.
Docked selection steppers keep their unit-bearing readouts uncaptioned.

* feat: hide text controls for tools that don't draw text

With context_aware_ui on (the default), show_text_controls = true pinned
the text size, Bold, and font family onto every tool, so Pen, Shape Pen,
Marker, and Arrow carried three controls that changed nothing they draw.
Context-aware UI now offers them only where they reach the next shape:
text and sticky notes, arrows while auto-number labels are on, and step
markers, whose number uses the font's family and weight but takes its
size from the marker's own slider (so they get Bold and family, not the
text size). Both toolbars read the same spec. show_text_controls keeps
its meaning in classic mode (context_aware_ui = false); the example
config, CONFIG.md, the Settings tooltip, and the configurator row say so.

* fix: show one active highlight for grouped tools

The Shapes picker read as active whenever any shape-family tool was up,
so Shape Pen, Line, and Arrow lit both their own strip button and the
picker, although full layouts leave those three out of the picker. The
picker now stands in for the active tool only while that tool lives
inside it (Rect, Ellipse, Blur, Spotlight, polygons; plus Shape Pen,
Line, and Arrow in the simple layout), or while its popover is open.
Both toolbars read the shared control state, so both change together.

* fix: outline dark color swatches

The palette's black (#241F31) sits at about 1.2:1 against the dark
toolbar, so its swatch read as a gap in the color row, the radial ring,
and the help overlay's K badge. A shared helper in ui::theme::swatch
measures WCAG contrast (linearized sRGB) between a swatch, as it shows
over its chrome, and that chrome; below 3:1 (WCAG 1.4.11) the swatch's
edge becomes a 1.5px ring in the tone that contrasts with the chrome
(light on dark, dark on light). The GTK and builtin toolbar swatches,
the radial color ring, and the help overlay color badges use it; every
other swatch keeps its quiet hairline.

* feat: preview saved preset slots on the toolbar

Filled slots already drew their tool glyph and color, but replaced the
slot number, and empty slots looked like five ordinary buttons. A filled
slot now keeps its number as a small caption in the corner opposite its
color swatch, and its tooltip and accessible name summarize what it
applies ("Preset 1: Pen, Red, 4px"; the color in the quick-color
palette's words, else hex; a user-given name leads). An empty slot shows
its number dimmed until hovered, and its tooltip says it is empty and
names the configured save binding ("click or press Shift+1 to save the
current tool"). Both toolbars change together; slot sizes are unchanged,
so the strip width planner needs no new budget.

* fix: make toolbar and overlay panels opaque

Toolbar islands, popovers, and tooltips (built-in and GTK), the
context menu and submenus, board picker, properties panel, command
palette, modal cards, the capture chooser, the region-capture readout
and legend, toasts, and the help panel used 0.85-0.98 alpha fills, so
terminal text bled through labels and a white board turned the chrome
muddy. Their backgrounds are now opaque; theme colors, borders, and the
translucent full-screen dim scrims are unchanged.

The help panel's default ui.help_overlay_style.bg_color alpha moves
from 0.92 to 1.0 (example config and CONFIG.md updated); a user-set
alpha is still honored.

* feat: hide the idle toolbar and reveal it near the pointer

With ui.toolbar.idle_fade on, the top strip and its style pill used to
dim to 55% opacity, which read as a broken ghost bar over busy content.
They now fade out to fully transparent after ~4 s without drawing or
toolbar use, and fade back in when the pointer comes within 64 px of
the strip or touches the top screen edge, while the pointer or keyboard
focus is on it, and for 1.5 s after a tool or color change made away
from the strip (a modifier held for a shortcut does not count). A new
stroke keeps a visible strip up but never brings back a hidden one, and
the strip lingers for the idle delay after the pointer leaves. Open
menus still hold it; idle_fade = false keeps it always visible.

The hidden strip stays mapped at its size but takes no input: the
built-in surface gets an empty input region, the inline strip ignores
hits, and the GTK window disables input and shows its transparent
capture frame. Proximity is measured on the canvas surface.

Labels read "Hide toolbar when idle" in the settings popover and the
configurator; config docs, example config, and search terms follow.

* feat: add a laser pointer tool with fading ink

Presenters had no disappearing ink. The new Laser tool (L, the toolbar
next to Marker in Regular and Advanced, or Laser Pointer Tool in the
palette) draws a glowing stroke with the same live preview path as the
pen. On release the stroke goes to a LaserInk owner instead of the
frame, so it never reaches undo, the session, selection, exports, or
captures, which render without transients.

Ink stays for hold_ms after the latest release, then fades over
fade_ms. Strokes drawn before the fade ends keep the whole group on
screen, so a multi-stroke gesture disappears together. Only a fade in
motion ticks the animation clock; a hold schedules one wakeup, idle
ink costs nothing, and damage follows each stroke's own path. Reduced
motion removes the ink in one step instead of fading.

Color, width, and timing come from a new [laser] section with
clamping, also on the configurator's Drawing page. Clear Canvas wipes
laser ink, locked presenter mode allows the laser, and light mode keeps
it when it is the active tool.

* fix: repaint selection chrome when the selection changes

Click-select, rubber-band select, deselect, undo and paste changed the
selection without damaging where its halo and handles were or now
belong, so only the status bar repainted: new chrome appeared late and
old halos stayed on screen until an unrelated full repaint.

Route every membership change through one InputState helper that
measures the selection chrome (halos, handle frame, and a lone text
shape's resize handle) before and after the change and damages both.
Undo/redo and delete measure the chrome before they mutate the frame,
and releasing a held selection repaints the handles it hid. Measurer
aware _with variants replace the ad hoc damage at the old call sites.

* fix: keep placed sticky notes and text inside the output

A sticky note or text block placed near an edge, or one that grew past
the edge while typing, rendered partly off-screen and was committed
there, cutting the note off at the output boundary.

Every draft change already passes through the text preview damage
update, so fit the draft there: shift its anchor left or up until the
rendered block (glyphs, background, or note card and shadow) sits inside
the visible canvas rect, which follows pan and zoom. Commit fits once
more to cover style changes made since the last keystroke. An edited
block may keep reaching as far past the edge as it already did, so
opening a note that straddles the edge does not move it.

* fix: refresh the status bar when only modifiers change

A modifier change that arrived without a key press, such as a
compositor wl_keyboard.modifiers sync or a Ctrl release, updated the
modifier state and the active drag tool but requested no redraw, so the
status bar kept showing the Ctrl drag tool (Rectangle) until the next
unrelated event.

Route modifier presses, releases, resets, and compositor syncs through
one update helper that requests a redraw when any modifier changed.
sync_modifiers now reports whether it changed anything. The canvas is
not dirtied; the render repaints only the status bar and tool preview
through their existing UI effect damage.

* feat: show a brief recognition chip for Shape Pen

When Shape Pen turned a stroke into a shape, nothing said so or pointed
to the one-undo escape hatch, so the ink-back contract was easy to miss.

A recognized stroke now shows a small chip beside the new shape, such
as "Circle · Ctrl+Z keeps ink", using the configured undo shortcut (or
"Undo keeps ink" when undo is unbound). It holds for about a second,
fades out by 1.5 s, and stays opaque under reduced motion. It is overlay
chrome with its own UI effect slot, so each frame damages only its
footprint; it is never drawn into frames, exports, captures, or
sessions. Undo, redo, and board or page changes take it away.

[drawing] shape_recognition_feedback (default true) turns it off, also
on the configurator's Drawing page as "Name recognized Shape Pen
shapes".

* fix: hide the toolbars while modal overlays are open

Help, quick reference, the command palette, and the board, font, and
color pickers are drawn on the overlay surface, so the separately
stacked GTK and layer-shell toolbars covered them. A derived
suppression now unmaps both frontends while one of these modals is
open, alongside the existing capture-picker suppression. Toolbar
preferences are never written, so position, pin, minimize, and open
popovers come back when the modal closes. The inline built-in bars
now paint before popups so menus and panels draw above them.

* fix: stop onboarding card clicks from reaching the canvas

The first-run card is painted over the canvas but every press on it
fell through to the active tool: clicking the card drew a dot, enabled
Undo, and ticked off the "Draw a stroke" step. The renderer now
returns the card's rectangle, and pointer, touch, and stylus presses
inside it are owned by the card through the chrome-press path. Other
buttons are swallowed with their release, idle hover over the card no
longer reaches the canvas, and the cursor shows the default arrow.

* feat: reorder onboarding around first use

The first-run tour opened on a keyboard-only background-service prompt
before the user had drawn anything. It now runs value first: draw and
undo, the toolbar and how to leave (Esc), color and thickness, quick
access, and finding commands, with background mode offered last. The
rebind jargon left the find step; the replayable tour still covers it.

Card actions are real buttons (Got it, Set up, Not now, Skip tour) with
their keys shown beside them; Enter, Y/N, and Shift+Esc still work and
no longer fire mid-gesture or with Ctrl/Alt held. Buttons run when press
and release land on the same button and highlight on hover.

onboarding.toml moves to v7: a pre-v7 tour still waiting on the old
first step restarts at draw and undo, the retired wait-draw step maps
forward, and completed or skipped profiles are never re-enrolled.

* fix: stop preselecting Exit in the command palette

With nothing typed the palette listed the registry in order, so Exit
opened highlighted at the top and Ctrl+K, Enter closed the overlay,
with Clear Canvas right below. The empty query now lists recent
commands, then everyday ones (Undo, Redo, pen, marker, eraser, text,
select, region capture, whiteboard, help), then the rest by category,
and a trailing "Clear, delete & exit" group. Exit and destructive
commands go there even when recently used, so none is preselected.

The edit/unbind/reset icons now draw only on the selected or hovered
row, where the trash icon no longer reads as "delete" beside Clear
Canvas. Their space stays reserved so hovering never shifts text, and
Ctrl+E, Ctrl+Delete, and Ctrl+R still act on the selected row.

* feat: rework the canvas context menu

The canvas menu put Clear All second, right under Paste, offered Paste
with nothing to paste, had no Undo, Redo, capture, or way out, and drew
its key hints in a tiny strip below the box.

It now reads in groups: Undo and Redo (dimmed without history), Paste
and Capture Region..., view and boards, the other surfaces, Clear All,
and Exit last (Hide Overlay when the daemon owns the overlay). Paste is
enabled once shapes were copied or a capture went to the clipboard;
the system clipboard cannot be probed without blocking, so that is all
it knows. The highlight row uses the action's own short label,
Highlight, as the toolbar does. The shape menu gains the same dividers
and closing Exit row.

Dividers sit on row edges, so rows keep one pitch for hit-testing and
keyboard focus. The key-hint footer is inside the box at 12px, and the
box is sized for every hint it can show.

* feat: tighten the help overlay layout

Rows now read "action label ... key chips": labels form the left
column and keys start one gap past the widest label, joined by a
subtle dotted leader, so the eye can follow each row.

Actions without a binding are hidden by default. A new footer pill,
"Show Unbound" (or Tab, since printable keys type into search),
brings them back with a muted "Not bound" marker instead of a key
chip. Search still finds unbound actions. The choice lasts for the
process.

Body text now renders at the configured help font size instead of
80% of it, footer pills use the body size, and secondary text has a
12px floor (11px in key chips). Sizes are logical pixels, so they
follow the output scale. Page 1 still fits a 1080p output.

Also fix the search-mode nav line, whose segments advanced by ink
width and overlapped ("Esc: Close" / "Backspace: Remove").

* fix: raise the minimum overlay text size

Add shared readable-floor tokens to the overlay theme: 12px for
labels, hints, and status lines, 11px for keycap labels, and 13px for
control labels and hint strips. Overlay chrome is laid out in logical
pixels, so the floors follow the output scale.

Apply them where text was too small:
- capture action panel: labels 11 -> 13px, key chips 8.5 -> 11px,
  toggle 10.5 -> 13px, status 10.5 -> 12px; the rows and controls
  grow so nothing clips
- region-capture hint strip: 12 -> 13px, drawn through the same text
  layout that measures it, in a taller strip
- radial menu: shortcut and thickness keycaps 9 -> 11px, sub-ring
  labels 11 -> 12px
- help overlay: its floors now come from the shared tokens

The context-menu footer is left to the context-menu rework.

* feat: shorten the capture toast and add actions

The capture toast printed the full absolute path and a "(Ctrl+Alt+O
opens folder)" note across the whole screen. It now reads, for
example, "Saved screenshot_2026-09-25_213231.png · Copied to
clipboard". A path without a usable file name falls back to a
~-relative path.

A saved file adds two buttons, reusing the toast's action chips:
"Open folder · Ctrl+Alt+O" runs the existing Open Capture Folder
action (its label shows the live binding), and "Copy path" copies the
full path through the text clipboard pipeline and confirms with
"Path copied". The toast stays up for 8 s so the buttons are
reachable. Copy-only captures say "Copied to clipboard", and a saved
clipboard-fallback file uses the same toast. Error toasts keep their
full detail.

* fix: reduce status bar noise

With a single output, "Output: WAYLAND-1" told the user nothing, and
the permanent "About v0.9.26" chip took space in every frame.

The output item now shows only while two or more outputs are
connected. The backend reports the connected-output count along with
the label (excluding an output that is being destroyed). An explicit
active_output_badge = false still hides it, and the new
ui.active_output_badge_always keeps it with a single output. A plain
active_output_badge = true, which the example config has always
carried, is not treated as a request to show a single output's name.

The About/version chip is now off by default (show_status_about =
false). It stays available as an opt-in item, and About remains in the
toolbar and the help overlay.

Update config.example.toml, docs/CONFIG.md, README, and the
configurator's status-bar contents page and search terms.

* feat: announce restored annotations on launch

Relaunching restored earlier ink onto the transparent overlay board,
where it sits over whatever is on screen now, with no notice.

After the launch-time session load, when the active board is
transparent and its page has at least one shape, show "Restored 7
annotations from last session" with a Clear button. Clear runs Clear
Canvas, so it is undoable. The toast uses the existing action-chip
support and stays up for 8 s.

Stay quiet for overlays the daemon reopens on a toggle (they carry
the overlay-child generation variable and show what the user just
had on screen), for loads without board data, for solid boards, and
for later output or named-session switches.

* fix: close toolbar popovers with escape

With a GTK toolbar popover open, the toolbar surface keeps keyboard
focus on its own Wayland connection, so Escape did not close the
popover and tool shortcuts typed meanwhile were dropped.

The GTK window and each popover now relay key presses to the backend,
which routes them through the same press path as the overlay's own
keyboard (with GTK's modifier state for that press only). Modifier
presses, Tab, and keys owned by a focused slider or entry stay in GTK.

Key routing now closes any open top-strip menu (shapes, overflow,
Canvas/Session/Settings) on a key press: Escape only closes it, any
other key closes it and still reaches its binding. The built-in
frontend shares the same routing.

* fix: pick toolbar layouts from a stable menu

The chrome island's layout button cycled Simple -> Regular -> Advanced.
Each preset gives the strip a different width, so the button moved out
from under the pointer and a second click landed on the canvas. Regular
and Advanced also rendered the same strip.

The button now opens a small menu (a new LayoutMenu top-strip flyout)
listing the three presets with a one-line description each and the
current one marked; choosing a row applies the preset and closes the
menu. Both frontends render the rows from one shared model, and the
menu closes like the other flyouts (Escape, click-away, tool picks).

Advanced now puts Rectangle, Ellipse, Blur, and Spotlight on the strip
and keeps only the polygons in the Shapes picker; under width pressure
those tools move to the overflow menu before Arrow and Line. Layout
changes still persist through runtime-ui.toml as before.

* feat: add an exit button to the toolbar header

A mouse-only user had no way to close the overlay: the chrome island
offered Layout, About, Pin, and Minimize, and Minimize turned red on
hover, which read as "close".

A new Exit entry (item id top.chrome.exit) ends the chrome island in
both frontends. It runs the Exit action as an explicit exit request, and
its tooltip names the configured Exit keys. When the daemon spawned the
overlay it reads "Hide overlay", since the daemon keeps running. It is a
typed toolbar item, so customization can hide it.

Minimize keeps its round chrome button but now lifts to a neutral tone
on hover; red stays reserved for destructive actions.

* feat: show the capture button on the top strip by default

Capture was reachable only through shortcuts and the command palette:
the toolbar screenshot item shipped in the default hidden list.

The default hidden list is now empty, so the capture button shows by
default. It moves from the annotation utilities into the history
island, after Undo/Redo and before the overflow toggle, and it starts
the interactive region capture (select, then Copy / Save / Both /
Board) instead of the selection-with-defaults capture. It stays a
typed, hideable item and still yields to the overflow menu first
under width pressure. Configs that author their own hidden list keep
it; the example config, docs, and README now describe the new default.

* fix: enlarge the minimized toolbar's restore tab

After Minimize the strip collapsed to a 64x24, low-contrast tab that sat
exactly over the restored strip's Select button, so a double-click
restored the strip and then selected a tool.

The tab is now 104x32 in both frontends (one shared size), with a
raised, outlined body, the restore glyph, and a "Tools" caption; its
tooltip and accessible label still say "Show toolbar". Restoring from
the tab or the micro chip arms a short guard (about a double-click
interval) in the input toolbar state, and the shared toolbar dispatch
drops clicks while it runs, so the second click cannot reach the
controls that reappear under the pointer.

* fix: tuck developer details out of toolbar settings

The Settings popover greeted every visit with "Runtime preferences are
saved separately" and the raw runtime-ui.toml path.

Healthy runtime persistence now reads "Toolbar changes are saved
automatically". The storage path moved into a collapsed "Details"
disclosure: settings notices carry a detail flag, the shared settings
model offers a Details / Hide details toggle, and a new
SetSettingsDetailsOpen event keeps its state in the input toolbar
state, so both frontends render the same rows. Warnings, recovery
files, and the reset action stay in view; the reset button now reads
"Reset toolbar changes".

* feat: add configurable Pen feel toolbar controls

Add a Pen feel panel with named levels and a live smoothing preview,
plus inline meter and stepper options in both toolbar frontends.
Expose the presentation setting in the configurator and config guide.

Prefer visible controls over inflated neighboring hit targets. Accumulate
fractional wheel travel per meter, clear it on pointer departure, and apply
GTK wheel deltas to live application state so delayed snapshots cannot
lose rapid or coalesced notches. Cover hit routing and GTK scroll callbacks.

* feat: show marker opacity as a swatch and tinted track

A bare "90%" beside the marker opacity slider did not say which way was
see-through, and the tooltip described it backwards. The track now fades
from clear to solid in the current color, and the readout is a swatch of
a marker stroke over sample text at the current opacity. Both use the
alpha a marker stroke actually gets, so a translucent color previews as
translucent as it draws. The percentage moves into the tooltip, which now
reads "Marker opacity: 90% solid".

* feat: pick the arrow style from a menu of drawn previews

The arrow style button cycled Standard, Pointy, Curved, and Double on each
click, hiding three styles behind names that do not say what an arrow looks
like. The style pill now carries a chip showing the next arrow's style drawn
and named, and clicking it opens a menu listing every style as a preview
drawn from the arrow outline the canvas fills. Choosing a style sets it and
closes the menu; the keyboard action still cycles.

The menu is a top-strip menu like the layout menu in both frontends: it
closes the other menus, hangs from the chip, and joins the built-in
surface's input region.

* test: split GTK top bar tests into focused modules

* fix: match marker opacity previews to stroke alpha

* fix: fit toolbar labels and help columns with fallback fonts

* feat: keep canvas context menus usable while zoomed

* fix: retire Shape Pen feedback after history changes

* fix: retract restored-ink notices after page and session changes
2026-09-26 22:49:05 +02:00
devmobasa 7b665b0ecd Fix Shape Pen circle and oval overlap recognition
Recognize ovals retraced past their start while preserving existing
closed-shape fits. Validate longer overlaps against the first lap
with a size-scaled tolerance to reject spirals and inward tails.

Add regression coverage for offset overshoots, overlapping polygons,
live previews, and committing on release.
2026-09-26 14:57:51 +02:00
devmobasa 009f8422b8 feat: Shape Pen UX follow-ups and more forgiving recognition (#393)
* fix: apply the Fill toggle to Shape Pen shapes

Recognized ellipses, rectangles, and triangles were always unfilled even
though the Shapes popover offers Fill while Shape Pen is active. They now
follow the toggle like the dedicated shape tools, and Shape Pen's style
pill shows the Fill control.

* fix: call the Shape Pen tool by one name

The palette, tooltip, and help called it "Live Shaping Tool" while the
toolbar said "Shape Pen" and the configurator "Shape pen". Use "Shape
Pen" everywhere, with "Shape Pen Tool" where other tools read "Pen Tool"
or "Line Tool". Also cover its toolbar icon in the painter test.

* feat: name the recognized shape in Shape Pen's live readout

While drawing with Shape Pen, the shape readout badge now says what
release will commit, such as "Triangle 120 × 90" or "Line 140", and
stays hidden while the stroke is still ink. The rectangle and ellipse
tools keep their plain width and height readout.

* feat: adjust Shape Pen sensitivity while drawing

Shape Pen sensitivity could only be set in the config file or the
configurator, and took effect after reopening the overlay. Add a
Sensitivity stepper to Shape Pen's style pill, unbound Increase and
Decrease Shape Pen Sensitivity actions with a toast, and remember the
level with the session like pen smoothing. The config value is the
starting level.

* feat: put Shape Pen on the toolbar and bind it to S

Shape Pen lived only in the Shapes picker and had no shortcut. In the
full toolbar it now sits next to Pen, and it no longer repeats in the
full-mode picker; simple mode keeps it in the picker. Press S to select it.
On a very narrow strip it is the first pen to move into the overflow menu,
since the key and the command palette still reach it.

* feat: undo a Shape Pen shape back to its ink

A recognized stroke used to replace its ink outright, so undo removed the
whole stroke. It now enters history as the ink and then becomes the shape
as a second step: the first undo gives the original stroke back, and the
second removes it. Strokes that stay ink are still one step.

* feat: show Shape Pen's ink faintly under the shape preview

The live preview swapped the ink for the recognized shape without a
trace, and swapped it back if the stroke stopped fitting. The preview now
draws the recognized shape over a faint copy of the stroke, so it is clear
what was recognized and nothing jumps when the stroke turns back into ink.
The preview damage covers both.

* feat: snap Shape Pen shapes to board paper, with an off switch

Only recognized lines snapped to the board grid, and snapping could not
be turned off. Rectangle and oval edges now snap to nearby Cartesian lines,
and triangle corners to nearby Cartesian lines or isometric lattice points,
using the same margin as lines. A snap that would collapse a shape is
skipped.

drawing.shape_recognition_grid_snap (default true), also on the
configurator's Drawing page as "Snap Shape Pen to board paper", turns
grid snapping off. Axis alignment still applies.

* feat: make Shape Pen more forgiving by default

Quick mouse rectangles with a tilted, leaning, or rounded side stayed ink
at the default level, because the rectangle fit compared the stroke with
its bounding box. When that fit fails, find the four corners, fit a line
through each side, and accept sides that alternate between roughly
horizontal and roughly vertical, leaning up to 12 degrees plus 4 per
sensitivity level. The rectangle takes each side's average position.

Also raise the default sensitivity from 2 to 3. Level 4 remains the most
forgiving, and one undo returns the ink when recognition guesses wrong.

The triangle fit now shares its resampling and side fitting with the new
fallback in live_shape/outline.rs.

* perf: recognize each Shape Pen stroke once per pointer move

The preview, its damage bounds, and the shape readout each ran
recognition over the whole stroke on every pointer move. A per-stroke memo
keyed by the stroke's length, ends, and settings now shares one run, and
it is reset when the next stroke starts.

Shape Pen also opted out of append-only damage, so every move repainted
the whole stroke's box. While the preview stays plain ink it now damages
only the new segment, like the pen; a recognized shape, or a switch
between ink and shape, still repaints the full preview.

* fix: clear Shape Pen's shape preview when release keeps ink

If the last preview showed a recognized shape and the release point
tipped the stroke back into ink, release repainted only the ink's own
path. Parts of the preview that sat off the ink, such as an edge snapped
to the grid, stayed on screen until something else repainted them.
Release now repaints the whole previous preview in that case.
2026-09-25 16:46:58 +02:00
devmobasa 99a1ee516e feat: recognize triangles with Shape Pen
Find three corners in a closed stroke, fit a straight edge through the
middle of each side, and place the vertices where the edges meet, so
rounded or overshooting corners still land on the true corner. Near-level
sides are levelled like lines. Right triangles skip the box-center winding
check because their hypotenuse runs through that center.

The existing sensitivity setting scales every triangle tolerance, and
round or four-sided strokes never become triangles at any level.
2026-09-25 15:17:14 +02:00
devmobasa f2c69ed5ed feat: add Shape Pen with live recognition and grid snapping (#392)
* feat: add live shape pen with grid snapping (#391)

* feat: add adjustable Shape Pen recognition and rectangles (#391)
2026-09-25 14:30:24 +02:00
devmobasa 53d122a5ba feat: board grid backgrounds - Cartesian and isometric board paper (#390)
* feat(boards): add Cartesian and isometric paper backgrounds

Integrate session appearance editing, recovery, configurator templates, previews, eraser-safe rendering, and PNG/PDF exports. Avoid redundant paper rendering on cache hits and preserve pending edits when using the visible palette.

* docs: clarify Rust readability and whitespace checks

* feat(boards): improve paper editing and picker menus

Add a scalable paper editor with a size slider, typed spacing, wheel controls, and a configurable Edit Board Paper action. Expose paper, rename, and pin actions through board menus with matching rendering, cursor, hover, and click priority.

Consume the release after outside menu dismissal so the picker stays open and underlying controls remain untouched. Cover board and page menu dismissal and subsequent clicks through the real pointer router.

Validation: tools/lint-and-test.sh passed, including strict Clippy, all-feature and no-default-feature workspace tests, and isolated rendering checks. The dismissal regression failed before the fix and passes with it.

* feat(menus): add cascading submenus with delayed hover navigation

* feat(shortcuts): unify key glyph labels across the overlay

Use shared glyphs and short key names in shortcut labels and the input HUD while preserving canonical config and terminal spellings. Keep help and command-palette search matching canonical key names, and give narrow keycaps a consistent minimum width.

Validation: formatting and whitespace checks passed. The preceding review reported 4,623 passing all-feature library tests with 10 ignored; full CI and manual UI validation were not run for this patch.

* feat(boards): edit paper drafts with the color picker

Open the color popup on the paper draft while preserving Apply and Cancel. Keep colors opaque, hide unsupported controls, and prevent wheel input from reaching the underlying sheet or page grid. Always restore the accepted target color when typed shorthand returns to the opening value.

Remove the board-paper validation artifacts and update the architecture reference.

Validation: the full tools/lint-and-test.sh gate passed, including packaging and repository tools, strict Clippy, all-feature and no-default-feature workspace tests, and isolated rendering tests.
2026-09-13 17:59:41 +02:00
devmobasa 6d3dccfab8 Migrate repository automation to C# 2026-09-10 18:51:27 +02:00
devmobasa d3a346ae12 Ignore Cairo face-cache crash test, drop native-fonts reproducer
The parallel all-features run segfaults inside Cairo's FreeType face
cache: once more than ten font files are open, Cairo evicts a face that
another thread still holds. Wayscriber code is not involved. Upstream
fix, unmerged: https://gitlab.freedesktop.org/cairo/cairo/-/merge_requests/81

Mark the observed victim test #[ignore] with that link so it stays
runnable via --ignored, and remove the C stress harness now that the
root cause is known.
2026-09-08 02:00:43 +02:00
devmobasa e5f0d343ee Fix thumbnail Spotlight sampling across page edges 2026-09-08 01:14:44 +02:00
devmobasa 3d9ca724a7 Fix review regressions and tighten state ownership 2026-09-08 00:50:12 +02:00
devmobasa 4b12f31a02 docs: fold toolbar compatibility guidance into maintained guides 2026-09-08 00:16:43 +02:00
devmobasa 1af696ba3b docs: map configurator workflows and clarify Cairo rendering 2026-09-07 23:30:36 +02:00
devmobasa 4fe3467900 feat(ui): theme the complete command palette surface 2026-09-07 23:30:36 +02:00
devmobasa 84d634e662 refactor: centralize edit and workflow ownership and fix panel spacing 2026-09-05 16:15:53 +02:00
devmobasa f0e90a4504 refactor(ui): inject themes and own overlay paint caches 2026-09-04 23:57:15 +02:00
devmobasa 9db6abbcd4 refactor(render): own image and blur caches explicitly 2026-09-04 23:33:20 +02:00
devmobasa d642ac31a1 refactor(render): separate frame planning from painting and submission 2026-09-04 23:02:24 +02:00