The WM phone shell rides on it: the simulated finger in the desktop skin, time-based release velocity (80 ms window, stale after a 120 ms rest), one critically damped spring (k 900, c 60) seeded with that velocity for paging, drawer, recents and app open/close, an 8 pt / 1.2x axis lock latched through release, a drawer that tracks the finger 1:1 and draws opaque over the home tiles, hold-to-Recents precedence, launches that zoom from the icon actually drawn with an opaque launch card, no ghost card on close, and one cancel/reset path for rotation, resize, focus loss, style switch and keyboard navigation.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Apple ships system fonts whose only outline table is the proprietary hvgl
format (PingFangUI.ttc on macOS 26+ has no glyf/CFF at all). They parse
fine — cmap, metrics and shaping all work — but ttf_parser can outline
nothing, so text silently renders blank.
When ttf_parser yields no outline, Font::glyph_outline now asks CoreText
(whose in-OS decoder reads hvgl) for the glyph path and converts the CGPath
into the same GlyphOutline commands the SDF rasterizer already consumes.
Shaping, metrics and the atlas are untouched; the fallback only fires for
glyphs that render nothing today.
CTFont resolution is layered because CoreGraphics and CTFontManager both
refuse hvgl-only fonts loaded from data or URL, and CTFontCreateWithName
silently substitutes a fallback for dot-prefixed hidden names: try the
PostScript name, then scan the system UI font's cascade lists for CJK
languages (where hidden fonts like .PingFangUITextSC-Default are
reachable), then a temp-file font-manager load. Every candidate is
validated against the ttf_parser view of the face (glyph count + cmap
probes) so a substituted font can never smuggle mismatched glyph IDs into
the atlas. Variation coordinates (e.g. wght for bold) are carried onto the
CTFont via kCTFontVariationAttribute, consistent with the HVAR-adjusted
metrics rustybuzz produces.
Also: env-gated MAKEPAD_FONT_DEBUG=1 logs font family member resolution
(resource path + byte count) while families load.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit 4c59a370834189b87ee6d151b9971defb860e812)
Apple system fonts carry more than 32 gvar variation tuples per glyph
(SFNS.ttf: 54). Without the gvar-alloc heap spill, VariationTuples::reserve
fails past its stack capacity and outline_glyph returns None for most
glyphs — variable-font text renders completely blank.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit f401d4d567245809f4ede9aa2d4a4b54157367fd)
splash_bench geomean against work, best of alternating runs: +12.9% with the series as submitted (the per-instruction Option<String>::take in take_allocation_error alone was +9.8%), -1.1% with this commit.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The ws1 equality/fuel port made an uncaught script error Bail the whole
evaluation (vendor semantics). Splash's incremental eval_with_append_source
sets silence_errors because incomplete source inevitably raises errors that
are meaningless until the rest arrives, and its live widget tree relies on
evaluation continuing past them; with the Bail, `field := TextInput{...}`
never produced its child and
splash::style_tests::embedded_splash_restyles_its_isolate_without_replacing_edits
regressed (bisected to vm-port/ws1-equality-fuel alone).
Gate the Bail on !silence_errors: streaming evals keep drain-and-continue,
every other eval (including Octoscript's captured-sink evals) terminates on
the first uncaught error. Regression test added in vm.rs.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JgSir4cQyaakzPju4h6smw
(cherry picked from commit a4347332b38d0d511287006dc19f0c079604f78a)
(cherry picked from commit 6ee2aecfcb7d9296b501be5ea481caef1a4d024a)
Workstream 3 of the Octoscript VM patch port (heap, strings, arrays, objects).
- Array opcode reads and writes validate the index (finite, non-negative,
integral, representable) before touching storage: ScriptValue::checked_index
and ScriptVm::checked_array_index, applied at every array/pod index site in
opcodes_vars.rs and opcodes_assign.rs.
- Updating an existing untracked object field keeps its insertion order
(ScriptObjectData::map_insert), matching the tracked path.
- Numeric string conversion handles inline and heap strings alike and yields
a traced NaN for text that is not a number (ScriptHeap::cast_to_f64).
- Heap accounting: Octoscript's retained-heap cap is expressed over upstream's
ScriptAllocationBudget instead of a second parallel accounting system. A
persistent budget (heap_cap) is charged by the same charge_allocation calls
as the scoped with_heap_allocation_limit budget, its headroom re-derived
from a retained-capacity estimate by reconcile_heap_bytes (setup, GC sweep,
shrink_to_fit, host boundaries). Public API preserved for octoscript-core:
set_max_heap_bytes / max_heap_bytes / accounted_heap_bytes /
reconcile_heap_bytes / take_heap_limit_exceeded. Refusals surface through
take_allocation_error, so run_core bails uncatchably as before.
- Per-string ceiling: set_max_string_bytes / max_string_bytes /
take_string_limit_exceeded, enforced on exact lengths at the store choke
points (new_string_from_str, new_string_concat, intern_or_store_string and
its preflighted variant, check_intern_string), plus ScriptStringSink,
ScriptStringBuffer, new_bounded_string_with and temp_bounded_string_with
(bounded by the string ceiling and the remaining allocation budget) for
hosts that build strings incrementally. cast_to_string is generic over the
sink. Byte-array parse_json builds its lossy text through the bounded buffer.
- Pod creation is charged; ValueMap/ScriptArrayStorage/ScriptObjectData expose
retained_bytes for the estimate.
Retired in favor of upstream: per-path capacity preflights in array_heap.rs /
object_heap.rs (charge_allocation already meters sparse growth), the sink
generalization of to_json/percent/regex builders (upstream preflights exact
lengths), array_mut_with in vec_prims.rs (host conversions are covered by
reconcile_heap_bytes at the host boundary).
Tests: invalid index reads/writes leave storage untouched, numeric conversion
variants, insertion order, capped sparse growth (array, object vec, object
map) refused before mutation, string ceiling at the store paths and in the
bounded buffer, byte-array to_string/parse_json limits, lossy UTF-8 parity,
scoped budget nesting inside the cap.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JgSir4cQyaakzPju4h6smw
(cherry picked from commit ce4bbe5980f38fd0206f06fe27568528bd49a394)
(cherry picked from commit b21de9ff271d523f52b6154df3c7ef84048772d7)
Ports the parser, tokenizer and control-flow part of the Octoscript
makepad-script patch set (PATCHES.md, grammar v0.2) onto the September
`work` revision, by hand, area by area.
Decisions per PATCHES.md item:
1. Logical/comparison precedence, streaming, `!`: ADAPT. Upstream already
patches a pending ShortCircuitEnd during auto-close but forgot the
operator: ShortCircuitEnd now retains `what_op` so a tighter logical
operator keeps a looser left jump open (`a || b && c`), the checkpoint
restores the original TEST opcodes on continuation, comparisons (14)
bind tighter than equality (15), and NOT always negates truth
conversion instead of doing a bitwise NOT on f64-stored numbers.
Upstream's `last_jump_target` bookkeeping is kept at every patch site.
2. Canonical `try protected catch fallback`: PORT. `catch` is a one-shot
contextual separator carried in TryErrBlockOrExpr checkpoint state
(allow_catch/canonical_catch/protected_was_block); block branches keep
their tail value by removing the inherited pop-to-me marker before
recomputing the jump; TRY_ERR now uses its encoded relative distance
and the parser adds the extra TRY_OK skip only when legacy `ok`
follows. Legacy catch-less `try a b [ok c]` still parses (the checker
in Octoscript's own crates restricts it to the compatibility entry).
3. Cross-call unwinding: PORT. handle_errors searches all call frames
(call_stack_has_try), pops younger script calls restoring slot_base
and the return ip's body, then applies the try-frame cleanup/jump.
Hard bails stay on ScriptTrapOn::Bail.
4. Loop back-edges: ADAPT onto upstream's reset_iteration_scope fast
path: truncate_loop_iteration_bases discards iteration-local tries,
operand values and mes before the scope reset; plain loop/while keep
their iteration scope and free nested ones. Hard time-budget bails
drain their diagnostic; OK_END with no try frame bails.
5. Field-assignment reverse-pair walk: PORT (stop at a 1-opcode chunk).
6. Prototype-field `:` rewrite: PORT (chain must begin with an id,
paired insert through insert_code_with_source keeps opcode/source-map
lockstep); unavailable rust-value index is a parse error.
7. Numeric-boundary tokenizer: PORT (`_` stays in the pending number
instead of moving to Whitespace with stale text) plus the char-count
token length so a multibyte identifier cannot underflow `pos`.
Tests: inline parser/tokenizer/vm regressions and tests/try_catch.rs
(legacy syntax, block/expression branches, nested and cross-function
recovery, contextual `catch`, checkpoint restoration, loop cleanup,
streaming appends, precedence and effects, tokenizer boundaries).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JgSir4cQyaakzPju4h6smw
(cherry picked from commit 8231a13906cfb339b496ff78b687e43a48e4e11b)
(cherry picked from commit a1f7afb543c8a737f9c56936bfcba605097f88df)
Port of the equality / fuel / error-bail slice of Octoscript's makepad-script
patch set onto the September `work` base.
equality (PORT, string compare ADAPTED): `deep_eq` moves from heap.rs into
the new equality.rs as an iterative worklist that visits each container pair
once (cycles and shared DAGs terminate), keeps NaN unequal to itself, and
charges one work unit per processed pair, queued edge and typed-array
element, capped by MAX_EQUALITY_WORK = 65,536 per comparison. Upstream's
69d78873e string early-out is kept instead of the patch's chunked byte
compare: every heap string is interned (string_heap.rs) and short strings
are inline, so string equality is exactly bit equality and a string pair
costs one unit. The raw host `ScriptHeap::deep_eq` is iterative and
unbounded and consumes no VM fuel. The `==`/`!=` opcodes go through
`deep_eq_bounded`: each unit charges one instruction of
`instruction_limit_remaining`, the hard deadline (now an f64 on the
platform clock) is sampled every 256 units so trivial comparisons never
touch the clock, and exhaustion drains diagnostics and raises an
uncatchable Bail, like the instruction limit.
uncaught errors (PORT): `handle_errors` without an active try frame drains
the diagnostic once and sets `ScriptTrapOn::Bail(error)`, so no later
instruction or host effect runs and `eval` returns the error value; active
`try` handlers recover exactly as before. The hard time-budget bail drains
its diagnostic before unwinding, as the instruction-limit bail already did.
allow_debug_output (PORT): new host-controlled `ScriptVmBase` flag,
default true so raw makepad debugging is unchanged. When false the `~` LOG
opcode raises a catchable not-allowed error and `ScriptVm::log` is a no-op.
Incidental VM prints are removed: run_core's `log!` traces, the undefined
opcode `eprintln!` (now a bail) and the loop "unknown state" `println!`
(now a bail). mod_std.rs needs no change: std.log already routes through
the gated `ScriptVm::log`.
Tests: platform/script/tests/equality_fuel_bail.rs covers cycles, shared
DAGs, NaN, string/number semantics, the host deep_eq on typed arrays and
beyond the ceiling, instruction fuel charging, the work ceiling being
uncatchable, the in-comparison hard-deadline check, the hard-budget drain,
uncaught-error bail with try recovery, and the debug-output flag.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JgSir4cQyaakzPju4h6smw
(cherry picked from commit a44f8678ed68b20a0c413d607c07a37d55186fbe)
(cherry picked from commit 353fa369faa5672e075dd874a431dc928420bafb)
Port of Octoscript's re-entrancy and hardening items onto the new VM host contract.
- vm.rs: run_core no longer caches a raw pointer into the active body's opcode
vector across native calls. Each opcode is copied through a scoped
`bodies.borrow()` that ends before dispatch, so a native handler that
re-enters `eval` and replaces the body's parser cannot leave a dangling
pointer. Regression: reentrant_reload_of_the_active_body_does_not_keep_an_opcode_pointer.
- thread.rs: ScriptThreads::set_current validates the index before updating
the cached pointer (set_current_thread_id routes through it); update_ptr is
bounds-checked via get_mut; cur/cur_ref/trap use release-mode assert!.
Regressions: selecting_an_unknown_current_thread_panics_before_pointer_update,
empty_threads_reject_current_access_in_release_builds.
- handle.rs: ScriptHandleGc: Any; is/downcast_ref/downcast_mut compare
Any::type_id, removing the overridable ref_cast_type_id hook.
Regression: handle_downcasts_use_the_concrete_any_type.
- suggest.rs: value previews truncate at character boundaries.
Regression: preview_truncation_preserves_utf8_boundaries.
- libs/regex utf8.rs: iterator entry uses `self.range_stack.pop()?` instead of
a never-advancing `while let`, replacing upstream's #[allow(clippy::never_loop)].
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JgSir4cQyaakzPju4h6smw
(cherry picked from commit ee7729e71c1408f61ec8f9083a79bdf9117d31a7)
(cherry picked from commit cdbc33e8ac1683fcdeb6e9dcb076e26ce5d23360)
Pressing a radio grabbed key focus immediately, and the theme draws the focus
ring in the same color as hover, so a press dragged off the button left what
looks like a stuck hover highlight behind.
Move `set_key_focus` to the completed click, so a cancelled press leaves no
mark. Keyboard focus still arrives through `Hit::KeyFocus`.
* HtmlLink: don't keep the hover color after a touch release
`Hit::FingerUp` played `hover.on` whenever the release was over the link, and
that state sets `pressed` as well as `hovered`. Touch never sends a
`FingerHoverOut` afterwards, so a tapped or long-pressed link kept the red
pressed color until its list item was recycled.
Guard that branch with `has_hovers()`, the way `TextFlowLink` and `Button`
already do.
* HtmlLink: let `hover_color` show while hovering
`hover.on` snapped `pressed` to 1.0, and `draw_walk` checks `pressed` before
`hovered`, so a hovered link always drew `pressed_color` and any `hover_color`
was dead. `TextFlowLink` carries the same animator but checks `hovered` first,
which is why it never showed there.
Clear `pressed` in that state, like `Button`, `CheckBox` and `Markdown` do.
Its `from` clause already fades `pressed` over 0.01s, which only makes sense
fading to zero.
* RadioButton: don't keep the hover tint after a touch release
`Hit::FingerUp` played `hover.on` unconditionally, and touch never sends a
`FingerHoverOut` afterwards, so a tapped radio kept its hover tint until
something else redrew it. Releasing the mouse away from the button left it
tinted too, since the arm never checked `is_over`.
Guard that branch with `is_over` and `has_hovers()`, the way `Button` and
`TextFlowLink` already do.
* RadioButton: only select when the release is over the button
`Hit::FingerUp` selected and emitted `Clicked` without checking `is_over`, so
pressing a radio and releasing anywhere else still selected it. `Button` gates
its click on `is_over`; do the same here.
A Splash isolate receives makepad's own mods and nothing else, so a widget
type defined in host code is unnameable from a mounted body. Octoscript-Makepad
hit this with OctoscriptTap: unable to name it, the generated body had to
target a Button, whose handle_event captures the finger on touch-down, so
every tappable row starved the scroll it sat in.
register_splash_isolate_mod(fn(&mut ScriptVm)) records an installer; each
isolate runs the registered installers as the last step of its allocation.
Last matters: it is after the ambient-authority strip (fs, run, res,
cx.quit) and after the jailed fs and brokered host re-registrations, so a
host mod cannot be removed by that pass and sees the isolate's final
namespace. Host code is trusted and already chooses what it installs.
The registry is a thread-local because the caller has no Cx in hand, and is
collected before running so an installer may register another. Mods are
taken at allocation, so a registration only reaches later isolates.
Test covers the contract in both directions: an isolate allocated before
registration does not resolve the probe, one allocated after does, each
allocation installs again, and the earlier isolate stays unchanged.
makepad-widgets: 207 passed, 5 failed — the same 5 that fail on the
unmodified branch (desktop_style, grid x2, widget_tree x2).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GR1KyERPDtrF9FgZ9HvWqt
(cherry picked from commit 227aa3625b8e53ab1ff3f153f63933564849640d)
(cherry picked from commit 9addb746b2497abd9fe530aebb68b3c3d99b0ccf)
* Tooltip: position anchored tooltips in the same draw
`CalloutTooltip` drew itself invisibly, waited on a 5ms timer, then read the
drawn size back and re-applied margins/widths until the height stopped
changing. When the timer beat the paint it read a zero rect, gave up, and left
the tooltip opened but invisible.
* base `Tooltip` gains `show_anchored`: it draws `content`, measures it, and
shifts its align range into place (`Tooltip::place` flips to the roomier
side, clamps to the safe area, and keeps `gap` from the anchor and edges)
* `CalloutTooltip` now derefs `Tooltip`, measures its label each draw to pick
the wrap width, and writes the callout edge/offset into the shader post-draw
* Tooltip: ignore zero-delta scroll events
macOS sends a zero-delta `Scroll` whenever fingers touch or rest on the
trackpad (`ScrollPhase::Touched`, and `Began` for a two-finger rest). That hid
every open tooltip, and since the pointer never left the target, nothing showed
it again. Only a scroll that actually moves counts as an interaction now.
* Wayland: restore a maximized window maximized, not fullscreen
`configure_window`'s `is_fullscreen` is the legacy maximize-or-fullscreen
flag, and on Wayland the reporting side says so out loud: `wayland_state`
builds the geom with `is_fullscreen: is_fullscreen || is_maximized`. But
`WaylandWindow::new` took that same bool and called
`toplevel.set_fullscreen(None)`. So an app that persists `is_fullscreen()`
on exit and feeds it back through `configure_window` on the next launch --
Robrix does exactly this -- turns a window the user had merely maximized
into true compositor fullscreen, and the escalation sticks: the next save
records the monitor size rather than the work area.
Wayland was alone in reading the flag that way. x11 creates with
`self.maximize()` (`_NET_WM_STATE_MAXIMIZED_HORZ/VERT`) and reports
`get_is_maximized()` back; win32 creates with `ShowWindow(SW_MAXIMIZE)`
and reports the `WS_MAXIMIZE` style bit. Both round-trip. Only macOS takes
the flag literally, and AppKit keeps a menu bar and traffic lights there.
* Create with `set_maximized()`.
* Seed `is_maximized` from the request rather than `is_fullscreen`.
`wayland_is_fullscreen` is copied out of that field before the first
configure arrives, so the old seeding claimed fullscreen from frame one,
before the compositor had confirmed anything, while `window_geom` still
said `is_fullscreen: false`. The two signals no longer disagree.
* `should_show_csd_shadow` gets the request as `maximized`; same answer as
before, now for the right reason.
An app that wants to come up genuinely fullscreen still can:
`WindowHandle::fullscreen()` during `Event::Startup` queues
`FullscreenWindow` behind `CreateWindow` in the same FIFO drain.
Also corrects the docs this contradicted. `configure_window` claimed
`inner_size` and `position` are ignored when fullscreen and that the
window is sized to the monitor, which is true on no backend now, and
`maximize()` claimed macOS zooms when it calls `toggleFullScreen:`.
* Wayland: keep our window chrome up in fullscreen
Under client-side decorations we draw the title bar and the min/max/close
cluster ourselves, and we were hiding both the moment the compositor put
the toplevel in fullscreen. The compositor draws nothing in their place,
so the window ends up with no chrome at all -- and the max button is the
only path to `RestoreWindow`, so there is no way back out. There is no
F11, no Escape, `Window::handle_event` has no `KeyDown` arm, and the View
menu is inert outside macOS. That leaves the compositor's own keybinding,
if the desktop happens to have one bound.
Hiding chrome in fullscreen is the right call when the OS supplies its own
-- macOS has an auto-hiding menu bar and traffic lights there, which is
why that arm stays as it is. Wayland supplies nothing, so ours stays up.
* `sync_caption_bar_state` drops the `wayland_fullscreen` terms: the
caption bar and the buttons now follow `custom_chrome` alone.
* Fill `window_chrome_buttons` in the geom in fullscreen too. It is the
transitional hit-test rect `WindowDragQuery` falls back on before the
widget layout is known, so leaving it empty made the first clicks after
entering fullscreen read as a caption drag instead of a button press.
* Let the caption's own gestures through in fullscreen -- they were gated
behind `!is_fullscreen` on a bar that could not be visible then anyway.
Double-click now unsets fullscreen first: `set_maximized` under it does
nothing, so the bar would have looked dead.
`is_wayland_fullscreen()` loses its only caller but stays public: it is
the only way to tell true fullscreen from maximize, which the conflated
`is_fullscreen()` cannot. Say so on `WindowGeom::is_fullscreen` too, since
reading it as real fullscreen is what started this.
* Window: drop hide_caption_on_fullscreen, a dead trap
The `WindowGeomChange` arm hid the caption bar whenever the geom flipped
to `is_fullscreen`, on `Windows | Macos`. On Windows that flag is literally
`get_is_maximized()` (`win32_window.rs`: `is_fullscreen:
self.get_is_maximized()`), and Windows draws its own chrome, so this would
have stripped the close button on a plain maximize -- the same trap just
fixed on Wayland, one `#[live]` default away from firing.
It never fired, and could not have: `hide_caption_on_fullscreen` is set
nowhere in makepad or in any app (grep finds no other mention, DSL
included), and `sync_caption_bar_state` re-decides caption visibility on
every event through `ensure_initialized()`, so it overwrites whatever this
arm set. The macOS half it duplicated lives there already.
Deleted rather than repaired: the whole caption policy belongs in
`sync_caption_bar_state`, and a second copy that keys off a flag meaning
different things per platform is what produced the bug in the first place.
* macOS: restore() no longer enters fullscreen
`restore()` and `maximize()` were the same call, `toggleFullScreen:`, so
`CxOsOp::RestoreWindow` on a window that was not fullscreen put it *into*
fullscreen. The Window widget's max button hands `restore()` whatever
`is_fullscreen()` reports, which on macOS is the real NSWindow fullscreen
state -- so this only misfires when something else pushes `RestoreWindow`
on its own, but then it does the exact opposite of its name.
Guard on `is_fullscreen`, the field the fullscreen delegates maintain.
* x11: implement FullscreenWindow and NormalizeWindow
Both fell through to the catch-all `Not implemented on this platform`, so
`WindowRef::fullscreen()` and `disable_fullscreen()` silently did nothing
on x11. `_NET_WM_STATE_FULLSCREEN` was never even interned -- the atom
table only carried the two maximize atoms.
* Intern `_NET_WM_STATE_FULLSCREEN`, and split the `_NET_WM_STATE` client
message out of `restore_or_maximize` so the fullscreen requests can
reuse it instead of copying the send.
* `get_is_maximized` becomes a thin caller of `has_net_wm_state`, which
`get_is_fullscreen` shares.
* `get_window_geom` reports `maximized || fullscreen`, the same union
Wayland reports and the meaning the flag already had. Creation still
maps the flag to `maximize()` alone, so a persisted `true` cannot come
back as fullscreen -- the bug this branch opened with.
* `RestoreWindow` drops both states, matching the Wayland arm: a caller
restoring off `is_fullscreen()` means "make it small again", and the
union does not say which of the two is set.
* Wayland: go back to the floating size when leaving maximize
An xdg_toplevel configure of 0x0 means "pick your own size", which is what
the compositor sends on the way out of maximize or fullscreen. We fell back
to `window_geom.inner_size` -- the size we were maximized at -- so the
window came out of maximize still covering the work area, with nothing to
bring it back down. Creating a window maximized made it permanent: the
floating size was never recorded anywhere.
Track the last size the window actually floated at, and use that for the
0x0 case. `is_floating` excludes tiled as well as maximized and fullscreen,
so a half-snapped window does not get recorded as the floating size.
A configure that does carry a size is still obeyed exactly as before, which
is both what the protocol requires and what a user drag-resize produces.
Seen with a window created maximized and then normalized: 3383x1408 before,
the requested 900x600 after. Note this only covers the case where the
compositor defers to us -- GNOME sends a concrete size after unfullscreening
a window that was maximized first, and we honor it.
* Linux: tell the app when the pointer leaves the window
Neither Linux backend ever sent `Event::MouseLeave`. Windows has sent one
since forever, and `Hit` handles it -- `finger.rs` returns `FingerHoverOut`
for whatever area still holds the hover -- but on Wayland and x11 nothing
produced it, so the last hovered widget kept its hover the entire time the
pointer was outside the window.
That is what makes the window chrome buttons flicker. Hover close, move the
pointer off the top of the window, and the button stays lit; the hover only
clears on the first motion after the pointer comes back, so returning to the
window makes the button flash off. It is most obvious on the chrome buttons
because they sit against the window edge, where leaving the button and
leaving the window are the same gesture.
It also broke re-entry. `FingerHoverOut` does not clear the stored hover
area -- `cycle_hover_area` does, once per event -- so with no leave event
`hover_last` still named the button, and coming back over it returned
`FingerHoverOver` instead of `FingerHoverIn`. Widgets act on HoverIn, so a
button could fail to light up at all on the second hover.
* Wayland: emit it from `wl_pointer.leave`, guarded on `pointer_window`
being set, which it is only for the toplevel surface -- leaving a CSD
shadow gutter has no hover to drop.
* x11: finish the `LeaveNotify` TODO that has been commented out in
`xlib_app`. The condition it had, `detail == 4`, would not have fired
anyway: a real pointer-out-of-window here reports detail 3
(NotifyNonlinear). Take any detail except NotifyInferior (the pointer
only moved into a child), and only mode NotifyNormal, so a grab or
ungrab does not drop the hover mid-drag.
* Both event loops then `cycle_hover_area` + `switch_captures`, the same
pair the MouseMove arm uses and the same thing the Win32 arm does.
Verified on both backends with a probe on the hover in/out arms: before,
leaving the surface logged nothing; after, HoverOut fires on leave and a
fresh HoverIn on re-entry.
* DesktopButton: cross-fade the hover background premultiplied
The chrome buttons flash dark for an instant when the pointer leaves them.
Not a hover-state bug -- the `hover` instance really does fall 1.0 -> 0.0
monotonically over the 100ms fade. The dip is in the shader.
`bg_color` is `#00000000`: transparent BLACK. Mixing it toward an opaque
`bg_color_hover` in straight-alpha space ramps rgb up from black as well as
alpha, and `sdf.fill` then multiplies rgb by that same alpha again, so what
reaches the premultiplied blend is `rgb * h^2` against coverage `h`. Over a
caption bar of luminance C the composite is quadratic in h and sags well
below both endpoints in the middle.
Measured on a #F3F3F3 bar with the `#E9E9E9` hover face this file's callers
use (widgets/src/window.rs), sweeping hover across the button's width:
hover 0% 30% 52% 80% 100%
before 246 207 199 212 238
after 246 244 242 240 239
The endpoints are 8 levels apart, so the intended highlight is nearly
invisible -- and the 47-level excursion between them is the only thing the
eye catches. It is symmetric, but on the way in it reads as arrival feedback
and on the way out it is a dark flash left behind where the pointer just
was, which is why it gets reported as flicker on hover-out.
Premultiply each face before mixing and fill with `fill_premul`, so the
cross-fade is linear in `hover` and anti-aliasing blends in premultiplied
space too. `DesktopButton` is the only widget with a fully transparent base
colour feeding this pattern.