~gioverse/chat

80bfa587cc9c8f08ddceb6a4a6b9f05f64d79116 — Jack Mordaunt 6 months ago 2df89e0 9patch
ninepatch,example/ninepatch: implement ninepatch

- implement NinePatch
- test harness for decoder
- scales according to screen density, manually overrideable
- tool for exercising layout (example/ninepatch)

Revisions
- fix typos
- privatize `eraseBorder` helper
- note the 1px transparent border
- factor pixel walking into helper function

Signed-off-by: Jack Mordaunt <jackmordaunt.dev@gmail.com>
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 := &gtx.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 := &gtx.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
}