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
11 changes: 11 additions & 0 deletions font.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) }
2 changes: 1 addition & 1 deletion mvvm_gate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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},
Expand Down
71 changes: 68 additions & 3 deletions toast.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
183 changes: 183 additions & 0 deletions toast_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
package toolkit

import (
"strings"
"testing"

"github.com/go-widgets/painter"
Expand Down Expand Up @@ -854,3 +855,185 @@ 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)
}
}

// 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)
}
}