~eliasnaur/gio-patches

gio: widget/material: add Decorations app: use material.Decorations as default v1 PROPOSED

~pierrec: 1
 widget/material: add Decorations app: use material.Decorations as default

 15 files changed, 736 insertions(+), 17 deletions(-)
#675090 apple.yml failed
#675091 freebsd.yml failed
#675092 linux.yml failed
#675093 openbsd.yml failed
Export patchset (mbox)
How do I use this?

Copy & paste the following snippet into your terminal to import this patchset into git:

curl -s https://lists.sr.ht/~eliasnaur/gio-patches/patches/28454/mbox | git am -3
Learn more about email & git

[PATCH gio] widget/material: add Decorations app: use material.Decorations as default Export this patch

From: Pierre Curto <pierre.curto@gmail.com>

This patch implements a mechanism for customizing window
decorations.
If a window is configured with app.Decorated(true), then the
decorations provided by the app.Decorate(app.Decorations) option
are applied. Custom decorations can be provided but by default
it uses the material.Decorations ones.

Decorations are automatically applied on Wayland if the Wayland
server does not provide window decorations.

Signed-off-by: Pierre Curto <pierre.curto@gmail.com>
---
 app/os.go                      |  63 ++++++-
 app/os_android.go              |  12 ++
 app/os_ios.go                  |  12 ++
 app/os_js.go                   |  12 ++
 app/os_macos.go                |  12 ++
 app/os_wayland.go              | 162 ++++++++++++++++--
 app/os_windows.go              |  13 +-
 app/os_x11.go                  |  12 ++
 app/window.go                  |  47 +++++-
 internal/ops/ops.go            |   7 +
 io/pointer/pointer.go          |   9 +
 io/system/decoration.go        |  95 +++++++++++
 layout/context.go              |   4 +-
 op/op.go                       |   4 +
 widget/material/decorations.go | 289 +++++++++++++++++++++++++++++++++
 15 files changed, 736 insertions(+), 17 deletions(-)
 create mode 100644 io/system/decoration.go
 create mode 100644 widget/material/decorations.go

diff --git a/app/os.go b/app/os.go
index 329f86d0..6b8c7476 100644
--- a/app/os.go
+++ b/app/os.go
@@ -7,11 +7,11 @@ import (
	"image"
	"image/color"

	"gioui.org/io/key"

	"gioui.org/gpu"
	"gioui.org/io/key"
	"gioui.org/io/pointer"
	"gioui.org/io/system"
	"gioui.org/op"
	"gioui.org/unit"
)

@@ -43,6 +43,9 @@ type Config struct {
	CustomRenderer bool
	// center is a flag used to center the window. Set by option.
	center bool
	// Decorated reports whether window decorations are active.
	Decorated   bool
	decorations Decorations
}

// ConfigEvent is sent whenever the configuration of a Window changes.
@@ -177,6 +180,62 @@ type driver interface {

	// Wakeup wakes up the event loop and sends a WakeupEvent.
	Wakeup()

	// Decorated reports whether decorations are active.
	Decorated() bool
	// Decorate the window.
	Decorate() decorations
	// Move initiates a window move gesture, usually in response to
	// a mouse button press on a custom window title.
	Move()
	// Resize initiates a window resize gesture, usually in response to
	// a mouse drag on window borders or corners.
	Resize(system.Action)
}

// Decorations applied to a window.
type Decorations interface {
	Decorate(e system.FrameEvent, ops *op.Ops, title string) system.Insets
	Actioned() system.Action
}

type decorations struct {
	Decorations
	title string
}

var allActions system.Action

func init() {
	for a := range actions {
		allActions |= a
	}
}

var actions = map[system.Action]func(driver, system.Action){
	system.ActionMinimize:        optionAction(Minimized.Option()),
	system.ActionMaximize:        optionAction(Maximized.Option()),
	system.ActionUnmaximize:      optionAction(Windowed.Option()),
	system.ActionClose:           func(d driver, a system.Action) { d.Close() },
	system.ActionMove:            func(d driver, a system.Action) { d.Move() },
	system.ActionResizeNorth:     resizeAction,
	system.ActionResizeSouth:     resizeAction,
	system.ActionResizeWest:      resizeAction,
	system.ActionResizeEast:      resizeAction,
	system.ActionResizeNorthWest: resizeAction,
	system.ActionResizeSouthWest: resizeAction,
	system.ActionResizeNorthEast: resizeAction,
	system.ActionResizeSouthEast: resizeAction,
}

func optionAction(option Option) func(d driver, a system.Action) {
	return func(d driver, a system.Action) {
		d.Configure([]Option{option})
	}
}

func resizeAction(d driver, a system.Action) {
	d.Resize(a)
}

