M example/kitchen/main.go => example/kitchen/main.go +3 -2
@@ 23,6 23,7 @@ import (
"git.sr.ht/~gioverse/chat/example/kitchen/appwidget/apptheme"
"git.sr.ht/~gioverse/chat/example/kitchen/model"
"git.sr.ht/~gioverse/chat/ninepatch"
+ "git.sr.ht/~gioverse/chat/res"
lorem "github.com/drhodes/golorem"
)
@@ 87,7 88,7 @@ func NewUI() *UI {
var (
cookie = func() ninepatch.NinePatch {
- imgf, err := os.Open("res/9-Patch/iap_platocookie_asset_2.png")
+ imgf, err := res.Resources.Open("9-Patch/iap_platocookie_asset_2.png")
if err != nil {
panic(fmt.Errorf("opening image: %w", err))
}
@@ 99,7 100,7 @@ func NewUI() *UI {
return ninepatch.DecodeNinePatch(img)
}()
hotdog = func() ninepatch.NinePatch {
- imgf, err := os.Open("res/9-Patch/iap_hotdog_asset.png")
+ imgf, err := res.Resources.Open("9-Patch/iap_hotdog_asset.png")
if err != nil {
panic(fmt.Errorf("opening image: %w", err))
}
M example/ninepatch/main.go => example/ninepatch/main.go +278 -106
@@ 6,21 6,21 @@ import (
"image"
"image/color"
"image/png"
+ "math"
"os"
"gioui.org/app"
- "gioui.org/f32"
"gioui.org/font/gofont"
"gioui.org/io/system"
"gioui.org/layout"
"gioui.org/op"
- "gioui.org/op/clip"
- "gioui.org/op/paint"
"gioui.org/unit"
"gioui.org/widget"
"gioui.org/widget/material"
+ "gioui.org/x/component"
"git.sr.ht/~gioverse/chat/example/kitchen/appwidget/apptheme"
"git.sr.ht/~gioverse/chat/ninepatch"
+ "git.sr.ht/~gioverse/chat/res"
lorem "github.com/drhodes/golorem"
)
@@ 77,26 77,42 @@ type UI struct {
Name string
widget.Bool
}
- // Patches available to the UI.
- Patches map[string]MessageStyle
- // Visible patches to render (by name).
+ // Messages available to the UI.
+ Messages map[string]*FauxMessage
+ // Visible messages to render (by name).
Visible []string
- // Width controls content width.
- Width widget.Float
- // Height controls content height.
- Height widget.Float
- // Real captures the real pixel dimensions of the content.
- Real image.Point
+ // Content controls the content dimensions.
+ Content struct {
+ Width widget.Float
+ Height widget.Float
+ }
+ // Constraints controls the constraints to render the 9-Patch with.
+ Constraints struct {
+ X widget.Float
+ Y widget.Float
+ }
+ // TextContent controls whether to simulate text content.
+ TextContent widget.Bool
+ // TextAmount controls the amount of text to display.
+ TextAmount widget.Float
+ // Scale controls the scale factor for the ninepatches.
+ Scale widget.Float
+ // PxPerDp controls the px-dp ratio to simulate different screen densities.
+ PxPerDp widget.Float
+ // ControlContainer adds scrolling to the controls.
+ ControlContainer widget.List
+ // DemoContainer adds scrolling to the demo.
+ DemoContainer widget.List
}
// NewUI constructs a UI and populates it with dummy data.
func NewUI() *UI {
return &UI{
- Patches: map[string]MessageStyle{
+ Messages: map[string]*FauxMessage{
"platocookie": {
- Content: material.Body1(th.Theme, lorem.Sentence(5, 20)),
+ Text: lorem.Sentence(1, 5),
Surface: func() ninepatch.NinePatch {
- imgf, err := os.Open("res/9-Patch/iap_platocookie_asset_2.png")
+ imgf, err := res.Resources.Open("9-Patch/iap_platocookie_asset_2.png")
if err != nil {
panic(fmt.Errorf("opening image: %w", err))
}
@@ 109,9 125,9 @@ func NewUI() *UI {
}(),
},
"hotdog": {
- Content: material.Body1(th.Theme, lorem.Sentence(5, 20)),
+ Text: lorem.Sentence(1, 5),
Surface: func() ninepatch.NinePatch {
- imgf, err := os.Open("res/9-Patch/iap_hotdog_asset.png")
+ imgf, err := res.Resources.Open("9-Patch/iap_hotdog_asset.png")
if err != nil {
panic(fmt.Errorf("opening image: %w", err))
}
@@ 129,9 145,19 @@ func NewUI() *UI {
widget.Bool
}{
{Name: "platocookie", Bool: widget.Bool{Value: true}},
- {Name: "hotdog", Bool: widget.Bool{Value: true}},
+ {Name: "hotdog", Bool: widget.Bool{Value: false}},
},
Visible: make([]string, 2),
+ Constraints: struct {
+ X widget.Float
+ Y widget.Float
+ }{
+ X: widget.Float{Value: 250},
+ Y: widget.Float{Value: 250},
+ },
+ TextContent: widget.Bool{Value: true},
+ Scale: widget.Float{Value: ninepatch.DefaultScale},
+ PxPerDp: widget.Float{Value: 1.0},
}
}
@@ 145,96 171,250 @@ func (ui *UI) Layout(gtx C) D {
ui.Visible[ii] = ""
}
}
+ if ui.TextAmount.Changed() {
+ for _, msg := range ui.Messages {
+ words := int(ui.TextAmount.Value)
+ msg.Text = lorem.Sentence(words, words)
+ }
+ }
+ if ui.Scale.Changed() {
+ for _, msg := range ui.Messages {
+ msg.Surface.Scale = ui.Scale.Value
+ }
+ }
+ if ui.Content.Width.Value > ui.Constraints.X.Value {
+ ui.Content.Width.Value = ui.Constraints.X.Value
+ }
+ if ui.Content.Height.Value > ui.Constraints.Y.Value {
+ ui.Content.Height.Value = ui.Constraints.Y.Value
+ }
+ return layout.Flex{
+ Axis: layout.Vertical,
+ }.Layout(gtx,
+ layout.Rigid(ui.layoutTitle),
+ layout.Rigid(ui.layoutContent),
+ )
+}
+
+func (ui *UI) layoutTitle(gtx C) D {
+ return layout.UniformInset(unit.Dp(12)).Layout(gtx, func(gtx C) D {
+ return layout.Center.Layout(gtx, func(gtx C) D {
+ return material.H4(th.Theme, "9-Patch Demo").Layout(gtx)
+ })
+ })
+}
+
+// breakpoint specifies dp at which to switch layout from horizontal to vertical.
+// 450 is hopefully sane.
+const breakpoint = 450
+
+// layoutContent lays out the controls and demo area.
+//
+// It uses a horizontal layout for large constraints and a vertical layout
+// for small constraints.
+func (ui *UI) layoutContent(gtx C) D {
+ var (
+ axis = layout.Vertical
+ )
+ if gtx.Constraints.Max.X > gtx.Px(unit.Dp(breakpoint)) {
+ axis = layout.Horizontal
+ }
return layout.UniformInset(unit.Dp(12)).Layout(gtx, func(gtx C) D {
return layout.Flex{
- Axis: layout.Vertical,
- Alignment: layout.Middle,
- }.Layout(
- gtx,
- layout.Rigid(func(gtx C) D {
- return layout.Center.Layout(gtx, func(gtx C) D {
- return material.H3(th.Theme, "9-Patch Demo").Layout(gtx)
+ Axis: axis,
+ }.Layout(gtx,
+ layout.Flexed(1, func(gtx C) D {
+ gtx.Constraints.Max.X = gtx.Px(unit.Dp(400))
+ ui.ControlContainer.Axis = layout.Vertical
+ return material.List(th.Theme, &ui.ControlContainer).Layout(gtx, 1, func(gtx C, _ int) D {
+ return layout.E.Layout(gtx, func(gtx C) D {
+ return layout.UniformInset(unit.Dp(20)).Layout(gtx, ui.layoutControls)
+ })
})
}),
layout.Rigid(func(gtx C) D {
- var items []layout.FlexChild
- for ii := range ui.Toggles {
- toggle := &ui.Toggles[ii]
- items = append(items, layout.Rigid(func(gtx C) D {
- return material.CheckBox(th.Theme, &toggle.Bool, toggle.Name).Layout(gtx)
- }))
+ if axis == layout.Horizontal {
+ return D{}
}
- return layout.Flex{
- Axis: layout.Horizontal,
- Alignment: layout.Middle,
- Spacing: layout.SpaceSides,
- }.Layout(gtx, items...)
- }),
- layout.Rigid(func(gtx C) D {
- return layout.UniformInset(unit.Dp(12)).Layout(gtx, func(gtx C) D {
- return layout.Flex{Axis: layout.Vertical}.Layout(
- gtx,
- layout.Rigid(func(gtx C) D {
- px := unit.Px(float32(ui.Real.X))
- dp := unit.Dp(px.V / gtx.Metric.PxPerDp)
- return LabeledSliderStyle{
- Label: material.Body1(th.Theme, fmt.Sprintf("Content Width: %s (%s)", px, dp)),
- Slider: material.Slider(th.Theme, &ui.Width, 0, 1),
- }.Layout(gtx)
- }),
- layout.Rigid(func(gtx C) D {
- px := unit.Px(float32(ui.Real.Y))
- dp := unit.Dp(px.V / gtx.Metric.PxPerDp)
- return LabeledSliderStyle{
- Label: material.Body1(th.Theme, fmt.Sprintf("Content Height: %s (%s)", px, dp)),
- Slider: material.Slider(th.Theme, &ui.Height, 0, 1),
- }.Layout(gtx)
- }),
- )
+ return layout.Inset{
+ Top: unit.Dp(10),
+ Bottom: unit.Dp(10),
+ }.Layout(gtx, func(gtx C) D {
+ return component.Divider(th.Theme).Layout(gtx)
})
}),
layout.Flexed(1, func(gtx C) D {
- return layout.Center.Layout(gtx, func(gtx C) D {
- var items []layout.FlexChild
- for ii := range ui.Visible {
- patch, ok := ui.Patches[ui.Visible[ii]]
- if !ok {
- continue
- }
- items = append(items, layout.Rigid(func(gtx C) D {
- gtx.Constraints.Min.X = int(ui.Width.Value * float32(gtx.Constraints.Max.X))
- gtx.Constraints.Min.Y = int(ui.Height.Value * float32(gtx.Constraints.Max.Y))
- ui.Real = gtx.Constraints.Min
- return patch.Layout(gtx)
- }))
- }
- return layout.Flex{
- Axis: layout.Vertical,
- Alignment: layout.Middle,
- }.Layout(gtx, items...)
+ ui.DemoContainer.Axis = layout.Vertical
+ return material.List(th.Theme, &ui.DemoContainer).Layout(gtx, 1, func(gtx C, _ int) D {
+ return layout.Center.Layout(gtx, func(gtx C) D {
+ return layout.UniformInset(unit.Dp(20)).Layout(gtx, ui.layoutDemo)
+ })
})
}),
)
})
}
-// MessageStyle draws a message atop a ninepatch surface.
-type MessageStyle struct {
- Content material.LabelStyle
+func (ui *UI) layoutControls(gtx C) D {
+ return layout.Flex{
+ Axis: layout.Vertical,
+ }.Layout(
+ gtx,
+ layout.Rigid(func(gtx C) D {
+ var items []layout.FlexChild
+ for ii := range ui.Toggles {
+ toggle := &ui.Toggles[ii]
+ items = append(items, layout.Rigid(func(gtx C) D {
+ return material.CheckBox(th.Theme, &toggle.Bool, toggle.Name).Layout(gtx)
+ }))
+ }
+ return layout.Flex{
+ Axis: layout.Horizontal,
+ Alignment: layout.Middle,
+ Spacing: layout.SpaceSides,
+ }.Layout(gtx, items...)
+ }),
+ // Layout constraint sliders.
+ layout.Rigid(func(gtx C) D {
+ px, dp := DP(gtx.Metric.PxPerDp, ui.Constraints.X.Value)
+ return LabeledSliderStyle{
+ Label: material.Body1(th.Theme, fmt.Sprintf("X Constraint: %s (%s)", px, dp)),
+ Slider: material.Slider(th.Theme, &ui.Constraints.X, 0, 700),
+ }.Layout(gtx)
+ }),
+ layout.Rigid(func(gtx C) D {
+ px, dp := DP(gtx.Metric.PxPerDp, ui.Constraints.Y.Value)
+ return LabeledSliderStyle{
+ Label: material.Body1(th.Theme, fmt.Sprintf("Y Constraint: %s (%s)", px, dp)),
+ Slider: material.Slider(th.Theme, &ui.Constraints.Y, 0, 700),
+ }.Layout(gtx)
+ }),
+ // Layout content sliders.
+ layout.Rigid(func(gtx C) D {
+ px, dp := DP(gtx.Metric.PxPerDp, ui.Content.Width.Value)
+ return LabeledSliderStyle{
+ Label: material.Body1(th.Theme, fmt.Sprintf("Content Width: %s (%s)", px, dp)),
+ Slider: material.Slider(th.Theme, &ui.Content.Width, 0, 700),
+ }.Layout(gtx)
+ }),
+ layout.Rigid(func(gtx C) D {
+ px, dp := DP(gtx.Metric.PxPerDp, ui.Content.Height.Value)
+ return LabeledSliderStyle{
+ Label: material.Body1(th.Theme, fmt.Sprintf("Content Height: %s (%s)", px, dp)),
+ Slider: material.Slider(th.Theme, &ui.Content.Height, 0, 700),
+ }.Layout(gtx)
+ }),
+ layout.Rigid(func(gtx C) D {
+ return LabeledSliderStyle{
+ Label: material.Body1(th.Theme, fmt.Sprintf("Scale: %.2f (default: %.2f)", ui.Scale.Value, ninepatch.DefaultScale)),
+ Slider: material.Slider(th.Theme, &ui.Scale, 0.3, 3),
+ }.Layout(gtx)
+ }),
+ layout.Rigid(func(gtx C) D {
+ return LabeledSliderStyle{
+ Label: material.Body1(th.Theme, fmt.Sprintf("PxPerDp: %.2f (default: %.2f)", ui.PxPerDp.Value, gtx.Metric.PxPerDp)),
+ Slider: material.Slider(th.Theme, &ui.PxPerDp, 0.3, 20),
+ }.Layout(gtx)
+ }),
+ layout.Rigid(func(gtx C) D {
+ return LabeledSliderStyle{
+ Label: material.Body1(th.Theme, "Text Amount"),
+ Slider: material.Slider(th.Theme, &ui.TextAmount, 0, 700),
+ }.Layout(gtx)
+ }),
+ layout.Rigid(func(gtx C) D {
+ return layout.Flex{
+ Axis: layout.Horizontal,
+ Alignment: layout.Middle,
+ }.Layout(
+ gtx,
+ layout.Rigid(func(gtx C) D {
+ return material.Body1(th.Theme, "Show Text").Layout(gtx)
+ }),
+ layout.Rigid(func(gtx C) D {
+ return D{Size: image.Point{X: gtx.Px(unit.Dp(10))}}
+ }),
+ layout.Rigid(func(gtx C) D {
+ return material.Switch(th.Theme, &ui.TextContent).Layout(gtx)
+ }),
+ )
+ }),
+ )
+}
+
+func (ui *UI) layoutDemo(gtx C) D {
+ var items []layout.FlexChild
+ for ii := range ui.Visible {
+ msg, ok := ui.Messages[ui.Visible[ii]]
+ if !ok {
+ continue
+ }
+ items = append(items, layout.Flexed(1, func(gtx C) D {
+ cs := >x.Constraints
+ cs.Max.X = int(ui.Constraints.X.Value)
+ cs.Max.Y = int(ui.Constraints.Y.Value)
+ return widget.Border{
+ Color: color.NRGBA{A: 200},
+ Width: unit.Dp(1),
+ }.Layout(gtx, func(gtx C) D {
+ return layout.Stack{}.Layout(
+ gtx,
+ layout.Stacked(func(gtx C) D {
+ return D{Size: gtx.Constraints.Max}
+ }),
+ layout.Expanded(func(gtx C) D {
+ gtx.Constraints.Min.X = int(ui.Content.Width.Value)
+ gtx.Constraints.Min.Y = int(ui.Content.Height.Value)
+ gtx.Metric.PxPerDp = ui.PxPerDp.Value
+ return NewMessage(th.Theme, msg, &ui.TextContent).Layout(gtx)
+ }),
+ )
+ })
+ }))
+ }
+ return layout.Flex{
+ Axis: layout.Vertical,
+ Alignment: layout.Middle,
+ }.Layout(gtx, items...)
+}
+
+// FauxMessage contains state needed to layout a fake message.
+type FauxMessage struct {
+ Text string
Surface ninepatch.NinePatch
}
+// NewMessage constructs a MessageStyle.
+func NewMessage(th *material.Theme, msg *FauxMessage, showText *widget.Bool) MessageStyle {
+ content := func(gtx C) D {
+ lb := material.Body1(th, msg.Text)
+ lb.Color = th.ContrastFg
+ return lb.Layout(gtx)
+ }
+ if !showText.Value {
+ content = func(gtx C) D {
+ return component.Rect{
+ Color: color.NRGBA{G: 200, A: 200},
+ Size: gtx.Constraints.Min,
+ }.Layout(gtx)
+ }
+ }
+ return MessageStyle{
+ FauxMessage: msg,
+ Content: content,
+ }
+}
+
+// MessageStyle lays a message content atop a ninepatch surface.
+type MessageStyle struct {
+ *FauxMessage
+ Content layout.Widget
+}
+
func (msg MessageStyle) Layout(gtx C) D {
- cs := >x.Constraints
return msg.Surface.Layout(gtx, func(gtx C) D {
- return RectStyle{
- Size: cs.Min,
- Color: color.NRGBA{G: 200, A: 200},
- }.Layout(gtx)
+ return msg.Content(gtx)
})
- // return msg.Surface.Layout(gtx, func(gtx C) D {
- // return msg.Content.Layout(gtx)
- // })
}
// LabeledSliderStyle draws a slider with a label.
@@ 244,29 424,21 @@ type LabeledSliderStyle struct {
}
func (slider LabeledSliderStyle) Layout(gtx C) D {
- return layout.Flex{Axis: layout.Vertical}.Layout(
+ gtx.Constraints.Min.X = gtx.Constraints.Max.X
+ return layout.Flex{
+ Axis: layout.Vertical,
+ }.Layout(
gtx,
layout.Rigid(slider.Label.Layout),
layout.Rigid(slider.Slider.Layout),
)
}
-// RectStyle draws a colored rectangle.
-type RectStyle struct {
- Color color.NRGBA
- Size image.Point
- Radii float32
-}
-
-func (r RectStyle) Layout(gtx C) D {
- paint.FillShape(
- gtx.Ops,
- r.Color,
- clip.UniformRRect(
- f32.Rectangle{
- Max: layout.FPt(r.Size),
- },
- r.Radii,
- ).Op(gtx.Ops))
- return layout.Dimensions{Size: image.Pt(r.Size.X, r.Size.Y)}
+// DP helper computes the dp given some pixels and the ratio of pixels per dp.
+//
+// Note: This helper is for display purposes. Pixels are rounded for clarity,
+// therefore do not use results as "real" units in layout.
+func DP(pixelperdp float32, pixels float32) (px unit.Value, dp unit.Value) {
+ pixels = float32(math.Round(float64(pixels)))
+ return unit.Px(pixels), unit.Dp(pixels / pixelperdp)
}
M go.mod => go.mod +2 -2
@@ 4,10 4,10 @@ go 1.16
require (
gioui.org v0.0.0-20210705140830-941aeaae910e
- gioui.org/x v0.0.0-20210615121216-b3d6aa6ed67b
+ gioui.org/x v0.0.0-20210705162517-de43ceaa90b6
github.com/drhodes/golorem v0.0.0-20160418191928-ecccc744c2d9
github.com/lucasb-eyer/go-colorful v1.2.0
golang.org/x/exp v0.0.0-20210625193404-fa9d1d177d71
golang.org/x/image v0.0.0-20210628002857-a66eb6448b8d // indirect
- golang.org/x/sys v0.0.0-20210616094352-59db8d763f22 // indirect
+ golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c // indirect
)
M go.sum => go.sum +4 -5
@@ 2,11 2,10 @@ cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMT
cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
dmitri.shuralyov.com/gpu/mtl v0.0.0-20201218220906-28db891af037/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
-gioui.org v0.0.0-20210611190218-9b5e9ae60717/go.mod h1:RSH6KIUZ0p2xy5zHDxgAM4zumjgTw83q2ge/PI+yyw8=
gioui.org v0.0.0-20210705140830-941aeaae910e h1:ute6jeS6oeVSBbprtz0HF2v3QnFV3r61oLVVZlOg2Xs=
gioui.org v0.0.0-20210705140830-941aeaae910e/go.mod h1:RSH6KIUZ0p2xy5zHDxgAM4zumjgTw83q2ge/PI+yyw8=
-gioui.org/x v0.0.0-20210615121216-b3d6aa6ed67b h1:VnAOU4G/FcTF0HzzHsKTAHu9yFR5uQLMdkmoGFSygrI=
-gioui.org/x v0.0.0-20210615121216-b3d6aa6ed67b/go.mod h1:Bx77f7mOqBBUNqP/XM1nSSRXwca1CU1jH7GvpICeMQY=
+gioui.org/x v0.0.0-20210705162517-de43ceaa90b6 h1:JbTiMUVNO21XK9A9q6t2UU3vgVZu7AZgeOVpxIEEY94=
+gioui.org/x v0.0.0-20210705162517-de43ceaa90b6/go.mod h1:wWLp7AlhjLtSUWOTiODXwRtpVSQUkG7enAvgWIjik8s=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
github.com/Knetic/govaluate v3.0.1-0.20171022003610-9aa49832a739+incompatible/go.mod h1:r7JcOSlj0wfOMncg0iLm8Leh48TZaKVeNIfJntJ2wa0=
@@ 351,8 350,8 @@ golang.org/x/sys v0.0.0-20191220142924-d4481acd189f/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210304124612-50617c2ba197/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20210616094352-59db8d763f22 h1:RqytpXGR1iVNX7psjB3ff8y7sNFinVFvkx1c8SjBkio=
-golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c h1:F1jZWGFhYfh0Ci55sIpILtKKK8p3i2/krTr0H1rg74I=
+golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
A ninepatch/decode.go => ninepatch/decode.go +130 -0
@@ 0,0 1,130 @@
+package ninepatch
+
+import (
+ "image"
+ "image/color"
+
+ "gioui.org/layout"
+ "gioui.org/unit"
+)
+
+// DecodeNinePatch from source image.
+//
+// Note: Any colored pixel around the border will be considered a 9-Patch marker.
+func DecodeNinePatch(src image.Image) NinePatch {
+ var (
+ b = src.Bounds()
+ inset = layout.Inset{}
+ x1, x2 = 0, 0
+ y1, y2 = 0, 0
+ )
+ right := walk(src, b.Max.X-1, layout.Vertical)
+ if right.IsValid() {
+ inset.Top = unit.Px(float32(right.Start))
+ inset.Bottom = unit.Px(float32(b.Max.Y - right.End))
+ }
+ bottom := walk(src, b.Max.Y-1, layout.Horizontal)
+ if bottom.IsValid() {
+ inset.Left = unit.Px(float32(bottom.Start))
+ inset.Right = unit.Px(float32(b.Max.X - bottom.End))
+ }
+ top := walk(src, 0, layout.Vertical)
+ if top.IsValid() {
+ y1, y2 = top.Start, b.Max.Y-top.End
+ }
+ left := walk(src, 0, layout.Horizontal)
+ if left.IsValid() {
+ x1, x2 = left.Start, b.Max.X-left.End
+ }
+ return NinePatch{
+ Image: eraseBorder(src),
+ Content: inset,
+ Grid: Grid{
+ Size: image.Point{
+ X: b.Dx(),
+ Y: b.Dy(),
+ },
+ X1: x1, X2: x2,
+ Y1: y1, Y2: y2,
+ },
+ }
+}
+
+// eraseBorder clears the 1px border around the image containing the 9-Patch
+// region specifiers (1px black lines).
+//
+// TODO(jfm) [performance]: type switch src to see if we can mutate it directly
+// and avoid copying it.
+//
+// The goal of loading a 9-Patch image is, at least ostensibly, to use the
+// NinePatch type. It would be unexpected to then want that data for something
+// else, post NinePatch allocation.
+//
+// However, if that were the case then mutating the src may be a bad idea.
+//
+// TODO(jfm) [performance]: current implemenation leaves 1px border of
+// transparent pixels, which consumes memory for no gain.
+func eraseBorder(src image.Image) *image.NRGBA {
+ var (
+ b = src.Bounds()
+ out = image.NewNRGBA(b)
+ )
+ // Copy image data.
+ for xx := b.Min.X; xx < b.Max.X; xx++ {
+ for yy := b.Min.Y; yy < b.Max.Y; yy++ {
+ out.Set(xx, yy, src.At(xx, yy))
+ }
+ }
+ // Clear out the borders which contain 1px 9-Patch stretch region
+ // identifiers.
+ for xx := b.Min.X; xx < b.Max.X; xx++ {
+ out.Set(xx, b.Min.Y, color.NRGBA{})
+ out.Set(xx, b.Max.Y-1, color.NRGBA{})
+ }
+ for yy := b.Min.Y; yy < b.Max.Y; yy++ {
+ out.Set(b.Min.X, yy, color.NRGBA{})
+ out.Set(b.Max.X-1, yy, color.NRGBA{})
+ }
+ return out
+}
+
+// line encodes a one-dimensional line.
+type line struct {
+ Start, End int
+}
+
+func (l line) IsValid() bool {
+ return l.Start > -1 && l.End > -1
+}
+
+// walk pixels in the source image, along the specified main axis, and offset
+// along the cross axis, returning a line that describes the length of any
+// squence of colored pixels.
+//
+// NOTE(jfm): in time we may want tighter control over what is considered
+// "colored". For now, any color that is not zero will suffice.
+func walk(src image.Image, offset int, axis layout.Axis) line {
+ var (
+ end = axis.Convert(src.Bounds().Max).X
+ line = line{Start: -1, End: -1}
+ )
+ for ii := 0; ii < end; ii++ {
+ pt := axis.Convert(image.Point{X: ii, Y: offset})
+ r, g, b, a := src.At(pt.X, pt.Y).RGBA()
+ var (
+ colorIsSet = r > 0 || g > 0 || b > 0 || a > 0
+ startIsSet = line.Start > -1
+ endIsSet = line.End > -1
+ )
+ if colorIsSet && !startIsSet {
+ line.Start = ii
+ }
+ if !colorIsSet && startIsSet {
+ line.End = ii
+ }
+ if startIsSet && endIsSet {
+ break
+ }
+ }
+ return line
+}
A ninepatch/grid.go => ninepatch/grid.go +36 -0
@@ 0,0 1,36 @@
+package ninepatch
+
+import "image"
+
+// Grid describes the stretchable regions of a 9-Patch as 3x3 grid divided
+// by 4 lines.
+type Grid struct {
+ // Size specifies the total dimensions including static and stretch regions.
+ Size image.Point
+ // X1 is the distance in pixels before the stretchable region along the X axis.
+ // X2 is the distance in pixels after the stretchable region along the X axis.
+ X1, X2 int
+ // Y1 is the distance in pixels before the stretchable region along the Y axis.
+ // Y2 is the distance in pixels after the stretchable region along the Y axis.
+ Y1, Y2 int
+}
+
+// Static returns the statically known dimensions (the corners).
+func (g Grid) Static() image.Point {
+ return image.Point{
+ X: g.X1 + g.X2,
+ Y: g.Y1 + g.Y2,
+ }
+}
+
+// Stretch returns the stretch dimensions (the space between the corners).
+func (g Grid) Stretch() image.Point {
+ stretch := g.Size.Sub(g.Static())
+ if stretch.X < 0 {
+ stretch.X = 0
+ }
+ if stretch.Y < 0 {
+ stretch.Y = 0
+ }
+ return stretch
+}
M ninepatch/ninepatch.go => ninepatch/ninepatch.go +280 -360
@@ 4,7 4,7 @@ package ninepatch
import (
"image"
- "image/color"
+ "math"
"sync"
"gioui.org/f32"
@@ 12,7 12,6 @@ import (
"gioui.org/op"
"gioui.org/op/clip"
"gioui.org/op/paint"
- "gioui.org/unit"
)
type (
@@ 26,405 25,326 @@ type (
// after the first layout will have no effect because the paint.ImageOp is
// cached.
type NinePatch struct {
- // Image is the backing image of the 9patch.
+ // Image is the backing image of the 9-Patch.
image.Image
- // Inset encodes the mandatory content insets defined by the black lines on the
- // bottom and right of the 9patch image.
- layout.Inset
- // X1 is the distance in pixels before the stretchable region along the X axis.
- // X2 is the distance in pixels after the stretchable region along the X axis.
- X1, X2 int
- // Y1 is the distance in pixels before the stretchable region along the Y axis.
- // Y2 is the distance in pixels after the stretchable region along the Y axis.
- Y1, Y2 int
+ // Grid describes the stretchable regions of the 9-Patch.
+ Grid Grid
+ // Inset describes content insets defined by the black lines on the bottom
+ // and right of the 9-Patch image.
+ Content layout.Inset
+ // Scale is the ratio of px to dp. If Scale is zero, NinePatch falls back
+ // to a scale that match a standard 72 DPI.
+ Scale float32
// Cache the image.
cache paint.ImageOp
once sync.Once
}
-// NinePatchRegion describes how to lay out a particular region of a 9patch image.
-// It defines an offset and size within the source image, and an offset and size
-// within the layout. It provides a layout method that will handle converting
-// between the provided offsets and sizes.
-type NinePatchRegion struct {
- Size, Offset image.Point
- SrcSize, SrcOffset image.Point
+// Patch describes the position and size of single patch in a 9-Patch image.
+type Patch struct {
+ Offset image.Point
+ Size image.Point
}
-// Layout the region of the provided ImageOp described by the NinePatchRegion.
-func (n NinePatchRegion) Layout(gtx C, src paint.ImageOp) D {
+// Region describes how to lay out a particular patch of a 9-Patch image.
+type Region struct {
+ // Source is the patch relative to the source image.
+ Source Patch
+ // Stretched is the patch relative to the layout.
+ Stretched Patch
+}
+
+// Layout the patch of the provided ImageOp described by the Region, scaling
+// as needed.
+func (r Region) Layout(gtx C, src paint.ImageOp) D {
defer op.Save(gtx.Ops).Load()
- // Shift layout to the origin of the region that we are covering, but compensate
- // for the fact that we're going to be reaching to an arbitrary point in the
- // source image. This logic aligns the origin of the important region of the
- // source image with the origin of the region that we're laying out.
- op.Offset(layout.FPt(n.Offset.Sub(n.SrcOffset))).Add(gtx.Ops)
// Set the paint material to our source texture.
src.Add(gtx.Ops)
+
// If we need to scale the source image to cover the content area, do so:
- if n.Size != n.SrcSize {
- op.Affine(f32.Affine2D{}.Scale(layout.FPt(n.Offset), f32.Point{
- X: float32(n.Size.X) / float32(n.SrcSize.X),
- Y: float32(n.Size.Y) / float32(n.SrcSize.Y),
+ if r.Stretched.Size != r.Source.Size {
+ op.Affine(f32.Affine2D{}.Scale(layout.FPt(r.Stretched.Offset), f32.Point{
+ X: float32(r.Stretched.Size.X) / float32(r.Source.Size.X),
+ Y: float32(r.Stretched.Size.Y) / float32(r.Source.Size.Y),
})).Add(gtx.Ops)
}
+
+ // Shift layout to the origin of the region that we are covering, but compensate
+ // for the fact that we're going to be reaching to an arbitrary point in the
+ // source image. This logic aligns the origin of the important region of the
+ // source image with the origin of the region that we're laying out.
+ op.Offset(layout.FPt(r.Stretched.Offset.Sub(r.Source.Offset))).Add(gtx.Ops)
+
// Clip the scaled image to the bounds of the area we need to cover.
clip.Rect(image.Rectangle{
- Min: n.SrcOffset,
- Max: n.SrcSize.Add(n.SrcOffset),
+ Min: r.Source.Offset,
+ Max: r.Source.Size.Add(r.Source.Offset),
}).Add(gtx.Ops)
+
// Paint the scaled, clipped image.
paint.PaintOp{}.Add(gtx.Ops)
- return D{Size: n.Size}
+ return D{Size: r.Stretched.Size}
}
+// DefaultScale is a standard 72 DPI.
+const DefaultScale = 1 / float32(160.0/72.0)
+
// Layout the provided widget with the NinePatch as a background.
func (n NinePatch) Layout(gtx C, w layout.Widget) D {
- // Layout content in macro to compute it's dimensions.
- // These dimensions are needed to figure out how much stretch we need.
- macro := op.Record(gtx.Ops)
- dims := n.Inset.Layout(gtx, w)
- call := macro.Stop()
-
- // Compute stretch region dimensions in pixels relative to the source image.
- // Depends on 9patch image definition.
- middleSrcWidth := n.Image.Bounds().Dx() - (n.X1 + n.X2)
- middleSrcHeight := n.Image.Bounds().Dy() - (n.Y1 + n.Y2)
-
- // Compute stretch region dimensions in pixels relative to the desired layout.
- // Dependends on content size.
- middleWidth := dims.Size.X - (n.X1 + n.X2)
- middleHeight := dims.Size.Y - (n.Y1 + n.Y2)
-
- // Handle tiny content.
- if middleHeight <= 0 {
- dims.Size.Y += -1 * middleHeight
- middleHeight = 0
- }
- if middleWidth <= 0 {
- dims.Size.X += -1 * middleWidth
- middleWidth = 0
- }
-
n.once.Do(func() {
n.cache = paint.NewImageOp(n.Image)
})
- upperLeft := NinePatchRegion{
- Size: image.Point{
- X: n.X1,
- Y: n.Y1,
- },
- SrcSize: image.Point{
- X: n.X1,
- Y: n.Y1,
- },
- }
- upperMiddle := NinePatchRegion{
- Offset: image.Point{
- X: n.X1,
- },
- Size: image.Point{
- X: middleWidth,
- Y: n.Y1,
- },
- SrcOffset: image.Point{
- X: n.X1,
- },
- SrcSize: image.Point{
- X: middleSrcWidth,
- Y: n.Y1,
- },
- }
- upperRight := NinePatchRegion{
- Offset: image.Point{
- X: n.X1 + middleWidth,
- },
- Size: image.Point{
- X: n.X2,
- Y: n.Y1,
- },
- SrcOffset: image.Point{
- X: n.X1 + middleSrcWidth,
- },
- SrcSize: image.Point{
- X: n.X2,
- Y: n.Y1,
- },
- }
+ // TODO(jfm) [performance]: cache scaled grid instead of recomputing every
+ // frame.
- middleLeft := NinePatchRegion{
- Offset: image.Point{
- Y: n.Y1,
- },
- Size: image.Point{
- Y: middleHeight,
- X: n.X1,
- },
- SrcOffset: image.Point{
- Y: n.Y1,
- },
- SrcSize: image.Point{
- Y: middleSrcHeight,
- X: n.X1,
- },
- }
- middleMiddle := NinePatchRegion{
- Offset: image.Point{
- Y: n.Y1,
- X: n.X1,
- },
- Size: image.Point{
- Y: middleHeight,
- X: middleWidth,
- },
- SrcOffset: image.Point{
- Y: n.Y1,
- X: n.X1,
- },
- SrcSize: image.Point{
- Y: middleSrcHeight,
- X: middleSrcWidth,
- },
- }
- middleRight := NinePatchRegion{
- Offset: image.Point{
- Y: n.Y1,
- X: n.X1 + middleWidth,
- },
- Size: image.Point{
- Y: middleHeight,
- X: n.X2,
- },
- SrcOffset: image.Point{
- Y: n.Y1,
- X: n.X1 + middleSrcWidth,
- },
- SrcSize: image.Point{
- Y: middleSrcHeight,
- X: n.X2,
- },
+ scale := n.Scale
+ if scale == 0 {
+ scale = DefaultScale
}
- bottomLeft := NinePatchRegion{
- Offset: image.Point{
- Y: n.Y1 + middleHeight,
- },
- Size: image.Point{
- Y: n.Y2,
- X: n.X1,
- },
- SrcOffset: image.Point{
- Y: n.Y1 + middleSrcHeight,
- },
- SrcSize: image.Point{
- Y: n.Y2,
- X: n.X1,
- },
- }
- bottomMiddle := NinePatchRegion{
- Offset: image.Point{
- Y: n.Y1 + middleHeight,
- X: n.X1,
- },
- Size: image.Point{
- Y: n.Y2,
- X: middleWidth,
- },
- SrcOffset: image.Point{
- Y: n.Y1 + middleSrcHeight,
- X: n.X1,
- },
- SrcSize: image.Point{
- Y: n.Y2,
- X: middleSrcWidth,
- },
- }
- bottomRight := NinePatchRegion{
- Offset: image.Point{
- Y: n.Y1 + middleHeight,
- X: n.X1 + middleWidth,
- },
- Size: image.Point{
- Y: n.Y2,
- X: n.X2,
- },
- SrcOffset: image.Point{
- Y: n.Y1 + middleSrcHeight,
- X: n.X1 + middleSrcWidth,
- },
- SrcSize: image.Point{
- Y: n.Y2,
- X: n.X2,
- },
- }
-
- upperLeft.Layout(gtx, n.cache)
- upperMiddle.Layout(gtx, n.cache)
- upperRight.Layout(gtx, n.cache)
- middleLeft.Layout(gtx, n.cache)
- middleMiddle.Layout(gtx, n.cache)
- middleRight.Layout(gtx, n.cache)
- bottomLeft.Layout(gtx, n.cache)
- bottomMiddle.Layout(gtx, n.cache)
- bottomRight.Layout(gtx, n.cache)
-
- call.Add(gtx.Ops)
-
- return dims
-}
-
-// DecodeNinePatch from source image.
-func DecodeNinePatch(src image.Image) NinePatch {
- // Algorithm:
- // - walk the border of the image in 4 parts
- // - line starts when the first non-zero pixel is encountered
- // - line ends when the first zero pixel is encountered, after the first
- // non-zero pixel
- // - right and bottom lines are used to compute content inset
- // - left and top lines are used to compute stretch regions
+ // Handle screen density.
+ scale /= gtx.Metric.PxPerDp
var (
- // bounds of the source image.
- b = src.Bounds()
- // Start and end point defining the line.
- start, end = -1, -1
- // Capture the content inset.
- inset = layout.Inset{}
- // Capture the stretch region grid lines.
- x1, x2 = 0, 0
- y1, y2 = 0, 0
- )
-
- // Top and Bottom insets are defined by the black line on the right
- // Left and Right inset are defined by the black line on the bottom
-
- // Walk the final column of pixels and decode the black line.
- for yy := b.Min.Y; yy < b.Max.Y; yy++ {
- r, g, b, a := src.At(b.Max.X-1, yy).RGBA()
- var (
- colorIsSet = r > 0 || g > 0 || b > 0 || a > 0
- startIsSet = start > -1
- endIsSet = end > -1
- )
- if colorIsSet && !startIsSet {
- start = yy
+ src = n.Grid
+ str = Grid{
+ X1: int(math.Round(float64(src.X1) / float64(1/scale))),
+ X2: int(math.Round(float64(src.X2) / float64(1/scale))),
+ Y1: int(math.Round(float64(src.Y1) / float64(1/scale))),
+ Y2: int(math.Round(float64(src.Y2) / float64(1/scale))),
}
- if !colorIsSet && startIsSet {
- end = yy
+ inset = layout.Inset{
+ Left: n.Content.Left.Scale(scale),
+ Right: n.Content.Right.Scale(scale),
+ Top: n.Content.Top.Scale(scale),
+ Bottom: n.Content.Bottom.Scale(scale),
}
- if startIsSet && endIsSet {
- break
- }
- }
+ )
- inset.Top = unit.Px(float32(start))
- inset.Bottom = unit.Px(float32(b.Max.Y - end))
- start, end = -1, -1
-
- // Walk the final row of pixels and decode the black line.
- for xx := b.Min.X; xx < b.Max.X; xx++ {
- r, g, b, a := src.At(xx, b.Max.Y-1).RGBA()
- var (
- colorIsSet = r > 0 || g > 0 || b > 0 || a > 0
- startIsSet = start > -1
- endIsSet = end > -1
- )
- if colorIsSet && !startIsSet {
- start = xx
- }
- if !colorIsSet && startIsSet {
- end = xx
- }
- if startIsSet && endIsSet {
- break
- }
- }
+ // Layout content in macro to compute it's dimensions.
+ // These dimensions are needed to figure out how much stretch is needed.
+ macro := op.Record(gtx.Ops)
+ dims := inset.Layout(gtx, w)
+ call := macro.Stop()
- inset.Left = unit.Px(float32(start))
- inset.Right = unit.Px(float32(b.Max.X - end))
- start, end = -1, -1
-
- // Horizontal stretch defined by black line on the top
- // Vertical stretch defined by black lin on the left
-
- // Walk the first column of pixels and decode the black line.
- for yy := b.Min.Y; yy < b.Max.Y; yy++ {
- r, g, b, a := src.At(b.Min.X, yy).RGBA()
- var (
- colorIsSet = r > 0 || g > 0 || b > 0 || a > 0
- startIsSet = start > -1
- endIsSet = end > -1
- )
- if colorIsSet && !startIsSet {
- start = yy
- }
- if !colorIsSet && startIsSet {
- end = yy
- }
- if startIsSet && endIsSet {
- break
- }
- }
+ str.Size = dims.Size
- y1, y2 = start, b.Max.Y-end
- start, end = -1, -1
-
- // Walk the first row of pixels and decode the black line.
- for xx := b.Min.X; xx < b.Max.X; xx++ {
- r, g, b, a := src.At(xx, b.Min.Y).RGBA()
- var (
- colorIsSet = r > 0 || g > 0 || b > 0 || a > 0
- startIsSet = start > -1
- endIsSet = end > -1
- )
- if colorIsSet && !startIsSet {
- start = xx
- }
- if !colorIsSet && startIsSet {
- end = xx
- }
- if startIsSet && endIsSet {
- break
- }
+ // Handle tiny content: at least stretch by the amount that original does.
+ if str.Stretch().Y <= src.Stretch().Y {
+ dims.Size.Y = dims.Size.Y - str.Stretch().Y + src.Stretch().Y
+ str.Size.Y = str.Size.Y - str.Stretch().Y + src.Stretch().Y
+ }
+ if str.Stretch().X <= src.Stretch().X {
+ dims.Size.X = dims.Size.X - str.Stretch().X + src.Stretch().X
+ str.Size.X = str.Size.X - str.Stretch().X + src.Stretch().X
}
- x1, x2 = start, b.Max.X-end
+ // Layout each of the 9 patches.
+
+ // upper left
+ Region{
+ Source: Patch{
+ Size: image.Point{
+ X: src.X1,
+ Y: src.Y1,
+ },
+ },
+ Stretched: Patch{
+ Size: image.Point{
+ X: str.X1,
+ Y: str.Y1,
+ },
+ },
+ }.Layout(gtx, n.cache)
+
+ // upper middle
+ Region{
+ Source: Patch{
+ Size: image.Point{
+ X: src.Stretch().X,
+ Y: src.Y1,
+ },
+ Offset: image.Point{
+ X: src.X1,
+ },
+ },
+ Stretched: Patch{
+ Size: image.Point{
+ X: str.Stretch().X,
+ Y: str.Y1,
+ },
+ Offset: image.Point{
+ X: str.X1,
+ },
+ },
+ }.Layout(gtx, n.cache)
+
+ // upper right
+ Region{
+ Source: Patch{
+ Size: image.Point{
+ X: src.X2,
+ Y: src.Y1,
+ },
+ Offset: image.Point{
+ X: src.X1 + src.Stretch().X,
+ },
+ },
+ Stretched: Patch{
+ Size: image.Point{
+ X: str.X2,
+ Y: str.Y1,
+ },
+ Offset: image.Point{
+ X: str.X1 + str.Stretch().X,
+ },
+ },
+ }.Layout(gtx, n.cache)
+
+ // middle left
+ Region{
+ Source: Patch{
+ Size: image.Point{
+ X: src.X1,
+ Y: src.Stretch().Y,
+ },
+ Offset: image.Point{
+ Y: src.Y1,
+ },
+ },
+ Stretched: Patch{
+ Size: image.Point{
+ X: str.X1,
+ Y: str.Stretch().Y,
+ },
+ Offset: image.Point{
+ Y: str.Y1,
+ },
+ },
+ }.Layout(gtx, n.cache)
+
+ // middle middle
+ Region{
+ Source: Patch{
+ Size: image.Point{
+ X: src.Stretch().X,
+ Y: src.Stretch().Y,
+ },
+ Offset: image.Point{
+ X: src.X1,
+ Y: src.Y1,
+ },
+ },
+ Stretched: Patch{
+ Size: image.Point{
+ X: str.Stretch().X,
+ Y: str.Stretch().Y,
+ },
+ Offset: image.Point{
+ X: str.X1,
+ Y: str.Y1,
+ },
+ },
+ }.Layout(gtx, n.cache)
+
+ // middle right
+ Region{
+ Source: Patch{
+ Size: image.Point{
+ X: src.X2,
+ Y: src.Stretch().Y,
+ },
+ Offset: image.Point{
+ X: src.X1 + src.Stretch().X,
+ Y: src.Y1,
+ },
+ },
+ Stretched: Patch{
+ Size: image.Point{
+ X: str.X2,
+ Y: str.Stretch().Y,
+ },
+ Offset: image.Point{
+ X: str.X1 + str.Stretch().X,
+ Y: str.Y1,
+ },
+ },
+ }.Layout(gtx, n.cache)
+
+ // lower left
+ Region{
+ Source: Patch{
+ Size: image.Point{
+ X: src.X1,
+ Y: src.Y2,
+ },
+ Offset: image.Point{
+ Y: src.Y1 + src.Stretch().Y,
+ },
+ },
+ Stretched: Patch{
+ Size: image.Point{
+ X: str.X1,
+ Y: str.Y2,
+ },
+ Offset: image.Point{
+ Y: str.Y1 + str.Stretch().Y,
+ },
+ },
+ }.Layout(gtx, n.cache)
+
+ // lower middle
+ Region{
+ Source: Patch{
+ Size: image.Point{
+ X: src.Stretch().X,
+ Y: src.Y2,
+ },
+ Offset: image.Point{
+ X: src.X1,
+ Y: src.Y1 + src.Stretch().Y,
+ },
+ },
+ Stretched: Patch{
+ Size: image.Point{
+ X: str.Stretch().X,
+ Y: str.Y2,
+ },
+ Offset: image.Point{
+ X: str.X1,
+ Y: str.Y1 + str.Stretch().Y,
+ },
+ },
+ }.Layout(gtx, n.cache)
+
+ // lower right
+ Region{
+ Source: Patch{
+ Size: image.Point{
+ X: src.X2,
+ Y: src.Y2,
+ },
+ Offset: image.Point{
+ Y: src.Y1 + src.Stretch().Y,
+ X: src.X1 + src.Stretch().X,
+ },
+ },
+ Stretched: Patch{
+ Size: image.Point{
+ X: str.X2,
+ Y: str.Y2,
+ },
+ Offset: image.Point{
+ Y: str.Y1 + str.Stretch().Y,
+ X: str.X1 + str.Stretch().X,
+ },
+ },
+ }.Layout(gtx, n.cache)
- return NinePatch{
- Image: EraseBorder(src),
- Inset: inset,
- X1: x1,
- X2: x2,
- Y1: y1,
- Y2: y2,
- }
-}
+ call.Add(gtx.Ops)
-// EraseBorder clears the 1px border around the image containing the 9-Patch
-// region specifiers (1px black lines).
-func EraseBorder(src image.Image) *image.NRGBA {
- var (
- b = src.Bounds()
- out = image.NewNRGBA(b)
- )
- // Copy image data.
- for xx := b.Min.X; xx < b.Max.X; xx++ {
- for yy := b.Min.Y; yy < b.Max.Y; yy++ {
- out.Set(xx, yy, src.At(xx, yy))
- }
- }
- // Clear out the borders which contain 1px 9-Patch stretch region
- // identifiers.
- for xx := b.Min.X; xx < b.Max.X; xx++ {
- out.Set(xx, b.Min.Y, color.NRGBA{})
- out.Set(xx, b.Max.Y-1, color.NRGBA{})
- }
- for yy := b.Min.Y; yy < b.Max.Y; yy++ {
- out.Set(b.Min.X, yy, color.NRGBA{})
- out.Set(b.Max.X-1, yy, color.NRGBA{})
- }
- return out
+ return dims
}
A ninepatch/ninepatch_test.go => ninepatch/ninepatch_test.go +213 -0
@@ 0,0 1,213 @@
+package ninepatch
+
+import (
+ "fmt"
+ "image"
+ "image/color"
+ "image/png"
+ "testing"
+
+ "gioui.org/layout"
+ "gioui.org/unit"
+ "git.sr.ht/~gioverse/chat/res"
+)
+
+var (
+ platocookie = open("9-Patch/iap_platocookie_asset_2.png")
+ hotdog = open("9-Patch/iap_hotdog_asset.png")
+)
+
+// TestDecodeNinePatch tests that 9-Patch data is successfully read from a
+// source image.
+func TestDecodeNinePatch(t *testing.T) {
+ for _, tt := range []struct {
+ Label string
+ Src image.Image
+ NP NP
+ }{
+ {
+ Label: "empty image",
+ Src: NewImg(image.Pt(0, 0)),
+ NP: NP{},
+ },
+ {
+ // An image with no stretch markers will be considered "completely
+ // static", and therefore will not resize in any way.
+ //
+ // An image with no content inset will have no padding around
+ // content.
+ //
+ // Both are still "valid" 9-Patch images, however unusable.
+ Label: "image with no border",
+ Src: NewImg(image.Pt(100, 100)),
+ NP: NP{Grid: Grid{Size: image.Point{X: 100, Y: 100}}},
+ },
+ {
+ Label: "image with no content inset",
+ Src: NewImg(image.Pt(100, 100)).TopBorder(25, 50).LeftBorder(25, 50),
+ NP: NP{
+ Grid: Grid{
+ Size: image.Point{X: 100, Y: 100},
+ X1: 25, X2: 25,
+ Y1: 25, Y2: 25,
+ },
+ },
+ },
+ {
+ Label: "image with no stretch regions",
+ Src: NewImg(image.Pt(100, 100)).BottomBorder(25, 50).RightBorder(25, 50),
+ NP: NP{
+ Content: layout.Inset{
+ Top: unit.Px(25),
+ Right: unit.Px(25),
+ Bottom: unit.Px(25),
+ Left: unit.Px(25),
+ },
+ Grid: Grid{Size: image.Point{X: 100, Y: 100}},
+ },
+ },
+ {
+ Label: "image with content inset and stretch regions",
+ Src: NewImg(image.Pt(100, 100)).
+ TopBorder(25, 50).
+ LeftBorder(25, 50).
+ BottomBorder(25, 50).
+ RightBorder(25, 50),
+ NP: NP{
+ Content: layout.Inset{
+ Top: unit.Px(25),
+ Right: unit.Px(25),
+ Bottom: unit.Px(25),
+ Left: unit.Px(25),
+ },
+ Grid: Grid{
+ Size: image.Point{X: 100, Y: 100},
+ X1: 25, X2: 25,
+ Y1: 25, Y2: 25,
+ },
+ },
+ },
+ {
+ Label: "platocookie",
+ Src: platocookie,
+ NP: NP{
+ Content: layout.Inset{
+ Top: unit.Px(31),
+ Right: unit.Px(70),
+ Bottom: unit.Px(27),
+ Left: unit.Px(70),
+ },
+ Grid: Grid{
+ Size: image.Point{
+ X: platocookie.Bounds().Dx(),
+ Y: platocookie.Bounds().Dy(),
+ },
+ X1: 86, X2: 61,
+ Y1: 55, Y2: 47,
+ },
+ },
+ },
+ {
+ Label: "hotdog",
+ Src: hotdog,
+ NP: NP{
+ Content: layout.Inset{
+ Top: unit.Px(31),
+ Right: unit.Px(70),
+ Bottom: unit.Px(27),
+ Left: unit.Px(70),
+ },
+ Grid: Grid{
+ Size: image.Point{
+ X: hotdog.Bounds().Dx(),
+ Y: hotdog.Bounds().Dy(),
+ },
+ X1: 86, X2: 61,
+ Y1: 55, Y2: 47,
+ },
+ },
+ },
+ } {
+ t.Run(tt.Label, func(t *testing.T) {
+ np := DecodeNinePatch(tt.Src)
+ got := NP{
+ Content: np.Content,
+ Grid: np.Grid,
+ }
+ want := tt.NP
+ if got != want {
+ t.Fatalf("\n got:{%v} \nwant:{%v}\n", got, want)
+ }
+ })
+ }
+}
+
+// NP wraps the layout data for a NinePatch for convenient equality testing.
+type NP struct {
+ Content layout.Inset
+ Grid
+}
+
+func (np NP) String() string {
+ return fmt.Sprintf(
+ "Content: %+v, Stretch: {X1:%dpx, X2:%dpx, Y1:%dpx, Y2:%dpx}",
+ np.Content, np.X1, np.X2, np.Y1, np.Y2)
+}
+
+// Img wraps an image.NRGBA with mutators for creating mock 9-Patch images.
+type Img struct {
+ *image.NRGBA
+}
+
+// NewImg allocates an Img for the given size.
+func NewImg(sz image.Point) *Img {
+ return &Img{
+ NRGBA: image.NewNRGBA(image.Rectangle{Max: sz}),
+ }
+}
+
+// LeftBorder renders a line along the first column of pixels.
+func (img *Img) LeftBorder(start, size int) *Img {
+ for ii := start; ii < start+size-1; ii++ {
+ img.Set(img.Bounds().Min.X, ii, color.NRGBA{A: 255})
+ }
+ return img
+}
+
+// RightBorder renders a line along the last column of pixels.
+func (img *Img) RightBorder(start, size int) *Img {
+ for ii := start; ii < start+size-1; ii++ {
+ img.Set(img.Bounds().Max.X-1, ii, color.NRGBA{A: 255})
+ }
+ return img
+}
+
+// TopBorder renders a line along the first row of pixels.
+func (img *Img) TopBorder(start, size int) *Img {
+ for ii := start; ii < start+size-1; ii++ {
+ img.Set(ii, img.Bounds().Min.Y, color.NRGBA{A: 255})
+ }
+ return img
+}
+
+// BottomBorder renders a line along the last row of pixels.
+func (img *Img) BottomBorder(start, size int) *Img {
+ for ii := start; ii < start+size-1; ii++ {
+ img.Set(ii, img.Bounds().Max.Y-1, color.NRGBA{A: 255})
+ }
+ return img
+}
+
+// open and decode a png from resources. Panic on failure.
+func open(path string) image.Image {
+ imgf, err := res.Resources.Open(path)
+ if err != nil {
+ panic(fmt.Errorf("opening 9-Patch image: %v", err))
+ }
+ defer imgf.Close()
+ img, err := png.Decode(imgf)
+ if err != nil {
+ panic(fmt.Errorf("decoding png: %v", err))
+ }
+ return img
+}