Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

45 changes: 45 additions & 0 deletions client/e2e/groupHug.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => `
<div id="container" style="width:170px; height:600px; display:flex; flex-direction:column; align-items:stretch;">
<div id="grp" style="flex:0 0 104px; min-height:max-content; ${
floorWidth ? 'min-width:max-content;' : ''
} display:flex; flex-direction:column; overflow:hidden; align-items:stretch;">
<div style="width:100%; height:100%; display:flex; flex-direction:column; align-items:stretch; overflow:hidden;">
<div id="rate" style="flex:0 0 auto; display:flex; flex-direction:row; gap:6px; white-space:nowrap;">
<span style="flex:1 1 0; min-width:0;">▲ 1234.5 MB/s</span>
<span style="flex:1 1 0; min-width:0;">▼ 6789.0 MB/s</span>
</div>
</div>
</div>
</div>`;

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);
});
35 changes: 25 additions & 10 deletions client/src/lib/core/flowStyle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)", () => {
Expand Down
24 changes: 18 additions & 6 deletions client/src/lib/core/flowStyle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion widgetsack/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
2 changes: 1 addition & 1 deletion widgetsack/tauri.conf.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@
"createUpdaterArtifacts": false
},
"productName": "widgetsack",
"version": "0.0.46",
"version": "0.0.47",
"identifier": "io.github.gyng",
"plugins": {},
"app": {
Expand Down