diff --git a/io/system/decoration.go b/io/system/decoration.go new file mode 100644 index 00000000..ad008666 --- /dev/null +++ b/io/system/decoration.go @@ -0,0 +1,112 @@ +package system + +import ( + "strings" + + "gioui.org/io/pointer" +) + +// Action is a set of window decoration actions. +type Action uint + +const ( + // ActionMinimize minimizes a window. + ActionMinimize Action = 1 << iota + // ActionMaximize maximizes a window. + ActionMaximize + // ActionUnmaximize restores a maximized window. + ActionUnmaximize + // ActionFullscreen makes a window fullscreen. + ActionFullscreen + // ActionClose closes a window. + ActionClose + // ActionMove moves a window directed by the user. + ActionMove + // ActionResizeNorth resizes the top border of a window (directed by the user). + ActionResizeNorth + // ActionResizeSouth resizes the bottom border of a window (directed by the user). + ActionResizeSouth + // ActionResizeWest resizes the right border of a window (directed by the user). + ActionResizeWest + // ActionResizeEast resizes the left border of a window (directed by the user). + ActionResizeEast + // ActionResizeNorthWest resizes the top-left corner of a window (directed by the user). + ActionResizeNorthWest + // ActionResizeSouthWest resizes the bottom-left corner of a window (directed by the user). + ActionResizeSouthWest + // ActionResizeNorthEast resizes the top-right corner of a window (directed by the user). + ActionResizeNorthEast + // ActionResizeSouthEast resizes the bottom-right corner of a window (directed by the user). + ActionResizeSouthEast +) + +// CursorName returns the cursor for the action. +// It must be a single action otherwise the default +// cursor is returned. +func (a Action) CursorName() pointer.CursorName { + switch a { + case ActionResizeNorthWest: + return pointer.CursorTopLeftResize + case ActionResizeSouthEast: + return pointer.CursorBottomRightResize + case ActionResizeNorthEast: + return pointer.CursorTopRightResize + case ActionResizeSouthWest: + return pointer.CursorBottomLeftResize + case ActionResizeWest: + return pointer.CursorLeftResize + case ActionResizeEast: + return pointer.CursorRightResize + case ActionResizeNorth: + return pointer.CursorTopResize + case ActionResizeSouth: + return pointer.CursorBottomResize + } + return pointer.CursorDefault +} + +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/widget/material/decorations.go b/widget/material/decorations.go new file mode 100644 index 00000000..92f0861a --- /dev/null +++ b/widget/material/decorations.go @@ -0,0 +1,297 @@ +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 +} + +// Decorate a window with the title and actions defined in DecorationsStyle. +// The space used by the decorations is returned as an inset for the window +// content. +func (d *Decorations) Decorate(gtx layout.Context, title string) layout.Inset { + 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 layout.Inset{ + Top: unit.Px(float32(dims.Size.Y)), + } +} + +func (d *Decorations) layoutResizing(gtx layout.Context) { + cs := gtx.Constraints.Min + wh := gtx.Px(unit.Dp(10)) + s := []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)}, + } + for i, data := range s { + 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) + } + 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.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) + }) + }) + }), + ) +} + +// Perform updates the decorations as if the specified actions were +// performed by the user. +func (d *Decorations) Perform(actions system.Action) { + if actions&system.ActionMaximize != 0 { + d.maximized = true + } + if actions&(system.ActionUnmaximize|system.ActionMinimize|system.ActionFullscreen) != 0 { + d.maximized = false + } +} + +// Actions returns the set of actions activated by the user. +func (d *Decorations) Actions() system.Action { + a := d.actioned + d.actioned = 0 + return a +} + +var ( + winIconSize = unit.Dp(20) + winIconMargin = unit.Dp(4) + winIconStroke = unit.Dp(2) +) + +// minimizeWindows draws a line icon representing the minimize action. +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)} +} + +// maximizeWindow draws a rectangle representing the maximize action. +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)} +} + +// maximizedWindow draws interleaved rectangles representing the un-maximize action. +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)} +} + +// closeWindow draws a cross representing the close action. +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)} +}