From b7b440ebeb6593f64ea436a4795cb87ba1dafba0 Mon Sep 17 00:00:00 2001 From: foxnne Date: Sun, 18 Oct 2026 19:38:57 -0500 Subject: [PATCH 1/3] backend: a window dragged over a title bar stays out of it A floating window torn off and dragged over another of the app's windows goes back into it. Over that window's title bar it went in too, and dvui held its top below the title bar, away from the pointer: the window jumped off the pointer as it crossed (the user). The tear-off now asks whose content the pointer is over (`windowContentAtPointer`, new beside `windowAtPointer`): over a title bar, none, and the window stays out until the pointer reaches the content. --- backend/src/SDLBackend.zig | 17 +++++++++++++++++ backend/src/os_windows.zig | 5 +++-- backend/src/viewports.zig | 6 ++++++ 3 files changed, 26 insertions(+), 2 deletions(-) diff --git a/backend/src/SDLBackend.zig b/backend/src/SDLBackend.zig index 14602e228..d4301e628 100644 --- a/backend/src/SDLBackend.zig +++ b/backend/src/SDLBackend.zig @@ -1221,6 +1221,23 @@ pub fn windowAtPointer(_: *SDLBackend) ?*anyopaque { return fizzy_macos_app_window_at_pointer(); } +/// The app's window whose content the pointer is over — below its title bar — or null: over a title +/// bar (the main window's, a window of the OS's own frame's) it is over no window's content. A +/// floating window dragged there took the window for the one to land in, and dvui held its top +/// below the title bar, away from the pointer (the user). +pub fn windowContentAtPointer(self: *SDLBackend) ?*anyopaque { + if (comptime builtin.target.os.tag != .macos) return null; + const window = fizzy_macos_app_window_at_pointer() orelse return null; + var gx: f32 = 0; + var gy: f32 = 0; + _ = c.SDL_GetGlobalMouseState(&gx, &gy); + if (window == cocoaWindow(self.window)) return if (gy < self.mainOnScreen().y) null else window; + for (&self.viewport_slots) |*slot| if (slot.*) |*vp| if (cocoaWindow(vp.window) == window and vp.native) { + return if (gy < @as(f32, @floatFromInt(vp.screen.y))) null else window; + }; + return window; +} + /// `window` (`windowAtPointer`'s) in front of the app's other windows, not made key. pub fn bringForward(_: *SDLBackend, window: *anyopaque) void { if (comptime builtin.target.os.tag != .macos) return; diff --git a/backend/src/os_windows.zig b/backend/src/os_windows.zig index a5201a8ca..4dbaf2670 100644 --- a/backend/src/os_windows.zig +++ b/backend/src/os_windows.zig @@ -367,8 +367,9 @@ fn tearFrame() void { } t.last = r; // The app's window it is over in front, to show where it would land (the user): once, as it - // comes over each. - const over = viewports.windowAtPointer(); + // comes over each. Over its content, not its title bar, where it stays out + // (`windowContentAtPointer`). + const over = viewports.windowContentAtPointer(); if (t.moving and over != t.over) if (over) |w| viewports.bringForward(w); t.over = over; // One with no `open_flag` gets no window (`named`), and its window could not close: it stays diff --git a/backend/src/viewports.zig b/backend/src/viewports.zig index d7c67b19c..2347e661a 100644 --- a/backend/src/viewports.zig +++ b/backend/src/viewports.zig @@ -82,6 +82,12 @@ pub fn windowAtPointer() ?*anyopaque { return dvui.currentWindow().backend.impl.windowAtPointer(); } +/// The app's window whose content (below its title bar) the pointer is over, or null +/// (`SDLBackend.windowContentAtPointer`). +pub fn windowContentAtPointer() ?*anyopaque { + return dvui.currentWindow().backend.impl.windowContentAtPointer(); +} + /// `window` (`windowAtPointer`'s) in front of the app's other windows, not made key. pub fn bringForward(window: *anyopaque) void { dvui.currentWindow().backend.impl.bringForward(window); From bf52722887f010f5f703f07e7e1ce0dbef5ad4b4 Mon Sep 17 00:00:00 2001 From: foxnne Date: Sun, 18 Oct 2026 19:40:51 -0500 Subject: [PATCH 2/3] backend: a torn-off window let go over the app goes back in at once A torn-off window moved by the OS went back into the main window only once all of it lay inside the main window, and only on a frame after the OS's move let go, which waited for the next event; then it shrank back through a card of its last picture, its title bar's place becoming dvui's header's, and flashed a title on the way (the user: "takes more events", "pops a title"). Now it goes back in as the OS's move lets go with the pointer over the main window's content: frames keep coming while the OS moves it, so the let-go is seen at once; the same frame draws the floating window where the window was, dvui's header where its title bar was; and the window, over it, closes the frame after (`closing`), so neither is missing for a frame. The card, its drawing and `mainHolds` go. --- backend/src/SDLBackend.zig | 29 ++++++---- backend/src/os_windows.zig | 114 +++++++++++-------------------------- backend/src/viewports.zig | 13 +++-- 3 files changed, 60 insertions(+), 96 deletions(-) diff --git a/backend/src/SDLBackend.zig b/backend/src/SDLBackend.zig index d4301e628..fdbd6e71d 100644 --- a/backend/src/SDLBackend.zig +++ b/backend/src/SDLBackend.zig @@ -2043,27 +2043,32 @@ pub fn viewportLiquidGlass(_: *SDLBackend, vp: *Viewport, look: platform.window. return platform.window.liquidGlassLook(vp.window, look); } -/// Where `vp`'s window is now, in the main window's part of the frame: physical pixels from the -/// main window's top left, wherever either window has been moved since it opened. -/// Whether `vp`'s window — its title bar's band too — lies wholly inside the main window, with the -/// main window on the Space being shown (`viewports.mainHolds`). -pub fn viewportMainHolds(self: *SDLBackend, vp: *const Viewport) bool { +/// Whether the pointer is over the main window's content — below its title bar — on the Space being +/// shown (`os_windows`: a torn-off window let go there goes back in). By where the main window is, +/// whatever lies over it: the window being let go is. +pub fn viewportPointerOverMainContent(self: *SDLBackend) bool { if (comptime builtin.target.os.tag == .macos) if (fizzy_macos_window_off_active_space(cocoaWindow(self.window)) != 0) return false; - var x: c_int = 0; - var y: c_int = 0; - var w: c_int = 0; - var h: c_int = 0; - _ = c.SDL_GetWindowPosition(vp.window, &x, &y); - _ = c.SDL_GetWindowSize(vp.window, &w, &h); + var gx: f32 = 0; + var gy: f32 = 0; + _ = c.SDL_GetGlobalMouseState(&gx, &gy); var mx: c_int = 0; var my: c_int = 0; var mw: c_int = 0; var mh: c_int = 0; _ = c.SDL_GetWindowPosition(self.window, &mx, &my); _ = c.SDL_GetWindowSize(self.window, &mw, &mh); - return x >= mx and y >= my and x + w <= mx + mw and y + h <= my + mh; + const top = self.mainOnScreen().y; + return gx >= @as(f32, @floatFromInt(mx)) and gx < @as(f32, @floatFromInt(mx + mw)) and gy >= top and gy < @as(f32, @floatFromInt(my + mh)); } +/// Whether the OS is moving `vp`'s window under a held press (`viewportOsMoveEnded` says when it +/// lets go). +pub fn viewportOsMoving(_: *SDLBackend, vp: *const Viewport) bool { + return vp.os_moving; +} + +/// Where `vp`'s window is now, in the main window's part of the frame: physical pixels from the +/// main window's top left, wherever either window has been moved since it opened. pub fn viewportInMain(self: *SDLBackend, vp: *const Viewport) viewport_map.Rect { // Where it was last put, not SDL's idea of it: on Windows it may have been moved in one step // behind SDL's back (`viewportPlace`), and only the app moves it. diff --git a/backend/src/os_windows.zig b/backend/src/os_windows.zig index 4dbaf2670..ebeb5ffda 100644 --- a/backend/src/os_windows.zig +++ b/backend/src/os_windows.zig @@ -86,9 +86,9 @@ var keyed: ?dvui.Id = null; /// OS window of its own at once — the OS's title bar and buttons under the pointer — shrunk to a /// miniature of itself: its content laid out at its size, shown scaled, live. Back over a window of /// the app's, the window hides and the floating window is there again. Let go over the desktop, the -/// window grows to the floating window's size (`Grow`) and is its window from then (`torn`). Moved -/// by the OS wholly back into the main window, the window shrinks into its place there, dvui's -/// header for the OS's title bar (`Merge`). One window all the way: a borderless card that grew and +/// window grows to the floating window's size (`Grow`) and is its window from then (`torn`). Let go +/// by the OS's move over the main window's content, it is the floating window there again, dvui's +/// header where its title bar was (`beforeFrame`, `closing`). One window all the way: a borderless card that grew and /// then gave way to a window opened for it jumped as one replaced the other (the user). Only one /// that can close (`open_flag`, `named`): a window's close clears it. dvui says nothing of a drag, /// so it is read from what dvui shows: the floating window that has the mouse, its rect moving @@ -141,21 +141,13 @@ const Grow = struct { }; var grow: ?Grow = null; -/// A torn-off window moved wholly back into the main window (`beforeFrame`): its last picture -/// shrinking from the window's frame to the floating window's place, the OS's title bar's band -/// becoming dvui's header's, then the floating window there. -const Merge = struct { - id: dvui.Id, - card: *viewports.Viewport, - card_target: ?dvui.Texture.Target = null, - snap: dvui.Texture.Target, - from: dvui.Rect.Physical, - band: f32, - to: dvui.Rect.Physical, - header: f32, - start_ns: i128, -}; -var merge: ?Merge = null; +/// Torn-off windows gone back into the main window (`beforeFrame`), closed the frame after: the +/// frame that goes back in draws the floating window in the main window where its window was, and +/// its window, over it, shows its last picture till then. Closed at once, neither showed it for a +/// frame; shrunk back through a card of its last picture, its title bar's place became dvui's +/// header's as it went and the window flashed a title, and only went back in once all of it was +/// over the main window (the user). +var closing: [max_outs]?struct { viewport: *viewports.Viewport, target: ?dvui.Texture.Target } = @splat(null); /// How long the card takes to grow into its window, or a window to shrink back into the main one, /// and the floating window to shrink into the card as it goes past the app's windows. @@ -235,6 +227,13 @@ fn physical(r: dvui.Rect, s: f32) viewports.Rect { pub fn beforeFrame() void { named_n = 0; closable_n = 0; + // The windows gone back in last frame (`closing`): the main window has drawn their floating + // windows by now. + for (&closing) |*c| if (c.*) |w| { + viewports.close(w.viewport); + if (w.target) |t| t.destroyLater(); + c.* = null; + }; const s = dvui.windowNaturalScale(); for (&outs) |*slot| { const o = if (slot.*) |*o| o else continue; @@ -242,10 +241,13 @@ pub fn beforeFrame() void { // never the other way. Not grown to fit what it holds, as dvui sizes a floating window // while its content changes (`_auto_size`). _ = viewports.osPlaced(o.viewport); - // Torn off, and moved by the OS wholly into the main window: back in it, where it was let - // go, as a floating window of its own again (`torn`). Wholly, so a window only overlapping - // the main one stays a window. - if (viewports.osMoveEnded(o.viewport)) |end| if (!end.resized and torn(o.id) and viewports.mainHolds(o.viewport)) { + // Moved by the OS, frames keep coming, so its let-go is seen at once: after the OS's own + // move it otherwise waited for the next event (the user: "takes more events"). + if (viewports.osMoving(o.viewport)) dvui.refresh(null, @src(), null); + // Torn off, and let go by the OS's move with the pointer over the main window's content: + // back in it, where it was let go, as a floating window of its own again (`torn`). A window + // let go elsewhere — over the desktop, the main window's title bar — stays a window. + if (viewports.osMoveEnded(o.viewport)) |end| if (!end.resized and torn(o.id) and viewports.pointerOverMainContent()) { // Its content where it was and at its size, dvui's header above it where the title bar // was: the floating window's rect holds its header, and given only the content's, the // content came back a header shorter (the user). @@ -257,16 +259,14 @@ pub fn beforeFrame() void { for (&torn_list) |*ts| if (ts.*) |tt| if (tt.id == o.id) { ts.* = null; }; - // Shrinking into its place from its window, its last picture in a card over it - // (`Merge`); or there at once where no card can be made. - const band = viewports.titleBand(); - const from: dvui.Rect.Physical = .{ .x = c.x, .y = c.y - band, .w = c.w, .h = c.h + band }; - if (merge == null and o.target != null and viewports.tears()) if (viewports.openCarry(viewRect(from))) |card| { - merge = .{ .id = o.id, .card = card, .snap = o.target.?, .from = from, .band = band, .to = to, .header = header, .start_ns = dvui.currentWindow().frame_time_ns }; - o.target = null; - }; - viewports.close(o.viewport); - if (o.target) |t| t.destroyLater(); + // Its window closed next frame, over the floating window this frame draws (`closing`). + const free = for (&closing) |*cl| { + if (cl.* == null) break cl; + } else null; + if (free) |cl| cl.* = .{ .viewport = o.viewport, .target = o.target } else { + viewports.close(o.viewport); + if (o.target) |t| t.destroyLater(); + } slot.* = null; open_failed = false; dvui.refresh(null, @src(), null); @@ -313,13 +313,12 @@ fn viewRect(r: dvui.Rect.Physical) viewports.Rect { } /// The floating window being dragged this frame, if any, as a card past the app's windows (`Tear`), -/// and a card growing into its window (`Grow`) or a window shrinking back (`Merge`). After the app +/// and its window growing to its size (`Grow`). After the app /// has drawn, so its rect and drawing are this frame's. fn tearFrame() void { if (!viewports.tears()) return; const cw = dvui.currentWindow(); growFrame(cw); - mergeFrame(cw); const held = viewports.pointerHeld(); var dragged: ?*dvui.Subwindows.Subwindow = null; if (held) if (dvui.captureMouseGet()) |cm| for (cw.subwindows.stack.items) |*sw| { @@ -463,7 +462,7 @@ fn dropContent(size: dvui.Rect.Physical, grab: dvui.Point.Physical) dvui.Rect.Ph .w = size.w, // The floating window's rect holds its header, which the title bar takes the place of: its // content is that much shorter. Given the whole rect, the window came back from each trip - // out and in a header taller (`Merge` puts the header back above the content; the user). + // out and in a header taller (going back in puts the header above the content; the user). .h = @max(1, size.h - headerHeight()) + band, }; const u = viewports.usableAtPointer(); @@ -494,32 +493,6 @@ fn livePicture(slot: *?dvui.Texture.Target, sw: *dvui.Subwindows.Subwindow) bool return true; } -/// A card in carry window `card` at `at` (the main window's frame, physical): the window's colour, -/// a band of it `strip` high at its top — a title bar's or a header's place — and `content` under -/// it at the card's width; its corners the window's at `scale`. -fn drawCard(card: *viewports.Viewport, card_target: *?dvui.Texture.Target, content: dvui.Texture.Target, at: dvui.Rect.Physical, strip: f32, scale: f32) void { - const placed = viewports.placeMain(card, viewRect(at)); - const shown: dvui.Rect.Physical = .{ .x = placed.x, .y = placed.y, .w = placed.w, .h = placed.h }; - viewports.carryShape(card, viewports.windowRadius() * dvui.windowNaturalScale() * scale, 1); - const w: u32 = @intFromFloat(@max(1, @round(shown.w))); - const h: u32 = @intFromFloat(@max(1, @round(shown.h))); - const target = viewports.sizedTarget(card_target, w, h) orelse return; - const picture: viewports.Picture = .begin(target, viewRect(shown)); - { - const prev_clip = dvui.clipGet(); - defer dvui.clipSet(prev_clip); - dvui.clipSet(shown); - shown.fill(.all(0), .{ .color = .{ .color = dvui.themeGet().color(.window, .fill) } }); - if (dvui.Texture.fromTargetTemp(content)) |tex| { - const k = at.w / @max(1, @as(f32, @floatFromInt(content.width))); - const r: dvui.Rect.Physical = .{ .x = at.x, .y = @round(at.y + strip), .w = at.w, .h = @as(f32, @floatFromInt(content.height)) * k }; - dvui.renderTexture(tex, .{ .r = r, .s = 1 }, .{}) catch {}; - } else |_| {} - } - picture.end(); - viewports.present(card, target); -} - /// The window let go over the desktop growing to the floating window's size (`Grow`), its picture /// scaled up with it, and the floating window's own window once it has: the floating window, drawn /// without its header meanwhile (`frameless`), laid out in it from the next frame. @@ -579,25 +552,6 @@ fn endGrow(g: *Grow) void { dvui.refresh(null, @src(), null); } -/// A torn-off window shrinking back into the main window (`Merge`): its last picture eased from the -/// window's frame to the floating window's place, the OS's title bar's band becoming dvui's -/// header's; the floating window drawn nowhere meanwhile, and there as the card goes. -fn mergeFrame(cw: *dvui.Window) void { - const m = if (merge) |*m| m else return; - const k = ease(cw.frame_time_ns - m.start_ns, grow_ns); - if (k >= 1) { - viewports.close(m.card); - if (m.card_target) |ct| ct.destroyLater(); - m.snap.destroyLater(); - merge = null; - return; - } - for (cw.subwindows.stack.items) |*sw| if (sw.id == m.id) viewports.Picture.drop(sw); - const at = whole(lerp(m.from, m.to, k)); - drawCard(m.card, &m.card_target, m.snap, at, m.band + (m.header - m.band) * k, 1); - dvui.refresh(null, @src(), null); -} - fn lerp(a: dvui.Rect.Physical, b: dvui.Rect.Physical, k: f32) dvui.Rect.Physical { return .{ .x = a.x + (b.x - a.x) * k, .y = a.y + (b.y - a.y) * k, .w = a.w + (b.w - a.w) * k, .h = a.h + (b.h - a.h) * k }; } diff --git a/backend/src/viewports.zig b/backend/src/viewports.zig index 2347e661a..30f378e31 100644 --- a/backend/src/viewports.zig +++ b/backend/src/viewports.zig @@ -52,10 +52,15 @@ pub fn tears() bool { return builtin.target.os.tag == .macos and SDLBackend.floating_os_windows != .none; } -/// Whether `vp`'s window lies wholly inside the main window, on the Space being shown -/// (`os_windows`: a torn-off window let go there goes back in). -pub fn mainHolds(vp: *const Viewport) bool { - return dvui.currentWindow().backend.impl.viewportMainHolds(vp); +/// Whether the pointer is over the main window's content, on the Space being shown (`os_windows`: +/// a torn-off window let go there goes back in; `SDLBackend.viewportPointerOverMainContent`). +pub fn pointerOverMainContent() bool { + return dvui.currentWindow().backend.impl.viewportPointerOverMainContent(); +} + +/// Whether the OS is moving `vp`'s window under a held press (`osMoveEnded` says when it lets go). +pub fn osMoving(vp: *const Viewport) bool { + return dvui.currentWindow().backend.impl.viewportOsMoving(vp); } /// Physical pixels from the frame's origin to the first viewport's band, and between bands. From b1b3713cd3c35b8a3eadbec7436b43b4c85cb73d Mon Sep 17 00:00:00 2001 From: foxnne Date: Sun, 18 Oct 2026 19:47:08 -0500 Subject: [PATCH 3/3] backend: a torn-off window going back in fades into the floating window Going back into the main window, a torn-off window snapped into its floating window (the user). Its window now stays over the floating window for 150 ms with its last picture, the pointer passing through it (`viewports.held`), and fades out (`viewports.fade`): the OS's title bar dissolves into dvui's header while the content, the same in both, stays still. --- backend/src/os_windows.zig | 47 ++++++++++++++++++++++++-------------- 1 file changed, 30 insertions(+), 17 deletions(-) diff --git a/backend/src/os_windows.zig b/backend/src/os_windows.zig index ebeb5ffda..766199c9a 100644 --- a/backend/src/os_windows.zig +++ b/backend/src/os_windows.zig @@ -88,12 +88,13 @@ var keyed: ?dvui.Id = null; /// the app's, the window hides and the floating window is there again. Let go over the desktop, the /// window grows to the floating window's size (`Grow`) and is its window from then (`torn`). Let go /// by the OS's move over the main window's content, it is the floating window there again, dvui's -/// header where its title bar was (`beforeFrame`, `closing`). One window all the way: a borderless card that grew and -/// then gave way to a window opened for it jumped as one replaced the other (the user). Only one -/// that can close (`open_flag`, `named`): a window's close clears it. dvui says nothing of a drag, -/// so it is read from what dvui shows: the floating window that has the mouse, its rect moving -/// while its size stays — moved by its header, not resized from an edge. Over another of the app's -/// windows the held pointer already takes it into that window (`SDLBackend.heldPoint`). +/// header where its title bar was, the window fading out over it (`beforeFrame`, `closing`). One +/// window all the way: a borderless card that grew and then gave way to a window opened for it +/// jumped as one replaced the other (the user). Only one that can close (`open_flag`, `named`): a +/// window's close clears it. dvui says nothing of a drag, so it is read from what dvui shows: the +/// floating window that has the mouse, its rect moving while its size stays — moved by its header, +/// not resized from an edge. Over another of the app's windows the held pointer already takes it +/// into that window (`SDLBackend.heldPoint`). const Tear = struct { id: dvui.Id, /// Where on it the pointer took it, from its top left (physical, at its size). @@ -141,13 +142,15 @@ const Grow = struct { }; var grow: ?Grow = null; -/// Torn-off windows gone back into the main window (`beforeFrame`), closed the frame after: the -/// frame that goes back in draws the floating window in the main window where its window was, and -/// its window, over it, shows its last picture till then. Closed at once, neither showed it for a -/// frame; shrunk back through a card of its last picture, its title bar's place became dvui's -/// header's as it went and the window flashed a title, and only went back in once all of it was -/// over the main window (the user). -var closing: [max_outs]?struct { viewport: *viewports.Viewport, target: ?dvui.Texture.Target } = @splat(null); +/// Torn-off windows gone back into the main window (`beforeFrame`), fading out over it: the frame +/// that goes back in draws the floating window in the main window where its window was, and its +/// window, over it with its last picture, the pointer passing through it, fades out over +/// `back_ns`, its title bar into dvui's header, its content still. Closed at once, the window +/// snapped into the floating window (the user); closed before the main window had drawn it, +/// neither showed it for a frame; shrunk back through a card of its last picture, its title bar's +/// place became dvui's header's as it went and the window flashed a title. +var closing: [max_outs]?struct { viewport: *viewports.Viewport, target: ?dvui.Texture.Target, start_ns: i128 } = @splat(null); +const back_ns: i128 = 150 * std.time.ns_per_ms; /// How long the card takes to grow into its window, or a window to shrink back into the main one, /// and the floating window to shrink into the card as it goes past the app's windows. @@ -227,9 +230,15 @@ fn physical(r: dvui.Rect, s: f32) viewports.Rect { pub fn beforeFrame() void { named_n = 0; closable_n = 0; - // The windows gone back in last frame (`closing`): the main window has drawn their floating - // windows by now. + // The windows gone back in (`closing`), fading out over the floating windows the main window + // draws under them, then closed. for (&closing) |*c| if (c.*) |w| { + const k = ease(dvui.currentWindow().frame_time_ns - w.start_ns, back_ns); + if (k < 1) { + viewports.fade(w.viewport, 1 - k); + dvui.refresh(null, @src(), null); + continue; + } viewports.close(w.viewport); if (w.target) |t| t.destroyLater(); c.* = null; @@ -259,11 +268,15 @@ pub fn beforeFrame() void { for (&torn_list) |*ts| if (ts.*) |tt| if (tt.id == o.id) { ts.* = null; }; - // Its window closed next frame, over the floating window this frame draws (`closing`). + // Its window fading out over the floating window this frame draws (`closing`), the + // pointer through it to the floating window under it. const free = for (&closing) |*cl| { if (cl.* == null) break cl; } else null; - if (free) |cl| cl.* = .{ .viewport = o.viewport, .target = o.target } else { + if (free) |cl| { + viewports.held(o.viewport, true); + cl.* = .{ .viewport = o.viewport, .target = o.target, .start_ns = dvui.currentWindow().frame_time_ns }; + } else { viewports.close(o.viewport); if (o.target) |t| t.destroyLater(); }