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": {