Add text components 021-040 with demos and behavior tests
This commit is contained in:
30 files changed
+1702
-7
No files matched your search
@@ -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
@@ -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 }
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"status": "failed",
|
||||
"failedTests": []
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)))
|
||||
}
|
||||
Reference in new issue
Block a user