From 74a90404fa2a01bd3f3bd5fd84f760526d85a9f2 Mon Sep 17 00:00:00 2001 From: Ng Guoyou Date: Mon, 15 Jun 2026 07:58:36 +0800 Subject: [PATCH] fix(layout): hug widgets no longer overflow their container cross axis (0.0.47) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A content-basis (hug) widget floored BOTH axes at the content extent, so a widget in a column got `min-width: max-content` — pinning it to its widest possible content and overflowing its container horizontally (the Network widget bulging past its column in studio, after 0.0.46 made the basis apply). Floor only the MAIN axis in a flex parent (min-width for a row, min-height for a col); the cross axis is already bounded by align-items:stretch. Grid parents keep the 2D floor (no single main axis, no regression report there). Verified live on the overlay + studio (overflowsParent:false, group width == container, vertical hug preserved) and with a new Chromium e2e that also proves the old both-axis behaviour overflowed. Co-Authored-By: Claude Opus 4.8 (1M context) --- Cargo.lock | 2 +- client/e2e/groupHug.spec.ts | 45 +++++++++++++++++++++++++++ client/src/lib/core/flowStyle.test.ts | 35 +++++++++++++++------ client/src/lib/core/flowStyle.ts | 24 ++++++++++---- widgetsack/Cargo.toml | 2 +- widgetsack/tauri.conf.json | 2 +- 6 files changed, 91 insertions(+), 19 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 578dc8b..c101ad9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5130,7 +5130,7 @@ dependencies = [ [[package]] name = "widgetsack" -version = "0.0.46" +version = "0.0.47" dependencies = [ "fontdb", "futures-util", diff --git a/client/e2e/groupHug.spec.ts b/client/e2e/groupHug.spec.ts index d27ec9b..44cd0d1 100644 --- a/client/e2e/groupHug.spec.ts +++ b/client/e2e/groupHug.spec.ts @@ -48,3 +48,48 @@ test('a content-basis group floored at max-content sizes to its FULL content (ra expect(m.rateBottom).toBeLessThanOrEqual(m.grpBottom + 1); expect(m.rateH).toBeGreaterThan(5); }); + +// Cross-axis (horizontal) overflow regression: a content-basis GROUP in a COL hugs along the MAIN +// axis (height) but must NOT be floored on the CROSS axis (width) — flooring width at max-content too +// forces the slot WIDER than its 170px container when the content (two un-wrapping rate texts) is +// wider, the live "Network widget overflows horizontally in studio" report. flowStyle now floors the +// main axis only; this asserts the group stays within its container width. +const crossHtml = (floorWidth: boolean) => ` +
+
+
+
+ ▲ 1234.5 MB/s + ▼ 6789.0 MB/s +
+
+
+
`; + +test('a content-basis group in a col does NOT overflow its container horizontally (cross axis unfloored)', async ({ + page +}) => { + await page.setContent(crossHtml(false)); + const w = await page.evaluate(() => { + const c = document.getElementById('container'); + const g = document.getElementById('grp'); + if (!c || !g) throw new Error('missing test nodes'); + return { containerW: c.getBoundingClientRect().width, grpW: g.getBoundingClientRect().width }; + }); + // Main-axis-only flooring: the group width tracks the 170px container — no horizontal overflow. + expect(w.grpW).toBeLessThanOrEqual(w.containerW + 1); + expect(w.grpW).toBeGreaterThan(160); + + // Sanity: the OLD both-axes behaviour (min-width:max-content) DID overflow, proving this guards a + // real regression and isn't trivially satisfiable. + await page.setContent(crossHtml(true)); + const overflow = await page.evaluate(() => { + const c = document.getElementById('container'); + const g = document.getElementById('grp'); + if (!c || !g) throw new Error('missing test nodes'); + return g.getBoundingClientRect().width > c.getBoundingClientRect().width + 1; + }); + expect(overflow).toBe(true); +}); diff --git a/client/src/lib/core/flowStyle.test.ts b/client/src/lib/core/flowStyle.test.ts index d2db7d6..8e639bc 100644 --- a/client/src/lib/core/flowStyle.test.ts +++ b/client/src/lib/core/flowStyle.test.ts @@ -114,34 +114,49 @@ describe('itemStyle (sizing)', () => { expect(itemStyle({ ...leaf(prim('A', 40, 20)) }, 'row').flexBasis).toBe('40px'); }); - it("basis 'content' on a FILL meter (gauge/gpu/…) → authored box as basis, floored at min-content", () => { - // Keeps the stored box as the default extent (can't collapse to 0), but floors BOTH axes at + it("basis 'content' on a FILL meter (gauge/gpu/…) → authored box as basis, MAIN axis floored at min-content", () => { + // Keeps the stored box as the default extent (can't collapse to 0), and floors the MAIN axis at // min-content so a content-bearing meter (e.g. GPU VRAM) is never squeezed below its content and - // clipped by the slot's overflow:hidden — the "hug clips" fix. + // clipped by the slot's overflow:hidden — the "hug clips" fix. The CROSS axis is NOT floored + // (the parent's align-items:stretch bounds it to the container) — flooring it too overflows the + // container horizontally. const s = itemStyle({ ...leaf(prim('A', 40, 20), 'content') }, 'row'); expect(s).toMatchObject({ flexGrow: 0, flexShrink: 0, flexBasis: '40px', - minWidth: 'min-content', - minHeight: 'min-content' + minWidth: 'min-content' // main axis (row → width) }); - // col → main axis is height, so the basis tracks the stored height. - expect(itemStyle({ ...leaf(prim('A', 40, 20), 'content') }, 'col').flexBasis).toBe('20px'); + expect('minHeight' in s).toBe(false); // cross axis stays unbounded → stretches, no overflow + // col → main axis is height, so the basis (and the floor) tracks height instead. + const sc = itemStyle({ ...leaf(prim('A', 40, 20), 'content') }, 'col'); + expect(sc.flexBasis).toBe('20px'); + expect(sc.minHeight).toBe('min-content'); + expect('minWidth' in sc).toBe(false); }); - it("basis 'content' on a GROUP (dropped template) → group size as basis, floored at MAX-content", () => { + it("basis 'content' on a GROUP (dropped template) → group size as basis, MAIN axis floored at MAX-content", () => { // The Network widget is a GROUP; hugging it must fit its FULL content (a collapsing sub-row // falls out of min-content), so groups floor at max-content — not min-content like fill meters. + // Only the MAIN axis is floored: in a col that's height, so the cross axis (width) stays bounded + // by the container — flooring width too made the Network group overflow horizontally. const g = group('g', { w: 80, h: 120 }, container('c', 'col', [])); const s = itemStyle({ ...leaf(g, 'content') }, 'col'); expect(s).toMatchObject({ flexGrow: 0, flexShrink: 0, flexBasis: '120px', // group size on the main (col) axis - minWidth: 'max-content', - minHeight: 'max-content' + minHeight: 'max-content' // main axis (col → height) }); + expect('minWidth' in s).toBe(false); // cross axis (width) not floored → no horizontal overflow + // row parent → main axis is width, so the floor lands on minWidth instead. + const sr = itemStyle({ ...leaf(g, 'content') }, 'row'); + expect(sr.minWidth).toBe('max-content'); + expect('minHeight' in sr).toBe(false); + // grid parent → no single main axis (2D cell), so BOTH axes keep the content floor. + const sg = itemStyle({ ...leaf(g, 'content') }, 'grid'); + expect(sg.minWidth).toBe('max-content'); + expect(sg.minHeight).toBe('max-content'); }); it("basis 'content' on an INTRINSIC text meter (clock/text) → content-fit (auto basis, shrinkable)", () => { diff --git a/client/src/lib/core/flowStyle.ts b/client/src/lib/core/flowStyle.ts index b4bee21..d9328c1 100644 --- a/client/src/lib/core/flowStyle.ts +++ b/client/src/lib/core/flowStyle.ts @@ -165,10 +165,17 @@ export function itemStyle(node: LayoutNode, parentKind: Container['kind']): Styl } else if (basis === 'content' && isLeaf(node)) { // 'content' on a FILL meter (gauge / sparkline / cpu / GPU panel / …, no intrinsic size) OR a // GROUP (a dropped template / nested layout): keep its authored box as the DEFAULT extent - // (flex-basis = the meter rect / the group size), but FLOOR both axes at the content extent so - // it's never squeezed below its own content and clipped by the slot's overflow:hidden — the - // GPU VRAM / Network "hug clips" reports. The cross axis (stretch) and a shrinking fr column - // otherwise have no such floor. + // (flex-basis = the meter rect / the group size), but FLOOR the MAIN axis at the content extent + // so it's never squeezed below its own content and clipped by the slot's overflow:hidden — the + // GPU VRAM / Network "hug clips" reports. A shrinking fr column otherwise has no such floor. + // + // In a FLEX parent (row/col) floor the MAIN axis ONLY. The cross axis is governed by the parent's + // align-items:stretch and already fills the container; pinning the cross axis to a content floor + // too (min/max-content) forces the slot WIDER than its container when the content is wider than + // the box — the Network widget's "overflows horizontally in studio" report. Main-axis-only + // flooring fits the content along the hug direction while letting the cross axis stay bounded by + // its container. A GRID parent has no single main axis (the item fills a 2D cell), and there's no + // such overflow report there, so it keeps the original 2D floor (both axes). // // The floor differs by kind. A GROUP floors at MAX-content — its full natural content: a // collapsing sub-row (e.g. the Network rate-text row, whose fr text leaves can shrink to 0) @@ -183,8 +190,13 @@ export function itemStyle(node: LayoutNode, parentKind: Container['kind']): Styl const box = isGroup(node.unit) ? node.unit.size : node.unit.rect; s.flexBasis = `${parentKind === 'row' ? box.w : box.h}px`; const floor = isGroup(node.unit) ? 'max-content' : 'min-content'; - s.minWidth = floor; - s.minHeight = floor; + if (parentKind === 'row') s.minWidth = floor; + else if (parentKind === 'col') s.minHeight = floor; + else { + // grid: no single main axis — floor both so a hugged cell fits its content in 2D. + s.minWidth = floor; + s.minHeight = floor; + } } else { // 'auto' / unset: a LEAF takes its STORED main extent (primitive rect / group size) as the // flex-basis, so a fill-meter (width/height:100%, no intrinsic size) keeps the authored box the diff --git a/widgetsack/Cargo.toml b/widgetsack/Cargo.toml index 740c2f5..6d4060b 100644 --- a/widgetsack/Cargo.toml +++ b/widgetsack/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "widgetsack" # Keep in lockstep with tauri.conf.json's version (the bundle/updater source of truth). -version = "0.0.46" +version = "0.0.47" description = "widgetsack desktop widget platform" authors = ["you"] license = "MIT OR Apache-2.0" diff --git a/widgetsack/tauri.conf.json b/widgetsack/tauri.conf.json index 4e07ae3..62bd766 100644 --- a/widgetsack/tauri.conf.json +++ b/widgetsack/tauri.conf.json @@ -50,7 +50,7 @@ "createUpdaterArtifacts": false }, "productName": "widgetsack", - "version": "0.0.46", + "version": "0.0.47", "identifier": "io.github.gyng", "plugins": {}, "app": {