Add text components 021-040 with demos and behavior tests

This commit is contained in:
ZacharyZhang-NY committed 2026-10-11 18:20:12 -04:00
1 parent 43098065bf
commit 59fde4096d
30 files changed
+1702 -7

No files matched your search

+210
View File
@@ -0,0 +1,210 @@
package gallery
import (
"fmt"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/text"
)
var textEntries = []Entry{
{21, "Heading", "标题", "text", "Titles at ×3, ×2 and ×1, each with a 1-dot shadow.", demoHeading},
{22, "Paragraph", "段落", "text", "Wrapped text with a line gap, alignment and a line limit that ends in an ellipsis.", demoParagraph},
{23, "ShadowText", "投影字", "text", "Text with a 1-dot drop shadow in Shade.", demoShadowText},
{24, "OutlineText", "描边字", "text", "Every glyph dot ringed in Edge, readable on any background.", demoOutlineText},
{25, "GradientText", "渐变字", "text", "Glyph dots dithered from one slot at the top to another at the bottom.", demoGradientText},
{26, "WaveText", "波浪字", "text", "Characters bob on a sine wave, stepped at 10 fps.", demoWaveText},
{27, "RainbowText", "彩虹字", "text", "Each character takes the next slot of a cycling band: palette cycling.", demoRainbowText},
{28, "Typewriter", "打字机", "text", "Dialogue that types itself out. Click or press Enter to show the rest.", demoTypewriter},
{29, "Marquee", "跑马灯", "text", "Text that scrolls a dot a step through a window. Point at it to pause.", demoMarquee},
{30, "BlinkText", "闪烁字", "text", "Text that blinks, and stays put under Reduce Motion.", demoBlinkText},
{31, "ShakeText", "颤抖字", "text", "Characters jitter a dot in a seeded pattern, for fear and earthquakes.", demoShakeText},
{32, "Kbd", "键帽", "text", "A keycap with depth; pressed, it sinks.", demoKbd},
{33, "Code", "行内代码", "text", "An inline code chip on Well with an Edge border.", demoCode},
{34, "Quote", "引用", "text", "A block quote: an Accent bar, a pixel quote mark, the words and a cite.", demoQuote},
{35, "Link", "链接", "text", "Underlined text that opens a page on click or Enter.", demoLink},
{36, "SevenSegment", "七段数码", "text", "Digits in seven segments, the unlit ones left as ghosts.", demoSevenSegment},
{37, "Odometer", "里程表", "text", "A number whose digit wheels roll to each new value.", demoOdometer},
{38, "MicroText", "微缩字", "text", "Upper-case text in the 3×5 micro font, for tiny labels.", demoMicroText},
{39, "Highlight", "高亮", "text", "Matches of a query marked with a dithered highlighter.", demoHighlight},
{40, "ScrambleText", "解码字", "text", "Text that settles from random glyphs, left to right.", demoScrambleText},
}
func demoHeading(c *ui.Context, log func(string)) {
k := pixel.K(c)
ui.Column(c).Gap(k.D(4)).Children(func() {
text.Heading(c, "LEVEL 1", text.HeadingOptions{Level: 1})
text.Heading(c, "Level 2", text.HeadingOptions{Level: 2, Slot: pixel.Accent})
text.Heading(c, "Level 3 第三级", text.HeadingOptions{Level: 3})
})
}
func demoParagraph(c *ui.Context, log func(string)) {
k := pixel.K(c)
words := "The cartridge slid into the slot with a click. The screen blinked twice, then the music began, and a small knight stepped out of the castle gate."
ui.Column(c).Gap(k.D(10)).Width(k.D(220)).Children(func() {
text.Paragraph(c, words, text.ParagraphOptions{Gap: 2})
text.Paragraph(c, words, text.ParagraphOptions{Align: text.Center, MaxLines: 2, Slot: pixel.Faint})
})
}
func demoShadowText(c *ui.Context, log func(string)) {
k := pixel.K(c)
ui.Column(c).Gap(k.D(4)).Children(func() {
text.ShadowText(c, "SCORE 004200", text.ShadowTextOptions{Slot: pixel.Accent, Scale: 2})
text.ShadowText(c, "Hi-score 最高分", text.ShadowTextOptions{})
})
}
func demoOutlineText(c *ui.Context, log func(string)) {
k := pixel.K(c)
ui.Box(c).Padding(k.D(6)).Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
g.Dither(0, 0, g.W, g.H, pixel.Info, pixel.Good, 8)
}).Children(func() {
text.OutlineText(c, "STAGE CLEAR", text.OutlineTextOptions{Scale: 2})
})
}
func demoGradientText(c *ui.Context, log func(string)) {
k := pixel.K(c)
ui.Column(c).Gap(k.D(4)).Children(func() {
text.GradientText(c, "SUNSET", text.GradientTextOptions{Top: pixel.Warn, Bottom: pixel.Bad, Scale: 3})
text.GradientText(c, "Crystal 水晶", text.GradientTextOptions{Top: pixel.Info, Bottom: pixel.Magic, Scale: 2})
})
}
func demoWaveText(c *ui.Context, log func(string)) {
text.WaveText(c, "~ WELCOME TO THE BEACH ~", text.WaveTextOptions{Slot: pixel.Info, Scale: 2})
}
func demoRainbowText(c *ui.Context, log func(string)) {
text.RainbowText(c, "SUPER PIXEL BROS", text.RainbowTextOptions{Scale: 2})
}
func demoTypewriter(c *ui.Context, log func(string)) {
k := pixel.K(c)
lines := []string{
"Old man: It's dangerous to go alone. Take this cartridge.",
"Old man: Blow on it first. Trust me.",
"Old man: That's all I know. Off you go.",
}
at := ui.Local(c.Root(), demoKey("typewriter"), func() int { return 0 })
ui.Column(c).Gap(k.D(6)).Width(k.D(240)).Children(func() {
r := text.Typewriter(c, lines[*at], text.TypewriterOptions{CPS: 24})
if r.Done {
log(fmt.Sprintf("line %d done", *at+1))
}
if button(c, "NEXT") {
*at = (*at + 1) % len(lines)
log("next line")
}
})
}
func demoMarquee(c *ui.Context, log func(string)) {
text.Marquee(c, "NEW HIGH SCORE! ★ INSERT COIN ★ PRESS START ★", text.MarqueeOptions{Width: 160, Slot: pixel.Accent})
}
func demoBlinkText(c *ui.Context, log func(string)) {
k := pixel.K(c)
ui.Row(c).Gap(k.D(12)).Children(func() {
text.BlinkText(c, "PRESS START", text.BlinkTextOptions{Scale: 2})
text.BlinkText(c, "LOW HP", text.BlinkTextOptions{Slot: pixel.Bad, FPS: 6})
})
}
func demoShakeText(c *ui.Context, log func(string)) {
text.ShakeText(c, "IT'S RIGHT BEHIND YOU", text.ShakeTextOptions{Slot: pixel.Bad, Scale: 2, Seed: 7})
}
func demoKbd(c *ui.Context, log func(string)) {
k := pixel.K(c)
ui.Row(c).Gap(k.D(4)).AlignItems(ui.End).Children(func() {
for _, key := range []string{"↑", "↑", "↓", "↓", "←", "→", "←", "→", "B", "A"} {
text.Kbd(c, key, text.KbdOptions{})
}
text.Kbd(c, "START", text.KbdOptions{Pressed: true})
})
}
func demoCode(c *ui.Context, log func(string)) {
k := pixel.K(c)
ui.Row(c).Gap(k.D(4)).AlignItems(ui.Center).Children(func() {
label(c, "Call", pixel.Ink)
text.Code(c, "pixel.Use(c, s)", text.CodeOptions{})
label(c, "once per frame.", pixel.Ink)
})
}
func demoQuote(c *ui.Context, log func(string)) {
k := pixel.K(c)
ui.Box(c).Width(k.D(240)).Children(func() {
text.Quote(c, "It's dangerous to go alone! Take this.", text.QuoteOptions{Cite: "An old man in a cave"})
})
}
func demoLink(c *ui.Context, log func(string)) {
k := pixel.K(c)
ui.Row(c).Gap(k.D(12)).Children(func() {
if text.Link(c, "MyGo docs", "https://mygo.egoist.dev/docs/ui", text.LinkOptions{}).Clicked {
log("opened MyGo docs")
}
text.Link(c, "Lost link", "https://example.com", text.LinkOptions{Disabled: true})
})
}
func demoSevenSegment(c *ui.Context, log func(string)) {
k := pixel.K(c)
ui.Column(c).Gap(k.D(6)).Children(func() {
text.SevenSegment(c, "12:59", text.SevenSegmentOptions{Scale: 2})
text.SevenSegment(c, "-0042", text.SevenSegmentOptions{Slot: pixel.Good})
})
}
func demoOdometer(c *ui.Context, log func(string)) {
k := pixel.K(c)
score := ui.Local(c.Root(), demoKey("odometer"), func() int { return 1990 })
ui.Row(c).Gap(k.D(8)).AlignItems(ui.Center).Children(func() {
text.Odometer(c, *score, text.OdometerOptions{Digits: 6})
if button(c, "+15") {
*score += 15
log(fmt.Sprintf("score %d", *score))
}
if button(c, "+500") {
*score += 500
log(fmt.Sprintf("score %d", *score))
}
})
}
func demoMicroText(c *ui.Context, log func(string)) {
k := pixel.K(c)
ui.Column(c).Gap(k.D(4)).Children(func() {
text.MicroText(c, "hp 32/40 mp 12/18", text.MicroTextOptions{})
text.MicroText(c, "lv 99", text.MicroTextOptions{Slot: pixel.Accent, Scale: 3})
})
}
func demoHighlight(c *ui.Context, log func(string)) {
k := pixel.K(c)
ui.Column(c).Gap(k.D(4)).Children(func() {
text.Highlight(c, "Slime, King Slime, Metal Slime", "slime", text.HighlightOptions{})
text.Highlight(c, "Potion, Hi-Potion, Ether", "potion", text.HighlightOptions{Mark: pixel.Info})
})
}
func demoScrambleText(c *ui.Context, log func(string)) {
k := pixel.K(c)
words := []string{"ACCESS GRANTED", "DECRYPTING...", "WELCOME, PLAYER 1"}
at := ui.Local(c.Root(), demoKey("scramble"), func() int { return 0 })
ui.Column(c).Gap(k.D(6)).Children(func() {
text.ScrambleText(c, words[*at], text.ScrambleTextOptions{Slot: pixel.Good, Scale: 2})
if button(c, "AGAIN") {
*at = (*at + 1) % len(words)
log("scramble " + words[*at])
}
})
}
+1 -1
View File
@@ -56,7 +56,7 @@ var Categories = []Category{
{"tabletop", "Tabletop", "桌游", icon.Crown},
}
var entries = slices.Concat(foundationEntries, layoutEntries)
var entries = slices.Concat(foundationEntries, layoutEntries, textEntries)
func Entries() []Entry { return entries }
+24
View File
@@ -221,6 +221,30 @@ func (g Canvas) Text(x, y int, s string, ink Slot, scale int) {
}
}
// TextBlend draws s, glyph dots dithered from top to bottom.
func (g Canvas) TextBlend(x, y int, s string, top, bottom Slot, scale int) {
checkScale(scale)
span := LineHeight * scale
for _, r := range s {
gl := glyphOf(r)
gx := x + gl.x*scale
for j := range gl.h * scale {
yy := y + gl.top()*scale + j
level := min(16, (yy-y)*17/span)
row := bayer[(g.gy+yy)&3]
for _, part := range [2]struct {
s Slot
down bool
}{{top, false}, {bottom, true}} {
runs(gl.w*scale, func(i int) bool {
return gl.on(i/scale, j/scale) && (row[(g.gx+gx+i)&3] < level) == part.down
}, func(a, b int) { g.Rect(gx+a, yy, b-a, 1, part.s) })
}
}
x += gl.adv * scale
}
}
// Micro draws s in the 3×5 micro font.
func (g Canvas) Micro(x, y int, s string, ink Slot, scale int) {
checkScale(scale)
+29
View File
@@ -239,3 +239,32 @@ func TestTextAndMicroDraw(t *testing.T) {
}
}
}
func TestTextBlend(t *testing.T) {
plain := canvas(t, 40, 20, func(g pixel.Canvas) { g.Text(2, 2, "HM", pixel.Ink, 1) })
blend := canvas(t, 40, 20, func(g pixel.Canvas) { g.TextBlend(2, 2, "HM", pixel.Accent, pixel.Good, 1) })
tops, bottoms := 0, 0
for y := range 20 {
for x := range 40 {
ink, got := plain.at(x, y) == pixel.Ink, blend.at(x, y)
if ink != (got == pixel.Accent || got == pixel.Good) {
t.Fatalf("dot %d,%d: text %v, blend %v", x, y, ink, got)
}
if got == pixel.Accent && y > 2+pixel.LineHeight*3/4 || got == pixel.Good && y < 2+pixel.LineHeight/4 {
t.Fatalf("dot %d,%d has %v on the wrong end", x, y, got)
}
tops += boolInt(got == pixel.Accent)
bottoms += boolInt(got == pixel.Good)
}
}
if tops == 0 || bottoms == 0 {
t.Fatalf("blend used %d top and %d bottom dots", tops, bottoms)
}
}
func boolInt(b bool) int {
if b {
return 1
}
return 0
}
+3 -3
View File
@@ -25,7 +25,7 @@ func Text(c *ui.Context, s string, o TextOptions) *ui.Element {
if o.Wrap {
width, check = WrapWidth(c, e)
}
lines := textLines(s, width/scale, o.MaxLines)
lines := Wrap(s, width/scale, o.MaxLines)
if o.Wrap {
e.Height(k.D(len(lines) * LineHeight * scale))
} else {
@@ -44,8 +44,8 @@ func Text(c *ui.Context, s string, o TextOptions) *ui.Element {
})
}
// textLines wraps s, cutting to maxLines with an ellipsis.
func textLines(s string, width, maxLines int) []string {
// Wrap wraps s to width; past maxLines, an ellipsis.
func Wrap(s string, width, maxLines int) []string {
if maxLines < 0 {
panic(fmt.Sprintf("pixel: MaxLines %d", maxLines))
}
+3 -3
View File
@@ -24,7 +24,7 @@ func TestRuns(t *testing.T) {
}
}
func TestTextLinesEllipsis(t *testing.T) {
func TestWrapEllipsis(t *testing.T) {
cases := []struct {
s string
width, n int
@@ -37,8 +37,8 @@ func TestTextLinesEllipsis(t *testing.T) {
{"像素风格组件库", 36, 2, []string{"像素风", "格组…"}},
}
for _, c := range cases {
if got := textLines(c.s, c.width, c.n); !slices.Equal(got, c.want) {
t.Errorf("textLines(%q, %d, %d) = %q, want %q", c.s, c.width, c.n, got, c.want)
if got := Wrap(c.s, c.width, c.n); !slices.Equal(got, c.want) {
t.Errorf("Wrap(%q, %d, %d) = %q, want %q", c.s, c.width, c.n, got, c.want)
}
}
}
+4
View File
@@ -0,0 +1,4 @@
{
"status": "failed",
"failedTests": []
}
+25
View File
@@ -0,0 +1,25 @@
package text
import (
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
type BlinkTextOptions struct {
Slot pixel.Slot
Scale int
// FPS is blinks per second, times two; zero means 2.
FPS int
}
// BlinkText blinks; under Reduce Motion it stays shown.
func BlinkText(c *ui.Context, s string, o BlinkTextOptions) *ui.Element {
k, sc := pixel.K(c), scale(o.Scale)
fps := orDef("BlinkText FPS", o.FPS, 2)
return block(c, k, s, pixel.Measure(s)*sc, pixel.LineHeight*sc, func(g pixel.Canvas) {
if g.Step(fps)%2 == 0 {
g.Text(0, 0, s, o.Slot, sc)
}
})
}
+20
View File
@@ -0,0 +1,20 @@
package text
import (
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
type CodeOptions struct {
Slot pixel.Slot
}
// Code is an inline chip on Well, bordered in Edge.
func Code(c *ui.Context, s string, o CodeOptions) *ui.Element {
k := pixel.K(c)
return block(c, k, s, pixel.Measure(s)+6, pixel.LineHeight+2, func(g pixel.Canvas) {
g.Frame(0, 0, g.W, g.H, pixel.Flat, pixel.Well)
g.Text(3, 1, s, o.Slot, 1)
})
}
+22
View File
@@ -0,0 +1,22 @@
package text
import (
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
type GradientTextOptions struct {
// Top is zero for AccentLit; Bottom is zero for Accent.
Top, Bottom pixel.Slot
Scale int
}
// GradientText dithers glyph dots from Top down to Bottom.
func GradientText(c *ui.Context, s string, o GradientTextOptions) *ui.Element {
k, sc := pixel.K(c), scale(o.Scale)
top, bottom := slotOr(o.Top, pixel.AccentLit), slotOr(o.Bottom, pixel.Accent)
return block(c, k, s, pixel.Measure(s)*sc, pixel.LineHeight*sc, func(g pixel.Canvas) {
g.TextBlend(0, 0, s, top, bottom, sc)
})
}
+28
View File
@@ -0,0 +1,28 @@
package text
import (
"fmt"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
type HeadingOptions struct {
// Level 1–3 draws at ×3, ×2, ×1; zero means 1.
Level int
Slot pixel.Slot
}
// Heading is a title with a 1-dot Shade shadow.
func Heading(c *ui.Context, s string, o HeadingOptions) *ui.Element {
level := orDef("Heading Level", o.Level, 1)
if level > 3 {
panic(fmt.Sprintf("text: Heading Level %d outside 1–3", level))
}
k, sc := pixel.K(c), 4-level
return block(c, k, s, pixel.Measure(s)*sc+1, pixel.LineHeight*sc+1, func(g pixel.Canvas) {
g.Text(1, 1, s, pixel.Shade, sc)
g.Text(0, 0, s, o.Slot, sc)
})
}
+54
View File
@@ -0,0 +1,54 @@
package text
import (
"unicode"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
type HighlightOptions struct {
Slot pixel.Slot
// Mark is the marker slot; zero means Warn.
Mark pixel.Slot
}
// Highlight marks each match of query in s, ignoring case.
func Highlight(c *ui.Context, s, query string, o HighlightOptions) *ui.Element {
k := pixel.K(c)
mark := slotOr(o.Mark, pixel.Warn)
rs := []rune(s)
spans := matches(rs, []rune(query))
return block(c, k, s, pixel.Measure(s), pixel.LineHeight, func(g pixel.Canvas) {
for _, sp := range spans {
x0, x1 := pixel.Measure(string(rs[:sp[0]])), pixel.Measure(string(rs[:sp[1]]))
g.Veil(x0, 2, x1-x0, pixel.LineHeight-4, mark, 10)
}
g.Text(0, 0, s, o.Slot, 1)
})
}
// matches are the rune spans of q in s.
func matches(s, q []rune) [][2]int {
var out [][2]int
if len(q) == 0 {
return nil
}
for i := 0; i+len(q) <= len(s); {
same := true
for j := range q {
if unicode.ToLower(s[i+j]) != unicode.ToLower(q[j]) {
same = false
break
}
}
if same {
out = append(out, [2]int{i, i + len(q)})
i += len(q)
} else {
i++
}
}
return out
}
+82
View File
@@ -0,0 +1,82 @@
package text
import (
"reflect"
"testing"
)
func TestWaveAndShake(t *testing.T) {
want := []int{0, 1, 2, 1, 0, -1, -2, -1}
for n, w := range want {
if got := wave(2, n); got != w {
t.Fatalf("wave(2, %d) = %d, want %d", n, got, w)
}
}
moved := false
for step := range 20 {
dx, dy := shake(false, 7, 3, step)
if dx < -1 || dx > 1 || dy < -1 || dy > 1 {
t.Fatalf("shake %d,%d outside one dot", dx, dy)
}
moved = moved || dx != 0 || dy != 0
if rx, ry := shake(true, 7, 3, step); rx != 0 || ry != 0 {
t.Fatal("shake moved under Reduce Motion")
}
}
if !moved {
t.Fatal("shake never moved")
}
if a, b := noise(1, 2, 3), noise(1, 2, 3); a != b {
t.Fatal("noise is not seeded")
}
}
func TestRollAndWheel(t *testing.T) {
v, steps := 0.0, 0
for v != 100 {
v = roll(v, 100)
if steps++; steps > 100 {
t.Fatal("roll never reached the target")
}
}
if got := roll(0, 100); got != 100.0/6 {
t.Fatalf("first roll step %v", got)
}
for _, tc := range []struct {
shown float64
i, d int
frac float64
}{{129.5, 0, 9, 0.5}, {129.5, 1, 2, 0.5}, {129.5, 2, 1, 0}, {125, 1, 2, 0}} {
if d, frac := wheel(tc.shown, tc.i); d != tc.d || frac != tc.frac {
t.Errorf("wheel(%v, %d) = %d %v, want %d %v", tc.shown, tc.i, d, frac, tc.d, tc.frac)
}
}
}
func TestMatches(t *testing.T) {
for _, tc := range []struct {
s, q string
want [][2]int
}{
{"Slime, King Slime", "slime", [][2]int{{0, 5}, {12, 17}}},
{"aaa", "aa", [][2]int{{0, 2}}},
{"水晶水晶", "晶", [][2]int{{1, 2}, {3, 4}}},
{"abc", "", nil},
} {
if got := matches([]rune(tc.s), []rune(tc.q)); !reflect.DeepEqual(got, tc.want) {
t.Errorf("matches(%q, %q) = %v, want %v", tc.s, tc.q, got, tc.want)
}
}
}
func TestCellWidths(t *testing.T) {
if cellW('8') != 7 || cellW(':') != 3 || cellW('-') != 7 {
t.Fatal("cell widths")
}
defer func() {
if recover() == nil {
t.Fatal("SevenSegment took an x")
}
}()
cellW('x')
}
+26
View File
@@ -0,0 +1,26 @@
package text
import (
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
type KbdOptions struct {
Pressed bool
}
// Kbd is a keycap with depth; Pressed sinks it.
func Kbd(c *ui.Context, label string, o KbdOptions) *ui.Element {
k := pixel.K(c)
face := pixel.LineHeight + 2
return block(c, k, label, pixel.Measure(label)+10, face+3, func(g pixel.Canvas) {
top := 0
if o.Pressed {
top = 2
}
g.Frame(0, 2, g.W, face+1, pixel.Flat, pixel.Shade)
g.Frame(0, top, g.W, face, pixel.Raised, pixel.Face)
g.Text(5, top+1, label, pixel.Ink, 1)
})
}
+52
View File
@@ -0,0 +1,52 @@
package text
import (
"log/slog"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
type LinkOptions struct {
Disabled bool
}
type LinkResult struct {
Element *ui.Element
Clicked bool
}
// Link is underlined text opening url on click or Enter.
func Link(c *ui.Context, label, url string, o LinkOptions) LinkResult {
k := pixel.K(c)
shell, base, st := pixel.Shell(c, func() *ui.Element {
return ui.Box(c).Focusable().Role(ui.RoleLink).Label(label).Description(url).Disabled(o.Disabled).Cursor(ui.CursorPointer)
})
w := pixel.Measure(label)
shell.Size(k.D(w+2), k.D(pixel.LineHeight)).Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
ink := pixel.Info
switch {
case st.Disabled:
ink = pixel.Faint
case st.Press:
g.Rect(0, 0, g.W, g.H, pixel.Shade)
case st.Hover:
g.Rect(0, 0, g.W, g.H, pixel.Lit)
}
g.Text(1, 0, label, ink, 1)
if !st.Disabled {
g.HLine(1, pixel.LineHeight-2, w, ink)
}
if st.Focus {
g.FocusMark(0, 0, g.W, g.H)
}
})
res := LinkResult{Element: shell, Clicked: base.Clicked()}
if res.Clicked {
slog.Debug("8bit: link open", "url", url)
c.OpenURL(url)
}
return res
}
+46
View File
@@ -0,0 +1,46 @@
package text
import (
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
type MarqueeOptions struct {
// Width is the window in dots; zero means 120.
Width int
Slot pixel.Slot
}
type marqueeKey struct{}
type marqueeState struct{ offset, last int }
// Marquee scrolls s a dot a step; hover pauses it.
func Marquee(c *ui.Context, s string, o MarqueeOptions) *ui.Element {
k := pixel.K(c)
w := orDef("Marquee Width", o.Width, 120)
e := ui.Box(c).Size(k.D(w), k.D(pixel.LineHeight)).Clip().Label(s).Role(ui.RoleText)
ms := ui.Local(e, marqueeKey{}, func() marqueeState { return marqueeState{last: -1} })
if k.Reduce() || e.Hovered() {
ms.last = -1
} else {
step := k.Tick(c, 20)
if ms.last >= 0 {
ms.offset += step - ms.last
}
ms.last = step
}
period := w + pixel.Measure(s)
x := -(ms.offset % period)
if k.Reduce() {
x = 0
}
return e.Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
g.Clip(0, 0, w, pixel.LineHeight, func() {
g.Text(x, 0, s, o.Slot, 1)
g.Text(x+period, 0, s, o.Slot, 1)
})
})
}
+23
View File
@@ -0,0 +1,23 @@
package text
import (
"strings"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
type MicroTextOptions struct {
Slot pixel.Slot
Scale int
}
// MicroText draws s upper-cased in micro font; unknown runes panic.
func MicroText(c *ui.Context, s string, o MicroTextOptions) *ui.Element {
k, sc := pixel.K(c), scale(o.Scale)
up := strings.ToUpper(s)
return block(c, k, s, pixel.MicroWidth(up)*sc, pixel.MicroHeight*sc, func(g pixel.Canvas) {
g.Micro(0, 0, up, o.Slot, sc)
})
}
+89
View File
@@ -0,0 +1,89 @@
package text
import (
"fmt"
"math"
"strconv"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
type OdometerOptions struct {
// Digits is zero for as many as value has.
Digits int
Slot pixel.Slot
}
type odoKey struct{}
type odoState struct {
shown float64
last int
}
const odoCell = 10
// Odometer rolls its digits toward value, step by step.
func Odometer(c *ui.Context, value int, o OdometerOptions) *ui.Element {
if value < 0 {
panic(fmt.Sprintf("text: Odometer value %d is negative", value))
}
k := pixel.K(c)
digits := orDef("Odometer Digits", o.Digits, len(strconv.Itoa(value)))
if value >= int(math.Pow10(digits)) {
panic(fmt.Sprintf("text: Odometer value %d needs more than %d digits", value, digits))
}
h := pixel.LineHeight + 2
e := ui.Box(c).Size(k.D(digits*odoCell), k.D(h)).Label(strconv.Itoa(value)).Role(ui.RoleText)
st := ui.Local(e, odoKey{}, func() odoState { return odoState{shown: float64(value), last: -1} })
target := float64(value)
switch {
case k.Reduce() || st.shown == target:
st.shown, st.last = target, -1
default:
tick := k.Tick(c, 30)
if st.last >= 0 {
for range tick - st.last {
st.shown = roll(st.shown, target)
}
}
st.last = tick
}
shown := st.shown
return e.Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
for i := range digits {
x := (digits - 1 - i) * odoCell
d, frac := wheel(shown, i)
g.Frame(x, 0, odoCell-1, h, pixel.Sunken, pixel.Well)
g.Clip(x+2, 2, odoCell-5, h-4, func() {
y := 1 - int(frac*float64(h))
g.Text(x+2, y, strconv.Itoa(d), o.Slot, 1)
g.Text(x+2, y+h, strconv.Itoa((d+1)%10), o.Slot, 1)
})
}
})
}
// roll moves shown a step toward target, faster when far.
func roll(shown, target float64) float64 {
diff := target - shown
stride := max(0.25, math.Abs(diff)/6)
if math.Abs(diff) <= stride {
return target
}
return shown + math.Copysign(stride, diff)
}
// wheel is digit i of shown and its roll.
func wheel(shown float64, i int) (d int, frac float64) {
place := math.Pow10(i)
v := shown / place
d = int(math.Floor(v)) % 10
if lower := math.Mod(shown, place); lower > place-1 {
frac = lower - (place - 1)
}
return d, frac
}
+27
View File
@@ -0,0 +1,27 @@
package text
import (
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
type OutlineTextOptions struct {
// Slot is the fill; zero means Lit.
Slot pixel.Slot
Scale int
}
// OutlineText rings every glyph dot of s with Edge.
func OutlineText(c *ui.Context, s string, o OutlineTextOptions) *ui.Element {
k, sc := pixel.K(c), scale(o.Scale)
fill := slotOr(o.Slot, pixel.Lit)
return block(c, k, s, pixel.Measure(s)*sc+2, pixel.LineHeight*sc+2, func(g pixel.Canvas) {
for dy := -1; dy <= 1; dy++ {
for dx := -1; dx <= 1; dx++ {
g.Text(1+dx, 1+dy, s, pixel.Edge, sc)
}
}
g.Text(1, 1, s, fill, sc)
})
}
+52
View File
@@ -0,0 +1,52 @@
package text
import (
"fmt"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
type Align uint8
const (
Left Align = iota
Center
Right
)
type ParagraphOptions struct {
// Gap is extra dots between lines.
Gap int
Align Align
MaxLines int
Slot pixel.Slot
}
// Paragraph wraps s at the width it had last frame.
func Paragraph(c *ui.Context, s string, o ParagraphOptions) *ui.Element {
k := pixel.K(c)
gap := orDef("Paragraph Gap", o.Gap, 0)
if o.Align > Right {
panic(fmt.Sprintf("text: unknown Align %d", o.Align))
}
e := ui.Box(c).FillWidth().Label(s).Role(ui.RoleText)
w, check := pixel.WrapWidth(c, e)
lines := pixel.Wrap(s, w, o.MaxLines)
step := pixel.LineHeight + gap
return e.Height(k.D(max(0, len(lines)*step-gap))).Draw(func(p *ui.Painter, r ui.Rect) {
check(r)
g := k.Canvas(p, r)
for i, l := range lines {
x := 0
switch o.Align {
case Center:
x = (g.W - pixel.Measure(l)) / 2
case Right:
x = g.W - pixel.Measure(l)
}
g.Text(x, i*step, l, o.Slot, 1)
}
})
}
+38
View File
@@ -0,0 +1,38 @@
package text
import (
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
var quoteMark = pixel.MustSprite(`
.##..##.
#...#...
##..##..
##..##..
`)
type QuoteOptions struct {
Cite string
}
// Quote is a block quote with bar, mark and cite.
func Quote(c *ui.Context, s string, o QuoteOptions) *ui.Element {
k := pixel.K(c)
return ui.Row(c).Gap(k.D(6)).AlignItems(ui.Stretch).Role(ui.RoleGroup).Children(func() {
ui.Box(c).Width(k.D(2)).Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
g.Rect(0, 0, g.W, g.H, pixel.Accent)
})
ui.Column(c).Grow(1).Gap(k.D(2)).Children(func() {
ui.Box(c).Size(k.D(quoteMark.W), k.D(quoteMark.H)).Draw(func(p *ui.Painter, r ui.Rect) {
k.Canvas(p, r).Sprite(0, 0, quoteMark, 0, pixel.Accent, 1)
})
Paragraph(c, s, ParagraphOptions{})
if o.Cite != "" {
pixel.Text(c, "— "+o.Cite, pixel.TextOptions{Slot: pixel.Faint})
}
})
})
}
+30
View File
@@ -0,0 +1,30 @@
package text
import (
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
var rainbow = []pixel.Slot{pixel.Bad, pixel.Warn, pixel.Good, pixel.Info, pixel.Magic}
type RainbowTextOptions struct {
// Band is zero for Bad, Warn, Good, Info, Magic.
Band []pixel.Slot
Scale int
}
// RainbowText gives each character the next slot of a band.
func RainbowText(c *ui.Context, s string, o RainbowTextOptions) *ui.Element {
k, sc := pixel.K(c), scale(o.Scale)
band := o.Band
if len(band) == 0 {
band = rainbow
}
return block(c, k, s, pixel.Measure(s)*sc, pixel.LineHeight*sc, func(g pixel.Canvas) {
cur := pixel.Cycle(band, g.Step(8))
each(s, sc, func(i int, r rune, x int) {
g.Text(x, 0, string(r), cur[i%len(cur)], sc)
})
})
}
+53
View File
@@ -0,0 +1,53 @@
package text
import (
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
const scrambleRunes = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789#$%&*+?"
type ScrambleTextOptions struct {
Slot pixel.Slot
Scale int
// FPS is zero for 30; two steps settle a character.
FPS int
}
type scrambleKey struct{}
type scrambleState struct {
text string
start, step int
}
// ScrambleText settles from random glyphs to s, left to right.
func ScrambleText(c *ui.Context, s string, o ScrambleTextOptions) *ui.Element {
k, sc := pixel.K(c), scale(o.Scale)
fps := orDef("ScrambleText FPS", o.FPS, 30)
rs := []rune(s)
e := ui.Box(c).Size(k.D(pixel.Measure(s)*sc), k.D(pixel.LineHeight*sc)).Label(s).Role(ui.RoleText)
st := ui.Local(e, scrambleKey{}, func() scrambleState { return scrambleState{start: -1} })
if st.text != s {
*st = scrambleState{text: s, start: -1}
}
settled := len(rs)
if !k.Reduce() && (st.start < 0 || (st.step-st.start)/2 < len(rs)) {
st.step = k.Tick(c, fps)
if st.start < 0 {
st.start = st.step
}
settled = min(len(rs), (st.step-st.start)/2)
}
step := st.step
return e.Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
each(s, sc, func(i int, ch rune, x int) {
if i >= settled && ch != ' ' {
ch = rune(scrambleRunes[noise(len(rs), i, step)%uint32(len(scrambleRunes))])
}
g.Text(x, 0, string(ch), o.Slot, sc)
})
})
}
+63
View File
@@ -0,0 +1,63 @@
package text
import (
"fmt"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
// segments lists a..g as rects in a 7×11 cell.
var segments = [7][4]int{{1, 0, 5, 1}, {6, 1, 1, 4}, {6, 6, 1, 4}, {1, 10, 5, 1}, {0, 6, 1, 4}, {0, 1, 1, 4}, {1, 5, 5, 1}}
// lit holds each rune's segments, bit 0 for a.
var lit = map[rune]uint8{
'0': 0x3F, '1': 0x06, '2': 0x5B, '3': 0x4F, '4': 0x66, '5': 0x6D,
'6': 0x7D, '7': 0x07, '8': 0x7F, '9': 0x6F, '-': 0x40, ' ': 0,
}
type SevenSegmentOptions struct {
Scale int
// Slot lights segments; zero means Bad.
Slot pixel.Slot
}
// SevenSegment shows digits, minus and colon with ghost segments.
func SevenSegment(c *ui.Context, s string, o SevenSegmentOptions) *ui.Element {
k, sc := pixel.K(c), scale(o.Scale)
ink := slotOr(o.Slot, pixel.Bad)
w := 0
for _, r := range s {
w += cellW(r) + 2
}
return block(c, k, s, max(0, w-2)*sc, 11*sc, func(g pixel.Canvas) {
x := 0
for _, r := range s {
if r == ':' {
g.Rect(x+sc, 3*sc, sc, sc, ink)
g.Rect(x+sc, 7*sc, sc, sc, ink)
} else {
for i, sg := range segments {
if lit[r]&(1<<i) != 0 {
g.Rect(x+sg[0]*sc, sg[1]*sc, sg[2]*sc, sg[3]*sc, ink)
} else {
g.Checker(x+sg[0]*sc, sg[1]*sc, sg[2]*sc, sg[3]*sc, pixel.Faint)
}
}
}
x += (cellW(r) + 2) * sc
}
})
}
// cellW is a rune's cell width; other runes panic.
func cellW(r rune) int {
if r == ':' {
return 3
}
if _, ok := lit[r]; !ok {
panic(fmt.Sprintf("text: SevenSegment cannot show %q", r))
}
return 7
}
+21
View File
@@ -0,0 +1,21 @@
package text
import (
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
type ShadowTextOptions struct {
Slot pixel.Slot
Scale int
}
// ShadowText drops a 1-dot Shade shadow behind s.
func ShadowText(c *ui.Context, s string, o ShadowTextOptions) *ui.Element {
k, sc := pixel.K(c), scale(o.Scale)
return block(c, k, s, pixel.Measure(s)*sc+1, pixel.LineHeight*sc+1, func(g pixel.Canvas) {
g.Text(1, 1, s, pixel.Shade, sc)
g.Text(0, 0, s, o.Slot, sc)
})
}
+34
View File
@@ -0,0 +1,34 @@
package text
import (
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
type ShakeTextOptions struct {
Slot pixel.Slot
Scale int
Seed int
}
// ShakeText jitters each character a dot, seeded, at 12 fps.
func ShakeText(c *ui.Context, s string, o ShakeTextOptions) *ui.Element {
k, sc := pixel.K(c), scale(o.Scale)
return block(c, k, s, pixel.Measure(s)*sc+2, pixel.LineHeight*sc+2, func(g pixel.Canvas) {
step := g.Step(12)
each(s, sc, func(i int, r rune, x int) {
dx, dy := shake(k.Reduce(), o.Seed, i, step)
g.Text(1+x+dx, 1+dy, string(r), o.Slot, sc)
})
})
}
// shake is a −1…1 offset, or none under Reduce Motion.
func shake(reduce bool, seed, i, step int) (dx, dy int) {
if reduce {
return 0, 0
}
n := noise(seed, i, step)
return int(n%3) - 1, int(n/3%3) - 1
}
+63
View File
@@ -0,0 +1,63 @@
// Package text draws words in the pixel font.
package text
import (
"fmt"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
// block is a w×h dot text box that fn draws.
func block(c *ui.Context, k pixel.Kit, s string, w, h int, fn func(g pixel.Canvas)) *ui.Element {
return ui.Box(c).Size(k.D(w), k.D(h)).Label(s).Role(ui.RoleText).Draw(func(p *ui.Painter, r ui.Rect) {
fn(k.Canvas(p, r))
})
}
// orDef is v, or def for zero; negative panics.
func orDef(name string, v, def int) int {
if v < 0 {
panic(fmt.Sprintf("text: %s %d is negative", name, v))
}
if v == 0 {
return def
}
return v
}
// scale is s, or 1 for zero; outside 1–4 panics.
func scale(s int) int {
s = orDef("Scale", s, 1)
if s > 4 {
panic(fmt.Sprintf("text: Scale %d outside 1–4", s))
}
return s
}
// slotOr is s, or def for the zero slot.
func slotOr(s, def pixel.Slot) pixel.Slot {
if s == pixel.Ink {
return def
}
return s
}
// each walks s rune by rune, with x in dots.
func each(s string, sc int, fn func(i int, r rune, x int)) {
i, x := 0, 0
for _, r := range s {
fn(i, r, x)
x += pixel.Measure(string(r)) * sc
i++
}
}
// noise is a seeded hash for jitter and scrambles.
func noise(seed, i, step int) uint32 {
x := uint32(seed*73856093 ^ i*19349663 ^ step*83492791)
x ^= x >> 13
x *= 0x5bd1e995
return x ^ x>>15
}
+446
View File
@@ -0,0 +1,446 @@
package text_test
import (
"image"
"slices"
"testing"
"time"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/text"
)
type clock struct{ t time.Time }
func (c *clock) now() time.Time { return c.t }
func (c *clock) add(d time.Duration) { c.t = c.t.Add(d) }
func view(w, h int, clk *clock, build func(c *ui.Context)) *ui.Tester {
return ui.NewTester(func(c *ui.Context) {
pixel.Use(c, pixel.Settings{Palette: pixel.SpringDay, Now: clk.now})
build(c)
}, w, h)
}
func newClock() *clock { return &clock{time.Unix(1_000_000, 0)} }
// slot is the palette slot of dot x, y.
func slot(t *testing.T, img *image.RGBA, x, y int) pixel.Slot {
t.Helper()
c := img.RGBAAt(2*x, 2*y)
for s, pc := range pixel.SpringDay.Colors {
if pc.R == c.R && pc.G == c.G && pc.B == c.B {
return pixel.Slot(s)
}
}
t.Fatalf("dot %d,%d off the palette: %v", x, y, c)
return 0
}
// count is how many dots in a box hold s.
func count(t *testing.T, img *image.RGBA, x, y, w, h int, s pixel.Slot) int {
n := 0
for j := range h {
for i := range w {
if slot(t, img, x+i, y+j) == s {
n++
}
}
}
return n
}
func find(t *testing.T, tt *ui.Tester, label string) ui.Rect {
t.Helper()
r, ok := tt.Find(label)
if !ok {
t.Fatalf("no %q in %q", label, tt.Texts())
}
return r
}
func TestHeadingLevels(t *testing.T) {
tt := view(600, 300, newClock(), func(c *ui.Context) {
ui.Column(c).Children(func() {
text.Heading(c, "BIG", text.HeadingOptions{})
text.Heading(c, "small", text.HeadingOptions{Level: 3})
})
})
if r := find(t, tt, "BIG"); r.W != float32((pixel.Measure("BIG")*3+1)*2) || r.H != float32((pixel.LineHeight*3+1)*2) {
t.Fatalf("level 1 box %v", r)
}
if r := find(t, tt, "small"); r.H != float32((pixel.LineHeight+1)*2) {
t.Fatalf("level 3 box %v", r)
}
defer func() {
if recover() == nil {
t.Fatal("level 4 did not panic")
}
}()
view(100, 100, newClock(), func(c *ui.Context) { text.Heading(c, "x", text.HeadingOptions{Level: 4}) })
}
func TestParagraphAlignAndLimit(t *testing.T) {
words := "one two three four five six seven eight nine ten"
tt := view(400, 300, newClock(), func(c *ui.Context) {
ui.Column(c).Width(200).Children(func() {
text.Paragraph(c, words, text.ParagraphOptions{MaxLines: 1})
text.Paragraph(c, "hi", text.ParagraphOptions{Align: text.Center})
text.Paragraph(c, "hi", text.ParagraphOptions{Align: text.Right})
})
})
img := tt.Image()
if r := find(t, tt, words); r.H != pixel.LineHeight*2 {
t.Fatalf("MaxLines 1 paragraph is %v DIPs tall", r.H)
}
left := func(row int) int {
for x := range 100 {
for y := row; y < row+pixel.LineHeight; y++ {
if slot(t, img, x, y) == pixel.Ink {
return x
}
}
}
return -1
}
w := pixel.Measure("hi")
if c, r := left(16), left(32); c < (100-w)/2 || c > (100-w)/2+2 || r < 100-w || r > 100-w+2 {
t.Fatalf("centered ink at %d, right ink at %d, for width %d", c, r, w)
}
}
func TestTypewriterGlyphs(t *testing.T) {
clk := newClock()
s, done := "AAAA\nBBBB", 0
tt := view(200, 100, clk, func(c *ui.Context) {
ui.Box(c).Width(float32(2 * (4*pixel.Measure("A") + 8))).Children(func() {
if text.Typewriter(c, s, text.TypewriterOptions{CPS: 10}).Done {
done++
}
})
})
w := 4 * pixel.Measure("A")
ink := func(y int) int { return count(t, tt.Image(), 0, y, w, pixel.LineHeight, pixel.Ink) }
clk.add(400 * time.Millisecond)
tt.Frame()
if ink(0) == 0 || ink(pixel.LineHeight) != 0 || done != 0 {
t.Fatalf("at 4 of 8 glyphs: line inks %d, %d, done %d", ink(0), ink(pixel.LineHeight), done)
}
clk.add(300 * time.Millisecond)
tt.Frame()
if ink(pixel.LineHeight) == 0 || done != 0 {
t.Fatalf("at 7 of 8 glyphs: second line ink %d, done %d", ink(pixel.LineHeight), done)
}
clk.add(100 * time.Millisecond)
tt.Frame()
if done != 1 {
t.Fatal("the eighth glyph did not finish the text")
}
// A box-wide line would push the mark out.
s = "AAAAA"
tt.Frame()
if err := tt.Click(s); err != nil {
t.Fatal(err)
}
img := tt.Image()
if count(t, img, 0, 0, w+8, 3*pixel.LineHeight, pixel.Accent) == 0 {
clk.add(500 * time.Millisecond)
tt.Frame()
img = tt.Image()
}
if count(t, img, 0, 0, w+8, 3*pixel.LineHeight, pixel.Accent) == 0 {
t.Fatal("the done mark never showed")
}
if n := count(t, img, w+8, 0, 20, 3*pixel.LineHeight, pixel.Accent); n != 0 {
t.Fatalf("the done mark left the box: %d dots outside", n)
}
}
func TestTypewriter(t *testing.T) {
clk := newClock()
s, done := "ABCDEFGHIJ", 0
opts := text.TypewriterOptions{CPS: 10}
tt := view(400, 200, clk, func(c *ui.Context) {
if text.Typewriter(c, s, opts).Done {
done++
}
})
clk.add(500 * time.Millisecond)
tt.Frame()
if done != 0 {
t.Fatal("half the time showed the whole text")
}
clk.add(600 * time.Millisecond)
tt.Frame()
if done != 1 {
t.Fatalf("after the whole time done fired %d times", done)
}
s = "NEW LINE"
tt.Frame()
if err := tt.Click(s); err != nil {
t.Fatal(err)
}
if done != 2 {
t.Fatal("a click did not complete a new line")
}
s = "THIRD"
tt.Frame()
tt.Key(0, ui.KeyEnter)
if done != 3 {
t.Fatal("Enter did not complete the line")
}
s, opts.Disabled = "FOURTH", true
tt.Frame()
_ = tt.Click(s)
if done != 3 {
t.Fatal("a disabled typewriter completed on a click")
}
s, opts.Disabled = "FIFTH", false
tt.SetPreferences(ui.Preferences{ReduceMotion: true})
if done != 4 {
t.Fatal("Reduce Motion did not show the whole line at once")
}
}
func TestMarqueeScrollsAndPauses(t *testing.T) {
clk := newClock()
tt := view(400, 100, clk, func(c *ui.Context) {
text.Marquee(c, "HELLO", text.MarqueeOptions{Width: 60})
})
// last is the rightmost ink column inside the window.
last := func() int {
img := tt.Image()
if count(t, img, 60, 0, 140, pixel.LineHeight, pixel.Ink) != 0 {
t.Fatal("the marquee drew outside its window")
}
for x := 59; x >= 0; x-- {
if count(t, img, x, 0, 1, pixel.LineHeight, pixel.Ink) > 0 {
return x
}
}
return -1
}
r0 := last()
clk.add(500 * time.Millisecond)
tt.Frame()
r1 := last()
tt.Move(10, 10)
clk.add(500 * time.Millisecond)
tt.Frame()
r2 := last()
tt.Move(300, 90)
tt.Frame()
clk.add(250 * time.Millisecond)
tt.Frame()
if r3 := last(); r0 < 0 || r0-r1 != 10 || r2 != r1 || r1-r3 != 5 {
t.Fatalf("ends at %d, %d (10 steps), %d (hovered), %d (5 more)", r0, r1, r2, r3)
}
tt.SetPreferences(ui.Preferences{ReduceMotion: true})
clk.add(time.Second)
tt.Frame()
if last() != r0 {
t.Fatal("under Reduce Motion the text is not shown at rest")
}
}
func TestBlinkUnderReduceMotion(t *testing.T) {
clk := newClock()
tt := view(200, 100, clk, func(c *ui.Context) { text.BlinkText(c, "ON", text.BlinkTextOptions{}) })
ink := func() int { return count(t, tt.Image(), 0, 0, 30, pixel.LineHeight, pixel.Ink) }
if ink() == 0 {
t.Fatal("step 0 hid the text")
}
clk.add(500 * time.Millisecond)
tt.Frame()
if ink() != 0 {
t.Fatal("step 1 showed the text")
}
tt.SetPreferences(ui.Preferences{ReduceMotion: true})
if ink() == 0 {
t.Fatal("Reduce Motion hid the text")
}
}
func TestLinkOpens(t *testing.T) {
opts := text.LinkOptions{}
clicks := 0
tt := view(300, 100, newClock(), func(c *ui.Context) {
if text.Link(c, "Docs", "https://example.com/docs", opts).Clicked {
clicks++
}
})
if err := tt.Click("Docs"); err != nil {
t.Fatal(err)
}
tt.Key(0, ui.KeyEnter)
if clicks != 2 || len(tt.OpenedURLs()) != 2 || tt.OpenedURLs()[0] != "https://example.com/docs" {
t.Fatalf("clicks %d, opened %q", clicks, tt.OpenedURLs())
}
opts.Disabled = true
tt.Frame()
_ = tt.Click("Docs")
if len(tt.OpenedURLs()) != 2 {
t.Fatal("a disabled link opened")
}
}
func TestSevenSegmentLightsSegments(t *testing.T) {
tt := view(200, 100, newClock(), func(c *ui.Context) {
text.SevenSegment(c, "1", text.SevenSegmentOptions{})
})
img := tt.Image()
if count(t, img, 6, 1, 1, 4, pixel.Bad) != 4 || count(t, img, 6, 6, 1, 4, pixel.Bad) != 4 {
t.Fatal("1 did not light b and c")
}
if count(t, img, 1, 0, 5, 1, pixel.Bad) != 0 || count(t, img, 1, 0, 5, 1, pixel.Faint) == 0 {
t.Fatal("1 lit a, or left no ghost")
}
}
func TestOdometerAndMicroPanics(t *testing.T) {
for name, build := range map[string]func(c *ui.Context){
"negative": func(c *ui.Context) { text.Odometer(c, -1, text.OdometerOptions{}) },
"overflow": func(c *ui.Context) { text.Odometer(c, 1000, text.OdometerOptions{Digits: 3}) },
"micro rune": func(c *ui.Context) { text.MicroText(c, "é", text.MicroTextOptions{}) },
"big scale": func(c *ui.Context) { text.ShadowText(c, "x", text.ShadowTextOptions{Scale: 5}) },
"seven rune": func(c *ui.Context) { text.SevenSegment(c, "x", text.SevenSegmentOptions{}) },
"paragraph 9": func(c *ui.Context) { text.Paragraph(c, "x", text.ParagraphOptions{Align: 9}) },
} {
func() {
defer func() {
if recover() == nil {
t.Errorf("%s did not panic", name)
}
}()
view(200, 100, newClock(), build)
}()
}
}
func TestOdometerRollsToValue(t *testing.T) {
clk := newClock()
value := 5
tt := view(300, 100, clk, func(c *ui.Context) { text.Odometer(c, value, text.OdometerOptions{Digits: 2}) })
digits := func() []pixel.Slot {
img := tt.Image()
var out []pixel.Slot
for y := range pixel.LineHeight + 2 {
for x := range 20 {
out = append(out, slot(t, img, x, y))
}
}
return out
}
five := digits()
value = 6
tt.Frame()
clk.add(34 * time.Millisecond)
tt.Frame()
mid := digits()
clk.add(time.Second)
tt.Frame()
six := digits()
if slices.Equal(six, five) || slices.Equal(mid, five) || slices.Equal(mid, six) {
t.Fatal("the odometer did not roll from 5 through a step to 6")
}
}
func TestScrambleSettles(t *testing.T) {
clk := newClock()
tt := view(300, 100, clk, func(c *ui.Context) {
ui.Column(c).Children(func() {
text.ScrambleText(c, "AB", text.ScrambleTextOptions{})
pixel.Text(c, "AB", pixel.TextOptions{})
})
})
same := func() bool {
img := tt.Image()
for y := range pixel.LineHeight {
for x := range pixel.Measure("AB") {
if slot(t, img, x, y) != slot(t, img, x, y+pixel.LineHeight) {
return false
}
}
}
return true
}
if same() {
t.Fatal("scramble started settled")
}
clk.add(200 * time.Millisecond)
tt.Frame()
if !same() {
t.Fatal("after 6 steps the text had not settled")
}
}
func TestKbdOutlineRainbow(t *testing.T) {
tt := view(400, 200, newClock(), func(c *ui.Context) {
ui.Column(c).AlignItems(ui.Start).Children(func() {
ui.Row(c).Children(func() {
text.Kbd(c, "A", text.KbdOptions{})
text.Kbd(c, "B", text.KbdOptions{Pressed: true})
})
text.OutlineText(c, "I", text.OutlineTextOptions{})
text.RainbowText(c, "AB", text.RainbowTextOptions{})
})
})
img := tt.Image()
a, b := find(t, tt, "A"), find(t, tt, "B")
if slot(t, img, int(a.X/2)+4, 0) != pixel.Edge || slot(t, img, int(b.X/2)+4, 0) == pixel.Edge {
t.Fatal("the pressed key did not sink")
}
o := find(t, tt, "I")
ox, oy := int(o.X/2), int(o.Y/2)
for y := range int(o.H / 2) {
for x := range int(o.W / 2) {
if slot(t, img, ox+x, oy+y) != pixel.Lit {
continue
}
for _, d := range [][2]int{{-1, 0}, {1, 0}, {0, -1}, {0, 1}} {
if n := slot(t, img, ox+x+d[0], oy+y+d[1]); n != pixel.Lit && n != pixel.Edge {
t.Fatalf("outline gap beside %d,%d", x, y)
}
}
}
}
r := find(t, tt, "AB")
wa := pixel.Measure("A")
if count(t, img, int(r.X/2), int(r.Y/2), wa, pixel.LineHeight, pixel.Bad) == 0 || count(t, img, int(r.X/2)+wa, int(r.Y/2), pixel.Measure("B"), pixel.LineHeight, pixel.Warn) == 0 {
t.Fatal("rainbow did not give A Bad and B Warn")
}
}
func TestTypewriterStaysDoneAcrossWraps(t *testing.T) {
clk := newClock()
width, done := float32(80), 0
tt := view(400, 200, clk, func(c *ui.Context) {
ui.Box(c).Width(width).Children(func() {
if text.Typewriter(c, "AAAA BBBB", text.TypewriterOptions{}).Done {
done++
}
})
pixel.Text(c, "AAAA BBBB", pixel.TextOptions{})
})
_ = tt.Click("AAAA BBBB")
if r, _ := tt.Find("AAAA BBBB"); done != 1 || r.H != 2*pixel.LineHeight*2 {
t.Fatalf("narrow: done %d, box %v", done, r)
}
width = 200
tt.Frame()
img := tt.Image()
w := pixel.Measure("AAAA BBBB")
for y := range pixel.LineHeight {
for x := range w {
if slot(t, img, x, y) != slot(t, img, x, pixel.LineHeight+y) {
t.Fatalf("after widening, dot %d,%d differs from the full text", x, y)
}
}
}
if done != 1 {
t.Fatalf("done fired %d times", done)
}
}
+101
View File
@@ -0,0 +1,101 @@
package text
import (
"log/slog"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
var more = pixel.MustSprite(`
#####
.###.
..#..
`)
type TypewriterOptions struct {
// CPS is characters per second; zero means 20.
CPS int
Slot pixel.Slot
Disabled bool
}
type TypewriterResult struct {
Element *ui.Element
// Done is true in the frame the whole text shows.
Done bool
}
type typeKey struct{}
type typeState struct {
text string
start, last int
done bool
}
// Typewriter reveals s; a click or Enter shows the rest.
func Typewriter(c *ui.Context, s string, o TypewriterOptions) TypewriterResult {
k := pixel.K(c)
cps := orDef("Typewriter CPS", o.CPS, 20)
shell, base, st := pixel.Shell(c, func() *ui.Element {
e := ui.Box(c).Role(ui.RoleText).Label(s).Disabled(o.Disabled)
if !o.Disabled {
e.Focusable()
}
return e
})
ts := ui.Local(shell, typeKey{}, func() typeState { return typeState{start: -1} })
if ts.text != s {
*ts = typeState{text: s, start: -1}
}
// Lines leave room for the mark; glyphs count, not runes.
w, check := pixel.WrapWidth(c, shell)
lines := pixel.Wrap(s, max(0, w-8), 0)
total := 0
for _, l := range lines {
total += len([]rune(l))
}
res := TypewriterResult{Element: shell}
if !ts.done {
ts.last = total
if !k.Reduce() {
tick := k.Tick(c, cps)
if ts.start < 0 {
ts.start = tick
}
ts.last = min(total, tick-ts.start)
}
if base.Clicked() {
ts.last = total
}
if ts.last == total {
ts.done, res.Done = true, true
slog.Debug("8bit: typewriter done", "glyphs", total)
}
}
shown := ts.last
if ts.done {
shown = total
}
shell.FillWidth().Height(k.D(len(lines) * pixel.LineHeight)).Draw(func(p *ui.Painter, r ui.Rect) {
check(r)
g := k.Canvas(p, r)
left, end := shown, 0
for i, l := range lines {
lr := []rune(l)
n := min(len(lr), max(0, left))
g.Text(0, i*pixel.LineHeight, string(lr[:n]), o.Slot, 1)
left -= len(lr)
end = pixel.Measure(string(lr[:n]))
}
if shown == total && g.Step(2)%2 == 0 {
g.Sprite(end+3, len(lines)*pixel.LineHeight-6, more, 0, pixel.Accent, 1)
}
if st.Focus {
g.FocusMark(0, 0, g.W, g.H)
}
})
return res
}
+33
View File
@@ -0,0 +1,33 @@
package text
import (
"math"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
type WaveTextOptions struct {
Slot pixel.Slot
Scale int
// Amp is the bob in dots; zero means 2.
Amp int
}
// WaveText bobs characters on a sine wave at 10 fps.
func WaveText(c *ui.Context, s string, o WaveTextOptions) *ui.Element {
k, sc := pixel.K(c), scale(o.Scale)
amp := orDef("WaveText Amp", o.Amp, 2)
return block(c, k, s, pixel.Measure(s)*sc, pixel.LineHeight*sc+2*amp, func(g pixel.Canvas) {
step := g.Step(10)
each(s, sc, func(i int, r rune, x int) {
g.Text(x, amp+wave(amp, step+i), string(r), o.Slot, sc)
})
})
}
// wave is the bob at step n of an 8-cycle.
func wave(amp, n int) int {
return int(math.Round(float64(amp) * math.Sin(float64(n%8)*math.Pi/4)))
}