From 389a113ba988b92becf6f8183eccc389cd76267e Mon Sep 17 00:00:00 2001 From: tannevaled Date: Sun, 6 Sep 2026 23:12:51 +0200 Subject: [PATCH 1/2] Keep a Toast inside its host: Toast.MaxW, and export WrapText A Toast sizes itself to its widest line with no upper bound. A host that docks one to a centre anchor then paints a pill wider than the view, so BOTH ENDS are cut and the reader gets the middle of a sentence -- which is worse than a truncated message, because it does not look truncated. Measured in go-xrkit/desk, on a 1920-wide view at the size it draws its notices (30px ink, 43px body): "screen 3 is back" 329px fits "the camera was refused; it is turned on again in System Settings > Privacy & Security > Camera" 2373px 1.2x the width a longer refusal 4975px 2.6x the width So this is not hypothetical: the middle one already ships, and anybody whose camera is denied has been reading the middle of that sentence. Toast.MaxW, when positive, is the widest the pill may be drawn; Text is wrapped across as many rows as it needs. The zero value keeps the old behaviour exactly -- no wrapping, no measuring, one line -- and Lines still wins, since a caller supplying its own rows has already decided where they break. The wrap is memoised because lines() is asked three times a frame (sizing, height, drawing) and wrapping costs a measure per word. WrapText exports the routine the card widgets already lay their bodies out with. It was private, so a widget outside cardframe.go had to write its own -- and wrap to different rules than the card beside it. MaxW is set-once layout config, so it joins the Toast entry in the MVVM gate's allow-list alongside Lines, Icon and Actions. The tests are proved by sabotage: making wrapWidthFor return 0 makes them fail, naming the overflow ("pill 584px, want at most 300px"). The first sabotage attempt did not apply and the suite stayed green -- checked that the file had actually changed before believing it. Co-Authored-By: Claude Opus 5 --- font.go | 11 +++++ mvvm_gate_test.go | 2 +- toast.go | 71 +++++++++++++++++++++++++++-- toast_test.go | 114 ++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 194 insertions(+), 4 deletions(-) diff --git a/font.go b/font.go index f6b0ace..59ef740 100644 --- a/font.go +++ b/font.go @@ -298,3 +298,14 @@ func (f *bitmapFont) Draw(p painter.Painter, x, y int, text string, ink RGBA) { func putPixel(p painter.Painter, px, py int, ink RGBA) { p.PutPixel(px, py, ink) } + +// WrapText greedily breaks text into lines that each fit within width pixels +// when measured in font f, breaking only at spaces. A single word wider than +// width is placed on its own line rather than split mid-word. Empty or +// all-whitespace text yields no lines. +// +// It is the same routine the card widgets lay their bodies out with, exported +// because every widget that shows a sentence eventually needs it -- and a +// caller that writes its own ends up wrapping to different rules than the card +// beside it. +func WrapText(f Font, text string, width int) []string { return wrapText(f, text, width) } diff --git a/mvvm_gate_test.go b/mvvm_gate_test.go index ceb6cfd..f4b767e 100644 --- a/mvvm_gate_test.go +++ b/mvvm_gate_test.go @@ -105,7 +105,7 @@ var mvvmMigratedWidgets = map[string]map[string]bool{ "TreeView": {"Root": true, "RowHeight": true, "MultiSelect": true, "HideRoot": true, "HideScrollbar": true}, "TreeTable": {"Columns": true, "Root": true}, "ListBox": {"Items": true, "RowHeight": true, "MultiSelect": true, "Reorderable": true, "Sections": true}, - "Toast": {"Text": true, "Kind": true, "ActionLabel": true, "Lines": true, "Actions": true, "Pixels": true, "IW": true, "IH": true, "Icon": true}, + "Toast": {"Text": true, "Kind": true, "ActionLabel": true, "Lines": true, "Actions": true, "Pixels": true, "IW": true, "IH": true, "Icon": true, "MaxW": true}, "Stat": {"Title": true}, "Sparkline": {"Values": true, "Kind": true, "Fill": true, "ShowLast": true}, "Tooltip": {"Text": true, "Placement": true}, diff --git a/toast.go b/toast.go index 4ec4e17..464f494 100644 --- a/toast.go +++ b/toast.go @@ -84,6 +84,36 @@ type Toast struct { // one-line toast is unchanged. Lines []string + // MaxW, when positive, is the widest the pill may be drawn. Text is wrapped + // across as many rows as it needs so the pill fits, instead of growing past + // its host. + // + // ⛔ WITHOUT IT A LONG SENTENCE IS UNREADABLE, not merely untidy. A toast + // sizes itself to its widest line with no upper bound, and a host that + // docks it to a centre anchor then paints a pill wider than the view -- so + // BOTH ENDS are cut and the reader gets the middle of a sentence. Measured + // on a 1920-wide view at the size xrdesk draws its notices: "the camera was + // refused; it is turned on again in System Settings > Privacy & Security > + // Camera" came to 2373px, and a longer refusal to 4975px -- 2.6 times the + // width it had to fit in. + // + // The zero value keeps the old behaviour exactly: no wrapping, no measuring, + // one line. Ignored when Lines is set, since a caller supplying its own rows + // has already decided where they break. + MaxW int + + // wrapped memoises the last wrap, because lines() is asked three times per + // frame (sizing, height, drawing) and wrapping is a measure per word. + // + // ⚠ THE KEY IS (text, width, glyph height) AND NOT THE FONT ITSELF. A Font + // is an interface and comparing one can panic on an uncomparable dynamic + // type. Two different fonts of the SAME height would therefore reuse a wrap + // computed for the other -- a mis-wrap, never a crash, and it corrects + // itself the moment the text or the width changes. + wrapped []string + wrapText string + wrapWidth, wrapGlyph int + // Actions, when non-empty, supplies several action buttons (superseding // the single ActionLabel/Action pair). Buttons are laid out along the // right edge in slice order, each with its own divider + label. The zero @@ -183,13 +213,48 @@ func toastFace(kind ToastKind, theme *Theme) RGBA { } } -// lines returns the message rows: Lines when non-empty, else a single-element -// slice holding Text (the backward-compatible default). +// lines returns the message rows: Lines when non-empty, else Text wrapped to +// MaxW, else a single-element slice holding Text (the backward-compatible +// default, and what a zero MaxW still gives). func (t *Toast) lines() []string { if len(t.Lines) > 0 { return t.Lines } - return []string{t.Text} + inner := t.wrapWidthFor() + if inner <= 0 { + return []string{t.Text} + } + f := t.EffectiveFont() + gh := f.Height() + if t.wrapped != nil && t.wrapText == t.Text && t.wrapWidth == inner && t.wrapGlyph == gh { + return t.wrapped + } + out := wrapText(f, t.Text, inner) + if len(out) == 0 { + // All-whitespace or empty: one empty row, so the pill keeps a height + // and every caller still gets exactly one line as it did before. + out = []string{t.Text} + } + t.wrapped, t.wrapText, t.wrapWidth, t.wrapGlyph = out, t.Text, inner, gh + return out +} + +// wrapWidthFor is how many pixels the message itself may occupy inside a pill +// capped at MaxW: the cap less the pill's own padding, its icon slot and its +// action zone. Zero or less means "do not wrap" -- either MaxW is unset, or the +// furniture already leaves the text no room, and a pill that overflows is still +// better than one with no words in it. +func (t *Toast) wrapWidthFor() int { + if t.MaxW <= 0 { + return 0 + } + inner := t.MaxW - 2*ToastPadX - t.iconSlotW() + if aw := t.actionsW(); aw > 0 { + // AnchorIn adds actionsW()-ToastPadX on top of the two-sided padding, + // so that is exactly what the text loses. + inner -= aw - ToastPadX + } + return inner } // acts returns the action buttons: Actions when non-empty, else a single-element diff --git a/toast_test.go b/toast_test.go index 85827ae..5f8ea2a 100644 --- a/toast_test.go +++ b/toast_test.go @@ -5,6 +5,7 @@ package toolkit import ( + "strings" "testing" "github.com/go-widgets/painter" @@ -854,3 +855,116 @@ func TestToastButtonRectsAgreeWithOnEvent(t *testing.T) { log, tt.Visible().Get()) } } + +// TestToastMaxWKeepsThePillInsideItsHost. +// +// ⛔ THE DEFECT THIS EXISTS FOR. A Toast sizes itself to its widest line with +// no upper bound. A host that docks it to a centre anchor then paints a pill +// wider than the view, so BOTH ENDS are cut and the reader is left with the +// middle of a sentence -- which is worse than a truncated message, because it +// does not look truncated. +// +// Measured in go-xrkit/desk on a 1920-wide view, at the size it draws notices: +// an already-shipped refusal came to 2373px and a longer one to 4975px, 2.6 +// times the width it had to fit in. +func TestToastMaxWKeepsThePillInsideItsHost(t *testing.T) { + const host = 300 + const long = "the camera was refused; it is turned on again in " + + "System Settings > Privacy & Security > Camera" + view := Rect{W: host, H: 200} + + t.Run("without MaxW the pill grows past its host", func(t *testing.T) { + // The control. If this ever passes, either the defect is gone or the + // string is too short to show it -- and the test below proves nothing. + tp := NewToast(long, ToastError) + tp.AnchorIn(view, BottomCenter, 0) + if w := tp.Bounds().W; w <= host { + t.Fatalf("pill is %dpx and the host %dpx: this string no longer overflows, "+ + "so the MaxW case below is not measuring anything", w, host) + } + }) + + t.Run("with MaxW it fits", func(t *testing.T) { + tp := NewToast(long, ToastError) + tp.MaxW = host + tp.AnchorIn(view, BottomCenter, 0) + if w := tp.Bounds().W; w > host { + t.Errorf("pill %dpx, want at most %dpx", w, host) + } + if n := len(tp.lines()); n < 2 { + t.Errorf("%d line(s): a sentence that did not fit must have been broken", n) + } + }) + + t.Run("and says the same words, in order", func(t *testing.T) { + // Wrapping may only move the spaces. A wrap that dropped or reordered + // words would be a wrap that lies about what happened. + tp := NewToast(long, ToastError) + tp.MaxW = host + if got := strings.Join(tp.lines(), " "); got != long { + t.Errorf("wrapping changed the message:\n got %q\nwant %q", got, long) + } + }) + + t.Run("the pill grows taller by exactly the rows it gained", func(t *testing.T) { + tp := NewToast(long, ToastError) + tp.MaxW = host + tp.AnchorIn(view, BottomCenter, 0) + n := len(tp.lines()) + want := n*tp.glyphHeight() + (n-1)*ToastLineGap + 2*ToastPadY + if h := tp.Bounds().H; h != want { + t.Errorf("pill is %dpx tall, want %dpx for %d rows", h, want, n) + } + }) +} + +// TestToastMaxWZeroChangesNothing pins the opt-in: a toast that never sets MaxW +// must size, wrap and draw exactly as it did before MaxW existed. +func TestToastMaxWZeroChangesNothing(t *testing.T) { + const long = "a sentence comfortably wider than any pill anybody would want" + tp := NewToast(long, ToastInfo) + if got := tp.lines(); len(got) != 1 || got[0] != long { + t.Errorf("lines() = %q, want the single unwrapped Text", got) + } + tp.AnchorIn(Rect{W: 50, H: 50}, BottomCenter, 0) + if w, want := tp.Bounds().W, tp.textWidth(long)+2*ToastPadX; w != want { + t.Errorf("pill %dpx, want %dpx -- an unset MaxW must not measure or wrap", w, want) + } +} + +// TestToastLinesBeatMaxW: a caller that supplied its own rows has already +// decided where they break, and MaxW must not second-guess it. +func TestToastLinesBeatMaxW(t *testing.T) { + tp := NewToast("ignored", ToastInfo) + tp.Lines = []string{"a title line that is quite long indeed", "and a body line"} + tp.MaxW = 40 + got := tp.lines() + if len(got) != 2 || got[0] != tp.Lines[0] || got[1] != tp.Lines[1] { + t.Errorf("lines() = %q, want the rows the caller gave", got) + } +} + +// TestToastWrapIsMemoisedAndInvalidated: lines() is asked three times a frame +// (sizing, height, drawing), so it must not re-wrap each time -- and it must +// re-wrap the moment the text or the width changes, or a toast would keep +// showing the previous message's layout. +func TestToastWrapIsMemoisedAndInvalidated(t *testing.T) { + tp := NewToast("one two three four five six seven eight nine ten", ToastInfo) + tp.MaxW = 120 + + first := tp.lines() + if same := tp.lines(); &same[0] != &first[0] { + t.Error("lines() re-wrapped an unchanged toast; the memo is not being used") + } + + tp.Text = "a completely different sentence that also needs several rows" + if got := tp.lines(); strings.Join(got, " ") != tp.Text { + t.Errorf("lines() = %q, still the old message: the memo did not notice the text change", got) + } + + before := len(tp.lines()) + tp.MaxW = 60 + if after := len(tp.lines()); after <= before { + t.Errorf("%d rows at half the width, was %d: the memo did not notice the width change", after, before) + } +} From 3a662064c65c0ce08b44089f7459fd2553dcccfd Mon Sep 17 00:00:00 2001 From: tannevaled Date: Sun, 6 Sep 2026 23:16:15 +0200 Subject: [PATCH 2/2] Cover the three ways the wrap declines to happen The repo's gate is 100% of statements, and the new code left three holes: WrapText was exported but never called from a test, lines() never met an all-whitespace message, and wrapWidthFor never met a toast with an action button to pay for. Co-Authored-By: Claude Opus 5 --- toast_test.go | 69 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) diff --git a/toast_test.go b/toast_test.go index 5f8ea2a..043b177 100644 --- a/toast_test.go +++ b/toast_test.go @@ -968,3 +968,72 @@ func TestToastWrapIsMemoisedAndInvalidated(t *testing.T) { t.Errorf("%d rows at half the width, was %d: the memo did not notice the width change", after, before) } } + +// TestToastMaxWCorners covers the three ways the wrap declines to happen: no +// room left for the text, nothing to wrap, and an action zone that has to be +// paid for out of the same budget. +func TestToastMaxWCorners(t *testing.T) { + t.Run("a cap too small to hold any text does not wrap", func(t *testing.T) { + // Better an overflowing pill than one with no words in it: a wrap to + // zero or fewer pixels would put one letter on each of forty rows. + tp := NewToast("several words that will not fit", ToastInfo) + tp.MaxW = 2 * ToastPadX // exactly the padding: nothing left + if got := tp.lines(); len(got) != 1 || got[0] != tp.Text { + t.Errorf("lines() = %q, want the single unwrapped Text", got) + } + }) + + t.Run("whitespace stays one row", func(t *testing.T) { + // wrapText yields no lines for all-whitespace, and a toast with no rows + // would have no height -- so the pill keeps exactly one. + tp := NewToast(" ", ToastInfo) + tp.MaxW = 200 + if got := tp.lines(); len(got) != 1 || got[0] != " " { + t.Errorf("lines() = %q, want one row holding the original text", got) + } + }) + + t.Run("the action zone is paid for out of the same width", func(t *testing.T) { + const cap = 300 + plain := NewToast("one two three four five six seven eight", ToastInfo) + plain.MaxW = cap + acted := NewToast(plain.Text, ToastInfo) + acted.MaxW = cap + acted.ActionLabel = "Undo" + + // The button takes room the words no longer have, so it must wrap onto + // at least as many rows -- and the pill must STILL fit the cap. + if len(acted.lines()) < len(plain.lines()) { + t.Errorf("%d rows with a button, %d without: the action zone was not charged", + len(acted.lines()), len(plain.lines())) + } + acted.AnchorIn(Rect{W: cap, H: 200}, BottomCenter, 0) + if w := acted.Bounds().W; w > cap { + t.Errorf("pill %dpx with a button, want at most %dpx", w, cap) + } + }) +} + +// TestWrapTextIsTheOneTheCardsUse: the exported wrapper must be the same +// routine, not a second implementation that breaks lines to other rules. +func TestWrapTextIsTheOneTheCardsUse(t *testing.T) { + f := CurrentFont() + const s = "one two three four five six seven eight nine ten" + width := f.Measure("one two three") // room for about three words + + got := WrapText(f, s, width) + if len(got) < 2 { + t.Fatalf("WrapText gave %d line(s) for %q at %dpx; want it broken up", len(got), s, width) + } + if joined := strings.Join(got, " "); joined != s { + t.Errorf("WrapText changed the words:\n got %q\nwant %q", joined, s) + } + for _, ln := range got { + if w := f.Measure(ln); w > width { + t.Errorf("line %q is %dpx, over the %dpx asked for", ln, w, width) + } + } + if got := WrapText(f, " ", width); got != nil { + t.Errorf("WrapText(whitespace) = %q, want no lines", got) + } +}