From 32b5e42a652d50ecc9d8b37905fc4d446042ccd5 Mon Sep 17 00:00:00 2001 From: Ethan Reynolds Date: Thu, 1 Oct 2026 13:29:11 -0400 Subject: [PATCH 1/2] fix(#198): a label within a sixty-fourth of a pixel of its box is drawn whole a box sized to exactly its label, as a tab or a fit-width button builds it, is formed from absolute coordinates, and far from the left edge their rounding leaves it a hair narrower than the label, which fit_box then cut with an ellipsis. the comparison is of float pixel widths, so it now allows FIT_SLACK, a sixty-fourth of a pixel. the test draws labels in boxes sized to them at offsets from 900 to over 5000 and fails without the fix. --- src/text.mach | 46 +++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 45 insertions(+), 1 deletion(-) diff --git a/src/text.mach b/src/text.mach index 4c0f66b..71cf631 100644 --- a/src/text.mach +++ b/src/text.mach @@ -198,6 +198,11 @@ fun cut_at(ctx: *context.Context, s: str, n: usize, x: f32, y: f32, w: f32, mode ret px - x; } +# how far a label may overrun its box and still count as fitting: a box sized to +# its label is built from absolute coordinates, whose rounding far from the left +# edge leaves it a hair short, and a sixty-fourth of a pixel never shows +val FIT_SLACK: f32 = 0.015625; + # draw the first n bytes of a line of text with its line box's top-left at # (x, y), as it is when it fits the box's span, else as the kind's overflow # says: clipped to the box, or cut with an ellipsis at its end to fit the @@ -218,7 +223,7 @@ fun cut_at(ctx: *context.Context, s: str, n: usize, x: f32, y: f32, w: f32, mode pub fun fit_box(ctx: *context.Context, kind: u32, s: str, n: usize, x: f32, y: f32, b: context.Area, c: draw.Color) { if (s == nil || n == 0) { ret; } val w: f32 = maxf(b.x1 - b.x0, 0.0::f32); - if (context.text_width_n(ctx, s, n) <= w) { + if (context.text_width_n(ctx, s, n) <= w + FIT_SLACK) { context.text_span(ctx, s, n, x, y, c); ret; } @@ -338,6 +343,45 @@ pub fun spans_at(ctx: *context.Context, sp: *Span, n: usize, x: f32, y: f32) f32 ret px - x; } +test text__a_label_sized_to_its_box_is_whole_anywhere { + var a: allo.Allocator; + val made: err[allo.Error] = page.make(?a); + if (sel made.err) { ret 1; } + var ctx: context.Context; + val ie: err[allo.Error] = context.init(?ctx, ?a); + if (sel ie.err) { ret 1; } + var in: input.Input; + context.begin(?ctx, in, 6000.0::f32, 100.0::f32); + val s: str = "Behavior"; + val n: usize = str_len(s); + val lw: f32 = context.text_width(?ctx, s); + val white: draw.Color = draw.rgba(1.0::f32, 1.0::f32, 1.0::f32, 1.0::f32); + # boxes sized to the label as a tab or button builds them, from absolute + # coordinates far from the left edge with paddings off the pixel grid; each + # must draw the same glyphs as the label drawn whole + var k: u32 = 0; + for (k < 600) { + val x: f32 = 900.0::f32 + k::f32 * 7.31::f32; + val pad: f32 = 1.0::f32 + (k % 37)::f32 * 0.173::f32; + val b: context.Area = context.Area{x0: x + pad, y0: 0.0::f32, x1: (x + (pad * 2.0::f32 + lw)) - pad, y1: 20.0::f32}; + val ref: usize = context.draw_count(?ctx); + context.text_span(?ctx, s, n, x + pad, 0.0::f32, white); + val at: usize = context.draw_count(?ctx); + fit_box(?ctx, 0, s, n, x + pad, 0.0::f32, b, white); + if (context.draw_count(?ctx) - at != at - ref) { ret 2; } + val p: *draw.Vert = context.draw_verts(?ctx); + var i: usize = 0; + for (i < at - ref) { + if (p[at + i].u != p[ref + i].u || p[at + i].v != p[ref + i].v) { ret 3; } + i = i + 1; + } + k = k + 1; + } + val freed: err[allo.Error] = context.free(?ctx); + if (sel freed.err) { ret 1; } + ret 0; +} + test text__cuts_and_spans { var a: allo.Allocator; val made: err[allo.Error] = page.make(?a); From 4479987e0c1dd4f8160a90173711aad516007de6 Mon Sep 17 00:00:00 2001 From: Ethan Reynolds Date: Thu, 1 Oct 2026 13:33:13 -0400 Subject: [PATCH 2/2] fix(#198): a tab's room on the strip includes its separator a tab's body is drawn short of the separator after it (the tab style's margin.r), but the strip measured and laid tabs out by their body width alone, so every unshrunk tab's body came out one separator narrower than its label and fit_box cut the label with an ellipsis. the strip now measures, scrolls and places tabs by their room, the body and the separator, read once into the bar's geometry. the test checks each roomy tab is drawn its whole natural width, and fails with the old layout. --- src/tabs.mach | 55 ++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 48 insertions(+), 7 deletions(-) diff --git a/src/tabs.mach b/src/tabs.mach index 5316ac5..d2fc753 100644 --- a/src/tabs.mach +++ b/src/tabs.mach @@ -151,6 +151,7 @@ pub rec Event { rec Geom { rh: f32; pad: f32; + sep: f32; lh: f32; x0: f32; x1: f32; @@ -366,6 +367,12 @@ fun width(ctx: *context.Context, g: *Geom, t: *Tab) f32 { ret lo + (natural(ctx, g, t) - lo) * g.fit; } +# the room a tab takes on the strip this frame: its body and the separator +# after it, which the body is drawn short of, so the body keeps its whole width +fun room(ctx: *context.Context, g: *Geom, t: *Tab) f32 { + ret width(ctx, g, t) + g.sep; +} + # the furthest the strip scrolls fun most(g: *Geom) f32 { ret maxf(g.total - (g.x1 - g.x0), 0.0::f32); @@ -376,14 +383,15 @@ fun measure(ctx: *context.Context, tabs: *Tab, n: usize) Geom { var g: Geom; val ts: *theme.Style = context.look_of(ctx, theme.TAB); g.pad = context.px(ctx, ts.inset.l); + g.sep = context.px(ctx, ts.margin.r); g.lh = context.line_height(ctx); g.rh = maxf(context.px(ctx, ts.min_h), g.lh + context.px(ctx, ts.inset.t + ts.inset.b)); var nat: f32 = 0.0::f32; var lo: f32 = 0.0::f32; var i: usize = 0; for (i < n) { - nat = nat + natural(ctx, ?g, ?tabs[i]); - lo = lo + least(ctx, ?g, ?tabs[i]); + nat = nat + natural(ctx, ?g, ?tabs[i]) + g.sep; + lo = lo + least(ctx, ?g, ?tabs[i]) + g.sep; i = i + 1; } val r: context.Area = widget.place(ctx, layout.fill(1.0::f32), layout.fit(), nat, g.rh); @@ -404,7 +412,7 @@ fun measure(ctx: *context.Context, tabs: *Tab, n: usize) Geom { g.total = 0.0::f32; i = 0; for (i < n) { - g.total = g.total + width(ctx, ?g, ?tabs[i]); + g.total = g.total + room(ctx, ?g, ?tabs[i]); i = i + 1; } ret g; @@ -415,7 +423,7 @@ fun reveal(ctx: *context.Context, g: *Geom, s: *State, tabs: *Tab, n: usize) { var x: f32 = 0.0::f32; var i: usize = 0; for (i < n) { - val w: f32 = width(ctx, g, ?tabs[i]); + val w: f32 = room(ctx, g, ?tabs[i]); if (i == s.index) { val view: f32 = g.x1 - g.x0; if (x < s.scroll) { s.scroll = x; } @@ -435,7 +443,7 @@ fun step(ctx: *context.Context, g: *Geom, s: *State, tabs: *Tab, n: usize, right var i: usize = 0; val eps: f32 = 0.5::f32; for (i < n) { - val w: f32 = width(ctx, g, ?tabs[i]); + val w: f32 = room(ctx, g, ?tabs[i]); if (right && x + w > s.scroll + view + eps) { ret x + w - view; } if (!right && x < s.scroll - eps) { to = x; } x = x + w; @@ -585,7 +593,7 @@ pub fun bar(ctx: *context.Context, key: str, tabs: *Tab, n: usize) Event { var over: interact.Hit; var slot: usize = n; var mark: f32 = g.x0 + g.total - s.scroll; - val seps: f32 = context.px(ctx, context.look_of(ctx, theme.TAB).margin.r); + val seps: f32 = g.sep; var x: f32 = g.x0 - s.scroll; var i: usize = 0; val grp: interact.Group = interact.begin_group(ctx, id, interact.ROW); @@ -594,7 +602,7 @@ pub fun bar(ctx: *context.Context, key: str, tabs: *Tab, n: usize) Event { for (i < n) { val t: *Tab = ?tabs[i]; val x0: f32 = x; - val x1: f32 = x + width(ctx, ?g, t); + val x1: f32 = x + room(ctx, ?g, t); x = x1; val tid: u64 = tab_id(id, t.key); val h: interact.Hit = interact.hit(ctx, tid, x0, g.y0, x1 - seps, g.y1, input.BUTTON_LEFT | input.BUTTON_MIDDLE | interact.FOCUS); @@ -754,6 +762,39 @@ fun keys(a: *App) u64 { ret k; } +test bar__a_tab_with_room_is_drawn_its_whole_width { + var al: allo.Allocator; + val made: err[allo.Error] = page.make(?al); + if (sel made.err) { ret 1; } + var a: App; + var d: driver.Driver; + if (!app(?d, ?al, ?a, 6, 1600.0::f32)) { ret 1; } + a.tabs[1].closable = false; + a.tabs[4].closable = false; + driver.step(?d); + driver.step(?d); + val ts: *theme.Style = context.look_of(?d.ctx, theme.TAB); + var g: Geom; + g.pad = context.px(?d.ctx, ts.inset.l); + g.sep = context.px(?d.ctx, ts.margin.r); + g.lh = context.line_height(?d.ctx); + g.fit = 1.0::f32; + if (g.sep <= 0.0::f32) { ret 2; } + # each tab's drawn rect, short of its separator, is its whole natural width, + # so its label box is exactly its label + val bid: u64 = context.id_of(?d.ctx, "tabs"); + var i: usize = 0; + for (i < a.n) { + val r: opt[context.Area] = driver.rect(?d, tab_id(bid, a.tabs[i].key)); + if (sel r.none) { ret 3; } + if (r.some.x1 - r.some.x0 != natural(?d.ctx, ?g, ?a.tabs[i])) { ret 4; } + i = i + 1; + } + val f: err[allo.Error] = driver.free(?d); + if (sel f.err) { ret 1; } + ret 0; +} + test bar__unshrunk_tabs_are_their_natural_width { var al: allo.Allocator; val made: err[allo.Error] = page.make(?al);