aboutsummaryrefslogtreecommitdiffstats
path: root/gui/layout
diff options
context:
space:
mode:
authorSam Anthony <sam@samanthony.xyz>2024-01-19 22:07:42 -0500
committerSam Anthony <sam@samanthony.xyz>2024-01-19 22:07:42 -0500
commit54d71a24b6eaa191b2777f6070de252fc26801a3 (patch)
tree1d26f49dfcacc5578b4e6c91e8ffb31b09a9f662 /gui/layout
parent39a3b948e525fe2b22b90e07df323ab77f6a81f6 (diff)
downloadvolute-54d71a24b6eaa191b2777f6070de252fc26801a3.zip
add layout from Keitio
Diffstat (limited to 'gui/layout')
-rw-r--r--gui/layout/doc.go10
-rw-r--r--gui/layout/grid.go115
-rw-r--r--gui/layout/intercepter.go43
-rw-r--r--gui/layout/layout.go20
-rw-r--r--gui/layout/mux.go172
-rw-r--r--gui/layout/scroller.go132
-rw-r--r--gui/layout/split.go26
7 files changed, 518 insertions, 0 deletions
diff --git a/gui/layout/doc.go b/gui/layout/doc.go
new file mode 100644
index 0000000..8305d43
--- /dev/null
+++ b/gui/layout/doc.go
@@ -0,0 +1,10 @@
+/*
+Package layout provides a Layout system for faiface/gui.
+
+The core of the package is the Layout interface, everything else is just
+implementation.
+
+The Layouts represent a Layout, and the Mux makes them usable.
+The Mux basically acts as a sort of driver.
+*/
+package layout
diff --git a/gui/layout/grid.go b/gui/layout/grid.go
new file mode 100644
index 0000000..324353c
--- /dev/null
+++ b/gui/layout/grid.go
@@ -0,0 +1,115 @@
+package layout
+
+import (
+ "image"
+ "image/color"
+ "image/draw"
+ "log"
+
+ "volute/gui"
+)
+
+var _ Layout = Grid{}
+
+// Grid represents a grid with rows and columns in each row.
+// Each row can be a different length.
+type Grid struct {
+ // Rows represents the number of childs of each row.
+ Rows []int
+ // Background represents the background of the grid as a uniform color.
+ Background color.Color
+ // Gap represents the grid gap, equal on all sides.
+ Gap int
+ // Split represents the way the space is divided among the columns in each row.
+ Split SplitFunc
+ // SplitRows represents the way the space is divided among the rows.
+ SplitRows SplitFunc
+
+ Margin int
+ Border int
+ BorderColor color.Color
+
+ // Flip represents the orientation of the grid.
+ // When false, rows are spread in the Y axis and columns in the X axis.
+ // When true, rows are spread in the X axis and columns in the Y axis.
+ Flip bool
+}
+
+func (g Grid) redraw(drw draw.Image, bounds image.Rectangle) {
+ col := g.Background
+ if col == nil {
+ col = color.Black
+ }
+ if g.Border > 0 {
+ bcol := g.BorderColor
+ if bcol == nil {
+ bcol = color.Black
+ }
+ draw.Draw(drw, bounds, image.NewUniform(bcol), image.ZP, draw.Src)
+ }
+ draw.Draw(drw, bounds.Inset(g.Border), image.NewUniform(col), image.ZP, draw.Src)
+}
+
+func (g Grid) Intercept(env gui.Env) gui.Env {
+ return RedrawIntercepter{g.redraw}.Intercept(env)
+}
+
+func (g Grid) Lay(bounds image.Rectangle) []image.Rectangle {
+ gap := g.Gap
+ rows := g.Rows
+ splitMain := g.Split
+ if splitMain == nil {
+ splitMain = EvenSplit
+ }
+ splitSec := g.SplitRows
+ if splitSec == nil {
+ splitSec = EvenSplit
+ }
+ margin := g.Margin
+ flip := g.Flip
+ if margin+gap < 0 {
+ log.Println("Grid goes out of bounds")
+ }
+ if margin+gap < g.Border {
+ log.Println("Grid border will not be shown properly")
+ }
+
+ ret := make([]image.Rectangle, 0)
+
+ // Sorry it's not very understandable
+ var H, W int
+ var mX, mY int
+ if flip {
+ H = bounds.Dx()
+ W = bounds.Dy()
+ mX = bounds.Min.Y
+ mY = bounds.Min.X
+ } else {
+ H = bounds.Dy()
+ W = bounds.Dx()
+ mX = bounds.Min.X
+ mY = bounds.Min.Y
+ }
+ rowsH := splitSec(len(rows), H-(gap*(len(rows)+1))-margin*2)
+ var X int
+ var Y int
+ Y = gap + mY + margin
+ for y, cols := range rows {
+ h := rowsH[y]
+ colsW := splitMain(cols, W-(gap*(cols+1))-margin*2)
+ X = gap + mX + margin
+ for _, w := range colsW {
+ var r image.Rectangle
+ if flip {
+ r = image.Rect(Y, X, Y+h, X+w)
+ } else {
+ r = image.Rect(X, Y, X+w, Y+h)
+ }
+ ret = append(ret, r)
+ X += gap + w
+ }
+ Y += gap + h
+ }
+
+ return ret
+}
diff --git a/gui/layout/intercepter.go b/gui/layout/intercepter.go
new file mode 100644
index 0000000..d25d578
--- /dev/null
+++ b/gui/layout/intercepter.go
@@ -0,0 +1,43 @@
+package layout
+
+import (
+ "image"
+ "image/draw"
+
+ "volute/gui"
+)
+
+// Intercepter represents an element that can interact with Envs.
+// An Intercepter can modify Events, stop them or emit arbitrary ones.
+// It can also put itself in the draw pipeline, for throttling very
+// expensive draw calls for example.
+type Intercepter interface {
+ Intercept(gui.Env) gui.Env
+}
+
+var _ Intercepter = RedrawIntercepter{}
+
+// RedrawIntercepter is a basic Intercepter, it is meant for use in simple Layouts
+// that only need to redraw themselves.
+type RedrawIntercepter struct {
+ Redraw func(draw.Image, image.Rectangle)
+}
+
+// Intercept implements Intercepter
+func (ri RedrawIntercepter) Intercept(env gui.Env) gui.Env {
+ out, in := gui.MakeEventsChan()
+ go func() {
+ for e := range env.Events() {
+ in <- e
+ if resize, ok := e.(gui.Resize); ok {
+ env.Draw() <- func(drw draw.Image) image.Rectangle {
+ bounds := resize.Rectangle
+ ri.Redraw(drw, bounds)
+ return bounds
+ }
+ }
+ }
+ }()
+ ret := &muxEnv{out, env.Draw()}
+ return ret
+}
diff --git a/gui/layout/layout.go b/gui/layout/layout.go
new file mode 100644
index 0000000..4d8e616
--- /dev/null
+++ b/gui/layout/layout.go
@@ -0,0 +1,20 @@
+package layout
+
+import (
+ "image"
+)
+
+// Layout represents any graphical layout
+//
+// Lay represents the way to divide space among your childs.
+// It takes a parameter of how much space is available,
+// and returns where exactly to put its childs.
+//
+// Intercept transforms an Env channel to another.
+// This way the Layout can emit its own Events, re-emit previous ones,
+// or even stop an event from propagating, think win.MoScroll.
+// It can be a no-op.
+type Layout interface {
+ Lay(image.Rectangle) []image.Rectangle
+ Intercepter
+}
diff --git a/gui/layout/mux.go b/gui/layout/mux.go
new file mode 100644
index 0000000..7b193e7
--- /dev/null
+++ b/gui/layout/mux.go
@@ -0,0 +1,172 @@
+package layout
+
+import (
+ "image"
+ "image/draw"
+ "log"
+ "sync"
+
+ "volute/gui"
+)
+
+// Mux can be used to multiplex an Env, let's call it a root Env. Mux implements a way to
+// create multiple virtual Envs that all interact with the root Env. They receive the same
+// events apart from gui.Resize, and their draw functions get redirected to the root Env.
+//
+// All gui.Resize events are instead modified according to the underlying Layout.
+// The master Env gets the original gui.Resize events.
+type Mux struct {
+ mu sync.Mutex
+ lastResize gui.Event
+ eventsIns []chan<- gui.Event
+ draw chan<- func(draw.Image) image.Rectangle
+
+ layout Layout
+}
+
+// Layout returns the underlying Layout of the Mux.
+func (mux *Mux) Layout() Layout {
+ return mux.layout
+}
+
+// NewMux should only be used internally by Layouts.
+// It has mostly the same behaviour as gui.Mux, except for its use of an underlying Layout
+// for modifying the gui.Resize events sent to the childs.
+func NewMux(ev gui.Env, envs []*gui.Env, l Layout) (mux *Mux, master gui.Env) {
+ env := l.Intercept(ev)
+ drawChan := make(chan func(draw.Image) image.Rectangle)
+ mux = &Mux{
+ layout: l,
+ draw: drawChan,
+ }
+ master, masterIn := mux.makeEnv(true)
+ events := make(chan gui.Event)
+ go func() {
+ for d := range drawChan {
+ env.Draw() <- d
+ }
+ close(env.Draw())
+ }()
+
+ go func() {
+ for e := range env.Events() {
+ events <- e
+ }
+ }()
+
+ go func() {
+ for e := range events {
+ // master gets a copy of all events to the Mux
+ masterIn <- e
+ mux.mu.Lock()
+ if resize, ok := e.(gui.Resize); ok {
+ mux.lastResize = resize
+ rect := resize.Rectangle
+ lay := mux.layout.Lay(rect)
+ if len(lay) < len(envs) {
+ log.Printf("Lay of %T is not large enough (%d) for %d childs, skipping\n", l, len(lay), len(envs))
+ mux.mu.Unlock()
+ continue
+ }
+
+ // Send appropriate resize Events to childs
+ for i, eventsIn := range mux.eventsIns {
+ resize.Rectangle = lay[i]
+ eventsIn <- resize
+ }
+
+ } else {
+ for _, eventsIn := range mux.eventsIns {
+ eventsIn <- e
+ }
+ }
+ mux.mu.Unlock()
+ }
+ mux.mu.Lock()
+ for _, eventsIn := range mux.eventsIns {
+ close(eventsIn)
+ }
+ mux.mu.Unlock()
+ }()
+
+ for _, en := range envs {
+ *en, _ = mux.makeEnv(false)
+ }
+ return
+}
+
+type muxEnv struct {
+ events <-chan gui.Event
+ draw chan<- func(draw.Image) image.Rectangle
+}
+
+func (m *muxEnv) Events() <-chan gui.Event { return m.events }
+func (m *muxEnv) Draw() chan<- func(draw.Image) image.Rectangle { return m.draw }
+
+// We do not store master env
+func (mux *Mux) makeEnv(master bool) (env gui.Env, eventsIn chan<- gui.Event) {
+ eventsOut, eventsIn := gui.MakeEventsChan()
+ drawChan := make(chan func(draw.Image) image.Rectangle)
+ env = &muxEnv{eventsOut, drawChan}
+
+ if !master {
+ mux.mu.Lock()
+ mux.eventsIns = append(mux.eventsIns, eventsIn)
+ // make sure to always send a resize event to a new Env if we got the size already
+ // that means it missed the resize event by the root Env
+ if mux.lastResize != nil {
+ eventsIn <- mux.lastResize
+ }
+ mux.mu.Unlock()
+ }
+
+ go func() {
+ func() {
+ // When the master Env gets its Draw() channel closed, it closes all the Events()
+ // channels of all the children Envs, and it also closes the internal draw channel
+ // of the Mux. Otherwise, closing the Draw() channel of the master Env wouldn't
+ // close the Env the Mux is muxing. However, some child Envs of the Mux may still
+ // send some drawing commmands before they realize that their Events() channel got
+ // closed.
+ //
+ // That is perfectly fine if their drawing commands simply get ignored. This down here
+ // is a little hacky, but (I hope) perfectly fine solution to the problem.
+ //
+ // When the internal draw channel of the Mux gets closed, the line marked with ! will
+ // cause panic. We recover this panic, then we receive, but ignore all furhter draw
+ // commands, correctly draining the Env until it closes itself.
+ defer func() {
+ if recover() != nil {
+ for range drawChan {
+ }
+ }
+ }()
+ for d := range drawChan {
+ mux.draw <- d // !
+ }
+ }()
+ if master {
+ mux.mu.Lock()
+ for _, eventsIn := range mux.eventsIns {
+ close(eventsIn)
+ }
+ mux.eventsIns = nil
+ close(mux.draw)
+ mux.mu.Unlock()
+ } else {
+ mux.mu.Lock()
+ i := -1
+ for i = range mux.eventsIns {
+ if mux.eventsIns[i] == eventsIn {
+ break
+ }
+ }
+ if i != -1 {
+ mux.eventsIns = append(mux.eventsIns[:i], mux.eventsIns[i+1:]...)
+ }
+ mux.mu.Unlock()
+ }
+ }()
+
+ return env, eventsIn
+}
diff --git a/gui/layout/scroller.go b/gui/layout/scroller.go
new file mode 100644
index 0000000..b7fc3f5
--- /dev/null
+++ b/gui/layout/scroller.go
@@ -0,0 +1,132 @@
+package layout
+
+import (
+ "image"
+ "image/color"
+ "image/draw"
+ "sync"
+
+ "volute/gui"
+ "volute/gui/win"
+)
+
+var _ Layout = &Scroller{}
+
+type Scroller struct {
+ Background color.Color
+ Length int
+ ChildHeight int
+ Offset int
+ Gap int
+ Vertical bool
+}
+
+func (s Scroller) redraw(drw draw.Image, bounds image.Rectangle) {
+ col := s.Background
+ if col == nil {
+ col = image.Black
+ }
+ draw.Draw(drw, bounds, image.NewUniform(col), image.ZP, draw.Src)
+}
+
+func clamp(val, a, b int) int {
+ if a > b {
+ if val < b {
+ return b
+ }
+ if val > a {
+ return a
+ }
+ } else {
+ if val > b {
+ return b
+ }
+ if val < a {
+ return a
+ }
+ }
+ return val
+}
+
+func (s *Scroller) Intercept(env gui.Env) gui.Env {
+ evs := env.Events()
+ out, in := gui.MakeEventsChan()
+ drawChan := make(chan func(draw.Image) image.Rectangle)
+ ret := &muxEnv{out, drawChan}
+ var lastResize gui.Resize
+ var img draw.Image
+ img = image.NewRGBA(image.ZR)
+ var mu sync.Mutex
+ var over bool
+
+ go func() {
+ for dc := range drawChan {
+ mu.Lock()
+ // draw.Draw will not draw out of bounds, call should be inexpensive if element not visible
+ res := dc(img)
+ // Only send a draw call up if visibly changed
+ if res.Intersect(img.Bounds()) != image.ZR {
+ env.Draw() <- func(drw draw.Image) image.Rectangle {
+ draw.Draw(drw, lastResize.Rectangle, img, lastResize.Rectangle.Min, draw.Over)
+ return img.Bounds()
+ }
+ }
+ mu.Unlock()
+ }
+ }()
+
+ go func() {
+ for ev := range evs {
+ switch ev := ev.(type) {
+ case win.MoMove:
+ mu.Lock()
+ over = ev.Point.In(lastResize.Rectangle)
+ mu.Unlock()
+ case win.MoScroll:
+ if !over {
+ continue
+ }
+ mu.Lock()
+ oldoff := s.Offset
+ v := s.Length*s.ChildHeight + ((s.Length + 1) * s.Gap)
+ if s.Vertical {
+ h := lastResize.Dx()
+ s.Offset = clamp(s.Offset+ev.Point.X*16, h-v, 0)
+ } else {
+ h := lastResize.Dy()
+ s.Offset = clamp(s.Offset+ev.Point.Y*16, h-v, 0)
+ }
+ if oldoff != s.Offset {
+ s.redraw(img, img.Bounds())
+ in <- lastResize
+ }
+ mu.Unlock()
+ case gui.Resize:
+ mu.Lock()
+ lastResize = ev
+ img = image.NewRGBA(lastResize.Rectangle)
+ s.redraw(img, img.Bounds())
+ mu.Unlock()
+ in <- ev
+ default:
+ in <- ev
+ }
+ }
+ }()
+ return ret
+}
+
+func (s Scroller) Lay(bounds image.Rectangle) []image.Rectangle {
+ items := s.Length
+ ch := s.ChildHeight
+ gap := s.Gap
+
+ ret := make([]image.Rectangle, items)
+ Y := bounds.Min.Y + s.Offset + gap
+ for i := 0; i < items; i++ {
+ r := image.Rect(bounds.Min.X+gap, Y, bounds.Max.X-gap, Y+ch)
+ ret[i] = r
+ Y += ch + gap
+ }
+ return ret
+}
diff --git a/gui/layout/split.go b/gui/layout/split.go
new file mode 100644
index 0000000..db04225
--- /dev/null
+++ b/gui/layout/split.go
@@ -0,0 +1,26 @@
+package layout
+
+import "fmt"
+
+// SplitFunc represents a way to split a space among a number of elements.
+// The length of the returned slice must be equal to the number of elements.
+// The sum of all elements of the returned slice must be eqal to the space.
+type SplitFunc func(elements int, space int) []int
+
+var _ SplitFunc = EvenSplit
+
+// EvenSplit is a SplitFunc used to split a space (almost) evenly among the elements.
+// It is almost evenly because width may not be divisible by elements.
+func EvenSplit(elements int, width int) []int {
+ if elements <= 0 {
+ panic(fmt.Errorf("EvenSplit: elements must be greater than 0"))
+ }
+ ret := make([]int, elements)
+ for elements > 0 {
+ v := width / elements
+ width -= v
+ elements -= 1
+ ret[elements] = v
+ }
+ return ret
+}