diff --git a/gallery/demos_text.go b/gallery/demos_text.go new file mode 100644 index 0000000..af8d998 --- /dev/null +++ b/gallery/demos_text.go @@ -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]) + } + }) +} diff --git a/gallery/registry.go b/gallery/registry.go index 2dd79f9..bad68f5 100644 --- a/gallery/registry.go +++ b/gallery/registry.go @@ -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 } diff --git a/pixel/canvas.go b/pixel/canvas.go index a606286..328cb34 100644 --- a/pixel/canvas.go +++ b/pixel/canvas.go @@ -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) diff --git a/pixel/canvas_test.go b/pixel/canvas_test.go index 385bae4..bb8dbba 100644 --- a/pixel/canvas_test.go +++ b/pixel/canvas_test.go @@ -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 +} diff --git a/pixel/element.go b/pixel/element.go index d50709b..7407775 100644 --- a/pixel/element.go +++ b/pixel/element.go @@ -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)) } diff --git a/pixel/engine_internal_test.go b/pixel/engine_internal_test.go index c5080ac..a9da4e6 100644 --- a/pixel/engine_internal_test.go +++ b/pixel/engine_internal_test.go @@ -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) } } } diff --git a/test-results/.last-run.json b/test-results/.last-run.json new file mode 100644 index 0000000..5fca3f8 --- /dev/null +++ b/test-results/.last-run.json @@ -0,0 +1,4 @@ +{ + "status": "failed", + "failedTests": [] +} \ No newline at end of file diff --git a/text/blinktext.go b/text/blinktext.go new file mode 100644 index 0000000..53a19ba --- /dev/null +++ b/text/blinktext.go @@ -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) + } + }) +} diff --git a/text/code.go b/text/code.go new file mode 100644 index 0000000..95cf030 --- /dev/null +++ b/text/code.go @@ -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) + }) +} diff --git a/text/gradienttext.go b/text/gradienttext.go new file mode 100644 index 0000000..8b2b1dc --- /dev/null +++ b/text/gradienttext.go @@ -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) + }) +} diff --git a/text/heading.go b/text/heading.go new file mode 100644 index 0000000..fdeaba4 --- /dev/null +++ b/text/heading.go @@ -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) + }) +} diff --git a/text/highlight.go b/text/highlight.go new file mode 100644 index 0000000..b1bb1b1 --- /dev/null +++ b/text/highlight.go @@ -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 +} diff --git a/text/internal_test.go b/text/internal_test.go new file mode 100644 index 0000000..85fc19e --- /dev/null +++ b/text/internal_test.go @@ -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') +} diff --git a/text/kbd.go b/text/kbd.go new file mode 100644 index 0000000..84bac12 --- /dev/null +++ b/text/kbd.go @@ -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) + }) +} diff --git a/text/link.go b/text/link.go new file mode 100644 index 0000000..191e5bc --- /dev/null +++ b/text/link.go @@ -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 +} diff --git a/text/marquee.go b/text/marquee.go new file mode 100644 index 0000000..f02df97 --- /dev/null +++ b/text/marquee.go @@ -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) + }) + }) +} diff --git a/text/microtext.go b/text/microtext.go new file mode 100644 index 0000000..799cd8d --- /dev/null +++ b/text/microtext.go @@ -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) + }) +} diff --git a/text/odometer.go b/text/odometer.go new file mode 100644 index 0000000..f89c065 --- /dev/null +++ b/text/odometer.go @@ -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 +} diff --git a/text/outlinetext.go b/text/outlinetext.go new file mode 100644 index 0000000..58aaba9 --- /dev/null +++ b/text/outlinetext.go @@ -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) + }) +} diff --git a/text/paragraph.go b/text/paragraph.go new file mode 100644 index 0000000..cc0d4c5 --- /dev/null +++ b/text/paragraph.go @@ -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) + } + }) +} diff --git a/text/quote.go b/text/quote.go new file mode 100644 index 0000000..8bb8057 --- /dev/null +++ b/text/quote.go @@ -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}) + } + }) + }) +} diff --git a/text/rainbowtext.go b/text/rainbowtext.go new file mode 100644 index 0000000..576d0ab --- /dev/null +++ b/text/rainbowtext.go @@ -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) + }) + }) +} diff --git a/text/scrambletext.go b/text/scrambletext.go new file mode 100644 index 0000000..15b3a68 --- /dev/null +++ b/text/scrambletext.go @@ -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) + }) + }) +} diff --git a/text/sevensegment.go b/text/sevensegment.go new file mode 100644 index 0000000..10513eb --- /dev/null +++ b/text/sevensegment.go @@ -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< 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 +} diff --git a/text/text_test.go b/text/text_test.go new file mode 100644 index 0000000..e7f9f41 --- /dev/null +++ b/text/text_test.go @@ -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) + } +} diff --git a/text/typewriter.go b/text/typewriter.go new file mode 100644 index 0000000..7481789 --- /dev/null +++ b/text/typewriter.go @@ -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 +} diff --git a/text/wavetext.go b/text/wavetext.go new file mode 100644 index 0000000..79bfc21 --- /dev/null +++ b/text/wavetext.go @@ -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))) +}