type windowRendezvous struct {
diff --git a/app/os_android.go b/app/os_android.go
index 5540a6c3..901a2bc4 100644
--- a/app/os_android.go
+++ b/app/os_android.go
@@ -1190,6 +1190,18 @@ func (w *window) Configure(options []Option) {
	})
}

func (w *window) Decorated() bool {
	return false
}

func (w *window) Decorate() decorations {
	return decorations{nil, ""}
}

func (w *window) Move() {}

func (w *window) Resize(system.Action) {}

func (w *window) Raise() {}

func (w *window) SetCursor(name pointer.CursorName) {
diff --git a/app/os_ios.go b/app/os_ios.go
index df45fde9..f512018e 100644
--- a/app/os_ios.go
+++ b/app/os_ios.go
@@ -275,6 +275,18 @@ func (w *window) WriteClipboard(s string) {

func (w *window) Configure([]Option) {}

func (w *window) Decorated() bool {
	return false
}

func (w *window) Decorate() decorations {
	return decorations{nil, ""}
}

func (w *window) Move() {}

func (w *window) Resize(system.Action) {}

func (w *window) Raise() {}

func (w *window) SetAnimating(anim bool) {
diff --git a/app/os_js.go b/app/os_js.go
index 55ec2694..d05c5aa6 100644
--- a/app/os_js.go
+++ b/app/os_js.go
@@ -533,6 +533,18 @@ func (w *window) Configure(options []Option) {
	}
}

func (w *window) Decorated() bool {
	return false
}

func (w *window) Decorate() decorations {
	return decorations{nil, ""}
}

func (w *window) Move() {}

func (w *window) Resize(system.Action) {}

func (w *window) Raise() {}

func (w *window) SetCursor(name pointer.CursorName) {
diff --git a/app/os_macos.go b/app/os_macos.go
index 3cbbcc3c..c3f1a34a 100644
--- a/app/os_macos.go
+++ b/app/os_macos.go
@@ -283,6 +283,18 @@ func (w *window) Configure(options []Option) {
	}
}

func (w *window) Decorated() bool {
	return false
}

func (w *window) Decorate() decorations {
	return decorations{nil, ""}
}

func (w *window) Move() {}

func (w *window) Resize(system.Action) {}

func (w *window) SetCursor(name pointer.CursorName) {
	w.cursor = windowSetCursor(w.cursor, name)
}
diff --git a/app/os_wayland.go b/app/os_wayland.go
index 157e5aba..99b34fe1 100644
--- a/app/os_wayland.go
+++ b/app/os_wayland.go
@@ -149,6 +149,7 @@ type repeatState struct {
type window struct {
	w          *callbacks
	disp       *wlDisplay
	seat       *wlSeat
	surf       *C.struct_wl_surface
	wmSurf     *C.struct_xdg_surface
	topLvl     *C.struct_xdg_toplevel
@@ -188,8 +189,10 @@ type window struct {
	newScale bool
	scale    int
	// size is the unscaled window size (unlike config.Size which is scaled).
	size   image.Point
	config Config
	size         image.Point
	config       Config
	wsize        image.Point // window config size before going fullscreen or maximized
	inCompositor bool        // window is moving or being resized

	wakeups chan struct{}
}
@@ -211,7 +214,7 @@ type wlOutput struct {
}

// callbackMap maps Wayland native handles to corresponding Go
// references. It is necessary because the the Wayland client API
// references. It is necessary because the Wayland client API
// forces the use of callbacks and storing pointers to Go values
// in C is forbidden.
var callbackMap sync.Map
@@ -240,6 +243,9 @@ func newWLWindow(callbacks *callbacks, options []Option) error {
		return err
	}
	w.w = callbacks
	if w.decor == nil {
		options = append(options, Decorated(true))
	}
	go func() {
		defer d.destroy()
		defer w.destroy()
@@ -496,9 +502,11 @@ func gio_onToplevelConfigure(data unsafe.Pointer, topLvl *C.struct_xdg_toplevel,
	w := callbackLoad(data).(*window)
	if width != 0 && height != 0 {
		w.size = image.Pt(int(width), int(height))
		w.config.Size = w.size
		w.updateOpaqueRegion()
	}
	//TODO need to send an ack right?
	//https://wayland.app/protocols/xdg-shell#xdg_toplevel:event:configure
	C.xdg_surface_ack_configure(w.wmSurf, w.serial)
}

//export gio_onOutputMode
@@ -552,6 +560,9 @@ func gio_onSurfaceEnter(data unsafe.Pointer, surf *C.struct_wl_surface, output *
		conf.windows = append(conf.windows, w)
	}
	w.updateOutputs()
	if w.config.Mode == Minimized {
		w.Configure([]Option{Windowed.Option()})
	}
}

//export gio_onSurfaceLeave
@@ -767,15 +778,22 @@ func gio_onPointerEnter(data unsafe.Pointer, pointer *C.struct_wl_pointer, seria
	s := callbackLoad(data).(*wlSeat)
	s.serial = serial
	w := callbackLoad(unsafe.Pointer(surf)).(*window)
	w.seat = s
	s.pointerFocus = w
	w.setCursor(pointer, serial)
	w.lastPos = f32.Point{X: fromFixed(x), Y: fromFixed(y)}
}

//export gio_onPointerLeave
func gio_onPointerLeave(data unsafe.Pointer, p *C.struct_wl_pointer, serial C.uint32_t, surface *C.struct_wl_surface) {
func gio_onPointerLeave(data unsafe.Pointer, p *C.struct_wl_pointer, serial C.uint32_t, surf *C.struct_wl_surface) {
	w := callbackLoad(unsafe.Pointer(surf)).(*window)
	w.seat = nil
	s := callbackLoad(data).(*wlSeat)
	s.serial = serial
	if w.inCompositor {
		w.inCompositor = false
		w.w.Event(pointer.Event{Type: pointer.Cancel})
	}
}

//export gio_onPointerMotion
@@ -813,6 +831,7 @@ func gio_onPointerButton(data unsafe.Pointer, p *C.struct_wl_pointer, serial, t,
	case 0:
		w.pointerBtns &^= btn
		typ = pointer.Release
		w.inCompositor = false
	case 1:
		w.pointerBtns |= btn
		typ = pointer.Press
@@ -920,22 +939,127 @@ func (w *window) Configure(options []Option) {
	prev := w.config
	cnf := w.config
	cnf.apply(cfg, options)
	if prev.Size != cnf.Size {
		w.size = image.Pt(cnf.Size.X/w.scale, cnf.Size.Y/w.scale)
		w.config.Size = cnf.Size

	switch cnf.Mode {
	case Fullscreen:
		switch prev.Mode {
		case Minimized, Fullscreen:
		default:
			w.config.Mode = Fullscreen
			w.wsize = w.config.Size
			C.xdg_toplevel_set_fullscreen(w.topLvl, nil)
		}
	case Minimized:
		switch prev.Mode {
		case Minimized, Fullscreen:
		default:
			w.config.Mode = Minimized
			C.xdg_toplevel_set_minimized(w.topLvl)
		}
	case Maximized:
		switch prev.Mode {
		case Minimized, Fullscreen:
		default:
			w.config.Mode = Maximized
			w.wsize = w.config.Size
			C.xdg_toplevel_set_maximized(w.topLvl)
			w.setTitle(prev, cnf)
		}
	case Windowed:
		switch prev.Mode {
		case Fullscreen:
			w.config.Mode = Windowed
			w.size = w.wsize.Div(w.scale)
			C.xdg_toplevel_unset_fullscreen(w.topLvl)
		case Minimized:
			w.config.Mode = Windowed
		case Maximized:
			w.config.Mode = Windowed
			w.size = w.wsize.Div(w.scale)
			C.xdg_toplevel_unset_maximized(w.topLvl)
		}
		w.setTitle(prev, cnf)
		if prev.Size != cnf.Size {
			w.config.Size = cnf.Size
			w.size = cnf.Size.Div(w.scale)
		}
		if prev.MinSize != cnf.MinSize {
			w.config.MinSize = cnf.MinSize
			C.xdg_toplevel_set_min_size(w.topLvl, C.int32_t(cnf.MinSize.X), C.int32_t(cnf.MinSize.Y))
		}
		if prev.MaxSize != cnf.MaxSize {
			w.config.MaxSize = cnf.MaxSize
			C.xdg_toplevel_set_max_size(w.topLvl, C.int32_t(cnf.MaxSize.X), C.int32_t(cnf.MaxSize.Y))
		}
	}
	if cnf.Decorated != prev.Decorated {
		w.config.Decorated = cnf.Decorated
	}
	if cnf.decorations != prev.decorations {
		w.config.decorations = cnf.decorations
	}
	if w.config != prev {
		w.w.Event(ConfigEvent{Config: w.config})
	}
}

func (w *window) setTitle(prev, cnf Config) {
	if prev.Title != cnf.Title {
		w.config.Title = cnf.Title
		title := C.CString(cnf.Title)
		C.xdg_toplevel_set_title(w.topLvl, title)
		C.free(unsafe.Pointer(title))
	}
	if w.config != prev {
		w.w.Event(ConfigEvent{Config: w.config})
}

func (w *window) Decorated() bool {
	return w.config.Decorated
}

func (w *window) Decorate() decorations {
	return decorations{w.config.decorations, w.config.Title}
}

func (w *window) Move() {
	if !w.inCompositor && w.seat != nil {
		w.inCompositor = true
		s := w.seat
		C.xdg_toplevel_move(w.topLvl, s.seat, s.serial)
	}
}

func (w *window) Raise() {}
func (w *window) Resize(a system.Action) {
	if w.inCompositor || w.seat == nil {
		return
	}
	var edge int
	switch a {
	case system.ActionResizeNorth: // top
		edge = 1
	case system.ActionResizeSouth: // bottom
		edge = 2
	case system.ActionResizeEast: // left
		edge = 4
	case system.ActionResizeWest: // right
		edge = 8
	case system.ActionResizeNorthWest: // top_left
		edge = 5
	case system.ActionResizeNorthEast: // bottom_left
		edge = 6
	case system.ActionResizeSouthEast: // bottom_right
		edge = 10
	case system.ActionResizeSouthWest: // top_right
		edge = 9
	}
	w.inCompositor = true
	s := w.seat
	C.xdg_toplevel_resize(w.topLvl, s.seat, s.serial, C.uint32_t(edge))
}

func (w *window) Raise() {
	// NB. there is no way for a minimized window to be unminimized.
	// https://wayland.app/protocols/xdg-shell#xdg_toplevel:request:set_minimized
}

func (w *window) SetCursor(name pointer.CursorName) {
	ptr := w.disp.seat.pointer
@@ -963,6 +1087,22 @@ func (w *window) SetCursor(name pointer.CursorName) {
		name = "left_side"
	case pointer.CursorGrab:
		name = "hand1"
	case pointer.CursorTopLeftResize:
		name = "top_left_corner"
	case pointer.CursorTopRightResize:
		name = "bottom_left_corner"
	case pointer.CursorBottomLeftResize:
		name = "top_right_corner"
	case pointer.CursorBottomRightResize:
		name = "bottom_right_corner"
	case pointer.CursorLeftResize:
		name = "right_side"
	case pointer.CursorRightResize:
		name = "left_side"
	case pointer.CursorTopResize:
		name = "top_side"
	case pointer.CursorBottomResize:
		name = "bottom_side"
	}
	cname := C.CString(string(name))
	defer C.free(unsafe.Pointer(cname))
diff --git a/app/os_windows.go b/app/os_windows.go
index 42594bd2..7ebbb8bc 100644
--- a/app/os_windows.go
+++ b/app/os_windows.go
@@ -569,13 +569,24 @@ func (w *window) Configure(options []Option) {
		)
		windows.ShowWindow(w.hwnd, windows.SW_SHOWNORMAL)
	}

	// A config event is sent to the main event loop whenever the configuration is changed
	if oldConfig.Mode != w.config.Mode || oldConfig.Size != w.config.Size {
		w.w.Event(ConfigEvent{Config: w.config})
	}
}

func (w *window) Decorated() bool {
	return false
}

func (w *window) Decorate() decorations {
	return decorations{nil, ""}
}

func (w *window) Move() {}

func (w *window) Resize(system.Action) {}

func (w *window) WriteClipboard(s string) {
	w.writeClipboard(s)
}
diff --git a/app/os_x11.go b/app/os_x11.go
index 1c752473..35918a2a 100644
--- a/app/os_x11.go
+++ b/app/os_x11.go
@@ -205,6 +205,18 @@ func (w *x11Window) Configure(options []Option) {
	}
}

func (w *x11Window) Decorated() bool {
	return w.config.Decorated
}

func (w *x11Window) Decorate() decorations {
	return decorations{nil, ""}
}

func (w *x11Window) Move() {}

func (w *x11Window) Resize(system.Action) {}

func (w *x11Window) Raise() {
	var xev C.XEvent
	ev := (*C.XClientMessageEvent)(unsafe.Pointer(&xev))
diff --git a/app/window.go b/app/window.go
index 7433ba3f..66c58b6f 100644
--- a/app/window.go
+++ b/app/window.go
@@ -11,6 +11,7 @@ import (
	"time"

	"gioui.org/f32"
	"gioui.org/font/gofont"
	"gioui.org/gpu"
	"gioui.org/io/event"
	"gioui.org/io/pointer"
@@ -19,6 +20,7 @@ import (
	"gioui.org/io/system"
	"gioui.org/op"
	"gioui.org/unit"
	"gioui.org/widget/material"

	_ "gioui.org/app/internal/log"
)
@@ -59,8 +61,9 @@ type Window struct {
	nextFrame    time.Time
	delayedDraw  *time.Timer

	queue  queue
	cursor pointer.CursorName
	queue   queue
	cursor  pointer.CursorName
	decoOps op.Ops

	callbacks callbacks

@@ -105,9 +108,14 @@ var ackEvent event.Event
// Calling NewWindow more than once is not supported on
// iOS, Android, WebAssembly.
func NewWindow(options ...Option) *Window {
	theme := material.NewTheme(gofont.Collection())
	deco := &material.Decorations{
		DecorationsStyle: material.Decorate(theme, allActions),
	}
	defaultOptions := []Option{
		Size(unit.Dp(800), unit.Dp(600)),
		Title("Gio"),
		Decorate(deco),
	}
	options = append(defaultOptions, options...)
	var cnf Config
@@ -574,8 +582,10 @@ func (w *Window) processEvent(d driver, e event.Event) {
		w.hasNextFrame = false
		e2.Frame = w.update
		e2.Queue = &w.queue
		deco := w.decorate(d, e2.FrameEvent)
		w.out <- e2.FrameEvent
		frame, gotFrame := w.waitFrame()
		deco.Add(frame)
		err := w.validateAndProcess(d, e2.Size, e2.Sync, frame)
		if gotFrame {
			// We're done with frame, let the client continue.
@@ -660,6 +670,25 @@ func (w *Window) updateCursor(d driver) {
	}
}

func (w *Window) decorate(d driver, e system.FrameEvent) op.CallOp {
	deco := d.Decorate()
	if deco.Decorations == nil {
		return op.CallOp{}
	}
	o := &w.decoOps
	o.Reset()
	rec := op.Record(o)
	e.Insets = deco.Decorate(e, o, deco.title)
	actioned := deco.Actioned()
	for a := system.Action(1); actioned != 0; a <<= 1 {
		if actioned&a != 0 {
			actions[a](d, a)
			actioned &^= a
		}
	}
	return rec.Stop()
}

// Raise requests that the platform bring this window to the top of all open windows.
// Some platforms do not allow this except under certain circumstances, such as when
// a window from the same application already has focus. If the platform does not
@@ -760,3 +789,17 @@ func CustomRenderer(custom bool) Option {
		cnf.CustomRenderer = custom
	}
}

// Decorated turns window decorations on.
func Decorated(flag bool) Option {
	return func(_ unit.Metric, cnf *Config) {
		cnf.Decorated = flag
	}
}

// Decorate a window.
func Decorate(deco Decorations) Option {
	return func(_ unit.Metric, cnf *Config) {
		cnf.decorations = deco
	}
}
diff --git a/internal/ops/ops.go b/internal/ops/ops.go
index ce076947..af6cfc45 100644
--- a/internal/ops/ops.go
+++ b/internal/ops/ops.go
@@ -25,6 +25,7 @@ type Ops struct {
	// multipOp indicates a multi-op such as clip.Path is being added.
	multipOp bool

	recording  bool
	macroStack stack
	stacks     [5]stack
}
@@ -224,11 +225,17 @@ func WriteMulti(o *Ops, n int) []byte {
}

func PushMacro(o *Ops) StackID {
	o.recording = true
	return o.macroStack.push()
}

func PopMacro(o *Ops, id StackID) {
	o.macroStack.pop(id)
	o.recording = false
}

func Recording(o *Ops) bool {
	return o.recording
}

func FillMacro(o *Ops, startPC PC) {
diff --git a/io/pointer/pointer.go b/io/pointer/pointer.go
index 97987f7c..b6ce9eb9 100644
--- a/io/pointer/pointer.go
+++ b/io/pointer/pointer.go
@@ -110,6 +110,15 @@ const (
	CursorGrab CursorName = "grab"
	// CursorNone hides the cursor. To show it again, use any other cursor.
	CursorNone CursorName = "none"

	CursorTopLeftResize     = "top-left-resize"
	CursorTopRightResize    = "top-right-resize"
	CursorBottomLeftResize  = "bottom-left-resize"
	CursorBottomRightResize = "bottom-right-resize"
	CursorLeftResize        = "left-resize"
	CursorRightResize       = "right-resize"
	CursorTopResize         = "top-resize"
	CursorBottomResize      = "bottom-resize"
)

const (
diff --git a/io/system/decoration.go b/io/system/decoration.go
new file mode 100644
index 00000000..71196c2d
--- /dev/null
+++ b/io/system/decoration.go
@@ -0,0 +1,95 @@
package system

import (
	"strings"

	"gioui.org/io/pointer"
)

// Action is a set of window decoration actions.
type Action uint

const (
	ActionMinimize Action = 1 << iota
	ActionMaximize
	ActionUnmaximize
	ActionClose
	ActionMove
	ActionResizeNorth
	ActionResizeSouth
	ActionResizeWest
	ActionResizeEast
	ActionResizeNorthWest
	ActionResizeSouthWest
	ActionResizeNorthEast
	ActionResizeSouthEast
)

func (a Action) CursorName() pointer.CursorName {
	var name pointer.CursorName
	switch a {
	case ActionResizeNorthWest:
		name = pointer.CursorTopLeftResize
	case ActionResizeSouthEast:
		name = pointer.CursorBottomRightResize
	case ActionResizeNorthEast:
		name = pointer.CursorTopRightResize
	case ActionResizeSouthWest:
		name = pointer.CursorBottomLeftResize
	case ActionResizeWest:
		name = pointer.CursorLeftResize
	case ActionResizeEast:
		name = pointer.CursorRightResize
	case ActionResizeNorth:
		name = pointer.CursorTopResize
	case ActionResizeSouth:
		name = pointer.CursorBottomResize
	}
	return name
}

func (a Action) String() string {
	var buf strings.Builder
	for b := Action(1); a != 0; b <<= 1 {
		if a&b != 0 {
			if buf.Len() > 0 {
				buf.WriteByte('|')
			}
			buf.WriteString(b.string())
			a &^= b
		}
	}
	return buf.String()
}

func (a Action) string() string {
	switch a {
	case ActionMinimize:
		return "ActionMinimize"
	case ActionMaximize:
		return "ActionMaximize"
	case ActionUnmaximize:
		return "ActionUnmaximize"
	case ActionClose:
		return "ActionClose"
	case ActionMove:
		return "ActionMove"
	case ActionResizeNorth:
		return "ActionResizeNorth"
	case ActionResizeSouth:
		return "ActionResizeSouth"
	case ActionResizeWest:
		return "ActionResizeWest"
	case ActionResizeEast:
		return "ActionResizeEast"
	case ActionResizeNorthWest:
		return "ActionResizeNorthWest"
	case ActionResizeSouthWest:
		return "ActionResizeSouthWest"
	case ActionResizeNorthEast:
		return "ActionResizeNorthEast"
	case ActionResizeSouthEast:
		return "ActionResizeSouthEast"
	}
	return ""
}
diff --git a/layout/context.go b/layout/context.go
index 5d314961..2f39df2d 100644
--- a/layout/context.go
+++ b/layout/context.go
@@ -42,7 +42,9 @@ type Context struct {
//
// NewContext calls ops.Reset and adjusts ops for e.Insets.
func NewContext(ops *op.Ops, e system.FrameEvent) Context {
	ops.Reset()
	if !ops.Recording() {
		ops.Reset()
	}

	size := e.Size

diff --git a/op/op.go b/op/op.go
index f0b2620c..c4b4a9af 100644
--- a/op/op.go
+++ b/op/op.go
@@ -145,6 +145,10 @@ func (o *Ops) Reset() {
	ops.Reset(&o.Internal)
}

func (o *Ops) Recording() bool {
	return ops.Recording(&o.Internal)
}

// Record a macro of operations.
func Record(o *Ops) MacroOp {
	m := MacroOp{
diff --git a/widget/material/decorations.go b/widget/material/decorations.go
new file mode 100644
index 00000000..64ecd5f8
--- /dev/null
+++ b/widget/material/decorations.go
@@ -0,0 +1,289 @@
package material

import (
	"image"
	"image/color"
	"math/bits"

	"gioui.org/f32"
	"gioui.org/gesture"
	"gioui.org/io/pointer"
	"gioui.org/io/system"
	"gioui.org/layout"
	"gioui.org/op"
	"gioui.org/op/clip"
	"gioui.org/op/paint"
	"gioui.org/unit"
	"gioui.org/widget"
)

// DecorationsStyle provides the style elements for Decorations.
type DecorationsStyle struct {
	Actions    system.Action
	Title      LabelStyle
	Background color.NRGBA
	Foreground color.NRGBA
}

// Decorate a window.
func Decorate(th *Theme, actions system.Action) DecorationsStyle {
	titleStyle := Body1(th, "")
	titleStyle.Color = th.Palette.ContrastFg
	return DecorationsStyle{
		Actions:    actions,
		Title:      titleStyle,
		Background: th.Palette.ContrastBg,
		Foreground: th.Palette.ContrastFg,
	}
}

// Decorations provides window decorations.
type Decorations struct {
	DecorationsStyle
	actions struct {
		layout.List
		clicks []widget.Clickable
		move   gesture.Drag
		resize [8]struct {
			gesture.Hover
			gesture.Drag
		}
	}
	actioned  system.Action
	path      clip.Path
	maximized bool
}

func (d *Decorations) Decorate(e system.FrameEvent, ops *op.Ops, title string) system.Insets {
	gtx := layout.NewContext(ops, e)

	rec := op.Record(gtx.Ops)
	dims := d.layoutDecorations(gtx, title)
	decos := rec.Stop()
	r := clip.Rect{Max: dims.Size}
	paint.FillShape(gtx.Ops, d.DecorationsStyle.Background, r.Op())
	decos.Add(gtx.Ops)
	d.layoutResizing(gtx)

	return system.Insets{
		Top:    unit.Px(float32(dims.Size.Y)),
		Bottom: unit.Value{},
		Left:   unit.Value{},
		Right:  unit.Value{},
	}
}

func (d *Decorations) layoutResizing(gtx layout.Context) {
	cs := gtx.Constraints.Min
	wh := gtx.Px(unit.Dp(10))
	for i, data := range []struct {
		system.Action
		image.Rectangle
	}{
		{system.ActionResizeNorth, image.Rect(0, 0, cs.X, wh)},
		{system.ActionResizeSouth, image.Rect(0, cs.Y-wh, cs.X, cs.Y)},
		{system.ActionResizeWest, image.Rect(cs.X-wh, 0, cs.X, cs.Y)},
		{system.ActionResizeEast, image.Rect(0, 0, wh, cs.Y)},
		{system.ActionResizeNorthWest, image.Rect(0, 0, wh, wh)},
		{system.ActionResizeSouthWest, image.Rect(cs.X-wh, 0, cs.X, wh)},
		{system.ActionResizeNorthEast, image.Rect(0, cs.Y-wh, wh, cs.Y)},
		{system.ActionResizeSouthEast, image.Rect(cs.X-wh, cs.Y-wh, cs.X, cs.Y)},
	} {
		action := data.Action
		if d.DecorationsStyle.Actions&action == 0 {
			continue
		}
		rsz := &d.actions.resize[i]
		rsz.Events(gtx.Metric, gtx, gesture.Both)
		if rsz.Drag.Dragging() {
			d.actioned |= action
		}
		st := clip.Rect(data.Rectangle).Push(gtx.Ops)
		if rsz.Hover.Hovered(gtx) {
			pointer.CursorNameOp{Name: action.CursorName()}.Add(gtx.Ops)
		}
		//paint.Fill(gtx.Ops, color.NRGBA{A: 100})
		rsz.Drag.Add(gtx.Ops)
		pass := pointer.PassOp{}.Push(gtx.Ops)
		rsz.Hover.Add(gtx.Ops)
		pass.Pop()
		st.Pop()
	}
}

func (d *Decorations) layoutDecorations(gtx layout.Context, title string) layout.Dimensions {
	gtx.Constraints.Min.X = gtx.Constraints.Max.X
	gtx.Constraints.Min.Y = 0
	inset := layout.UniformInset(unit.Dp(10))
	return layout.Flex{
		Axis:      layout.Horizontal,
		Alignment: layout.Middle,
		Spacing:   layout.SpaceBetween,
	}.Layout(gtx,
		layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
			d.DecorationsStyle.Title.Text = title
			dims := inset.Layout(gtx, d.DecorationsStyle.Title.Layout)
			if d.DecorationsStyle.Actions&system.ActionMove != 0 {
				d.actions.move.Events(gtx.Metric, gtx, gesture.Both)

				st := clip.Rect{Max: dims.Size}.Push(gtx.Ops)
				d.actions.move.Add(gtx.Ops)
				if d.actions.move.Pressed() {
					d.actioned |= system.ActionMove
				}
				st.Pop()
			}
			return dims
		}),
		layout.Rigid(func(gtx layout.Context) layout.Dimensions {
			// Remove the unmaximize action as it is taken care of by maximize.
			actions := d.DecorationsStyle.Actions &^ system.ActionUnmaximize
			an := bits.OnesCount(uint(actions))
			if n := len(d.actions.clicks); n < an {
				d.actions.clicks = append(d.actions.clicks, make([]widget.Clickable, an-n)...)
			}
			return d.actions.Layout(gtx, an, func(gtx layout.Context, idx int) layout.Dimensions {
				action := system.Action(1 << idx)
				var w layout.Widget
				switch actions & action {
				case system.ActionMinimize:
					w = d.minimizeWindow
				case system.ActionMaximize:
					if d.maximized {
						w = d.maximizedWindow
					} else {
						w = d.maximizeWindow
					}
				case system.ActionClose:
					w = d.closeWindow
				default:
					return layout.Dimensions{}
				}
				click := &d.actions.clicks[idx]
				if click.Clicked() {
					if action == system.ActionMaximize {
						if d.maximized {
							d.maximized = false
							d.actioned |= system.ActionUnmaximize
						} else {
							d.maximized = true
							d.actioned |= system.ActionMaximize
						}
					} else {
						d.actioned |= action
					}
				}
				return Clickable(gtx, click, func(gtx layout.Context) layout.Dimensions {
					return inset.Layout(gtx, w)
				})
			})
		}),
	)
}

func (d *Decorations) Actioned() system.Action {
	a := d.actioned
	d.actioned = 0
	return a
}

var (
	winIconSize   = unit.Dp(20)
	winIconMargin = unit.Dp(4)
	winIconStroke = unit.Dp(2)
)

// line
func (d *Decorations) minimizeWindow(gtx layout.Context) layout.Dimensions {
	paint.ColorOp{Color: d.DecorationsStyle.Foreground}.Add(gtx.Ops)
	size := gtx.Px(winIconSize)
	size32 := float32(size)
	margin := float32(gtx.Px(winIconMargin))
	width := float32(gtx.Px(winIconStroke))
	p := &d.path
	p.Begin(gtx.Ops)
	p.MoveTo(f32.Point{X: margin, Y: size32 - margin})
	p.LineTo(f32.Point{X: size32 - 2*margin, Y: size32 - margin})
	st := clip.Stroke{
		Path:  p.End(),
		Width: width,
	}.Op().Push(gtx.Ops)
	paint.PaintOp{}.Add(gtx.Ops)
	st.Pop()
	return layout.Dimensions{Size: image.Pt(size, size)}
}

// rectangle
func (d *Decorations) maximizeWindow(gtx layout.Context) layout.Dimensions {
	paint.ColorOp{Color: d.DecorationsStyle.Foreground}.Add(gtx.Ops)
	size := gtx.Px(winIconSize)
	size32 := float32(size)
	margin := float32(gtx.Px(winIconMargin))
	width := float32(gtx.Px(winIconStroke))
	r := clip.RRect{
		Rect: f32.Rect(margin, margin, size32-margin, size32-margin),
	}
	st := clip.Stroke{
		Path:  r.Path(gtx.Ops),
		Width: width,
	}.Op().Push(gtx.Ops)
	paint.PaintOp{}.Add(gtx.Ops)
	st.Pop()
	r.Rect.Max = f32.Pt(size32-margin, 2*margin)
	st = clip.Outline{
		Path: r.Path(gtx.Ops),
	}.Op().Push(gtx.Ops)
	paint.PaintOp{}.Add(gtx.Ops)
	st.Pop()
	return layout.Dimensions{Size: image.Pt(size, size)}
}

// rectangles
func (d *Decorations) maximizedWindow(gtx layout.Context) layout.Dimensions {
	paint.ColorOp{Color: d.DecorationsStyle.Foreground}.Add(gtx.Ops)
	size := gtx.Px(winIconSize)
	size32 := float32(size)
	margin := float32(gtx.Px(winIconMargin))
	width := float32(gtx.Px(winIconStroke))
	r := clip.RRect{
		Rect: f32.Rect(margin, margin, size32-2*margin, size32-2*margin),
	}
	st := clip.Stroke{
		Path:  r.Path(gtx.Ops),
		Width: width,
	}.Op().Push(gtx.Ops)
	paint.PaintOp{}.Add(gtx.Ops)
	st.Pop()
	r = clip.RRect{
		Rect: f32.Rect(2*margin, 2*margin, size32-margin, size32-margin),
	}
	st = clip.Stroke{
		Path:  r.Path(gtx.Ops),
		Width: width,
	}.Op().Push(gtx.Ops)
	paint.PaintOp{}.Add(gtx.Ops)
	st.Pop()
	return layout.Dimensions{Size: image.Pt(size, size)}
}

// cross
func (d *Decorations) closeWindow(gtx layout.Context) layout.Dimensions {
	paint.ColorOp{Color: d.DecorationsStyle.Foreground}.Add(gtx.Ops)
	size := gtx.Px(winIconSize)
	size32 := float32(size)
	margin := float32(gtx.Px(winIconMargin))
	width := float32(gtx.Px(winIconStroke))
	p := &d.path
	p.Begin(gtx.Ops)
	p.MoveTo(f32.Point{X: margin, Y: margin})
	p.LineTo(f32.Point{X: size32 - margin, Y: size32 - margin})
	p.MoveTo(f32.Point{X: size32 - margin, Y: margin})
	p.LineTo(f32.Point{X: margin, Y: size32 - margin})
	st := clip.Stroke{
		Path:  p.End(),
		Width: width,
	}.Op().Push(gtx.Ops)
	paint.PaintOp{}.Add(gtx.Ops)
	st.Pop()
	return layout.Dimensions{Size: image.Pt(size, size)}
}
-- 
2.32.0
gio/patches: FAILED in 1m53s

[widget/material: add Decorations app: use material.Decorations as default][0] from [~pierrec][1]

[0]: https://lists.sr.ht/~eliasnaur/gio-patches/patches/28454
[1]: mailto:pierre.curto@gmail.com

✗ #675090 FAILED gio/patches/apple.yml   https://builds.sr.ht/~eliasnaur/job/675090
✗ #675092 FAILED gio/patches/linux.yml   https://builds.sr.ht/~eliasnaur/job/675092
✗ #675091 FAILED gio/patches/freebsd.yml https://builds.sr.ht/~eliasnaur/job/675091
✗ #675093 FAILED gio/patches/openbsd.yml https://builds.sr.ht/~eliasnaur/job/675093