Supervisor, Frame Pack and Misc Fixes

* Button: do not call MoveTo inside of Compute().
* Label: do not call MoveTo inside of Compute().
* MainWindow: add OnLoop callback function support so you can run custom code
  each loop and react to the event.State before the UI updates.
* Supervisor: locate widgets using AbsolutePosition() instead of w.Point()
  to play nice with Frame and Window packed widgets.
* Widget interface: rename Adopt() to SetParent() which makes more sense for
  what the function actually does.
* Window: set itself as the parent of the body Frame so that the Supervisor
  can locate widgets anywhere inside a window's frames.

Frame packing fixes:

* Widgets with Expand:true grow their space with ResizeAuto to preserve the
  FixedSize() boolean, instead of being hard-resized to fill the Frame.
* Widgets that Fill their space are resized with ResizeAuto too.

Outstanding bugs:

* Labels don't expand (window title bars, etc.)
This commit is contained in:
Noah 2019-12-28 23:56:00 -08:00
parent 9d6b172878
commit 4ba563d48d
10 changed files with 99 additions and 71 deletions

View File

@ -11,8 +11,8 @@ applications (SDL2, for Linux, MacOS and Windows) as well as web browsers
> _(Screenshot is from Project: Doodle's GUITest debug screen showing a_ > _(Screenshot is from Project: Doodle's GUITest debug screen showing a_
> _Window, several Frames, Labels, Buttons and a Checkbox widget.)_ > _Window, several Frames, Labels, Buttons and a Checkbox widget.)_
It is very much a **work in progress** and it's a bit buggy. See the It is very much a **work in progress** and may contain bugs and its API may
[Known Issues](#known-issues) at the bottom of this document. change as bugs are fixed or features added.
This library is being developed in conjunction with my drawing-based maze This library is being developed in conjunction with my drawing-based maze
game, [Project: Doodle](https://www.kirsle.net/doodle). The rendering engine game, [Project: Doodle](https://www.kirsle.net/doodle). The rendering engine
@ -214,24 +214,6 @@ MainWindow includes its own Supervisor: just call the `.Add(Widget)`
method to add interactive widgets to the supervisor. The MainLoop() of the method to add interactive widgets to the supervisor. The MainLoop() of the
window calls Supervisor.Loop() automatically. window calls Supervisor.Loop() automatically.
# Known Issues
The frame packing algorithm (frame_pack.go) is currently very buggy and in
need of a re-write. Some examples of issues with it:
* Currently, when the Frame is iterating over packed widgets to decide their
location and size, it explicitly calls MoveTo() and Resize() giving them
their pixel-coordinates, relative to the Frame's own position.
* When Frames nest other Frames this becomes more of an issue.
* The Supervisor sometimes can't determine the correct position of a
button packed inside of nested frames. It currently checks the
Point() of the button (set by its parent Frame) and this doesn't
account for the grandparent frame's position. Using the
AbsolutePosition() helper function (which recursively crawls up a
widget tree) also yields incorrect results, as the position of each
Frame is _added_ to the position of the Button which throws it off even
further.
# License # License
MIT. MIT.

View File

@ -91,7 +91,6 @@ func (w *Button) Present(e render.Engine, P render.Point) {
} }
w.Compute(e) w.Compute(e)
w.MoveTo(P)
var ( var (
S = w.Size() S = w.Size()
ChildSize = w.child.Size() ChildSize = w.child.Size()

View File

@ -1,15 +1,28 @@
package ui package ui
import "strings" import (
"fmt"
"strings"
)
// WidgetTree returns a string representing the tree of widgets starting // WidgetTree returns a string representing the tree of widgets starting
// at a given widget. // at a given widget.
func WidgetTree(root Widget) []string { func WidgetTree(root Widget) []string {
var crawl func(int, Widget) []string var crawl func(int, Widget) []string
crawl = func(depth int, node Widget) []string { crawl = func(depth int, node Widget) []string {
var ( var (
prefix = strings.Repeat(" ", depth) prefix = strings.Repeat(" ", depth)
lines = []string{prefix + node.ID()} size = node.Size()
width = size.W
height = size.H
fixedSize = node.FixedSize()
lines = []string{
fmt.Sprintf("%s%s P:%s S:%dx%d (fixedSize: %+v)",
prefix, node.ID(), node.Point(), width, height, fixedSize,
),
}
) )
for _, child := range node.Children() { for _, child := range node.Children() {

View File

@ -47,7 +47,7 @@ func (w *Frame) Pack(child Widget, config ...Pack) {
} }
// Adopt the child widget so it can access the Frame. // Adopt the child widget so it can access the Frame.
child.Adopt(w) child.SetParent(w)
w.packs[C.Side] = append(w.packs[C.Side], packedWidget{ w.packs[C.Side] = append(w.packs[C.Side], packedWidget{
widget: child, widget: child,
@ -65,7 +65,7 @@ func (w *Frame) computePacked(e render.Engine) {
// that the Frame must be to contain all of its children. If the Frame // that the Frame must be to contain all of its children. If the Frame
// was configured with an explicit Size, the Frame will be that Size, // was configured with an explicit Size, the Frame will be that Size,
// but we still calculate how much space the widgets _actually_ take // but we still calculate how much space the widgets _actually_ take
// so we can expand them to fill remaining space in fixed size widgets. // so we can expand them to fill remaining space in fixed size Frames.
maxWidth int maxWidth int
maxHeight int maxHeight int
visited = []packedWidget{} visited = []packedWidget{}
@ -82,8 +82,8 @@ func (w *Frame) computePacked(e render.Engine) {
var ( var (
x int x int
y int y int
yDirection int = 1 yDirection = 1
xDirection int = 1 xDirection = 1
) )
if side.IsSouth() { if side.IsSouth() {
@ -128,12 +128,15 @@ func (w *Frame) computePacked(e render.Engine) {
x -= size.W - pack.PadX x -= size.W - pack.PadX
} }
// NOTE: we place the child's position relative to the Frame's
// position. So a child placed at the top/left of the Frame gets
// an x,y near zero regardless of the Frame's position.
child.MoveTo(render.NewPoint(x, y)) child.MoveTo(render.NewPoint(x, y))
if side.IsNorth() { if side.IsNorth() {
y += size.H + pack.PadY y += size.H + pack.PadY
} }
if side == W { if side.IsWest() {
x += size.W + pack.PadX x += size.W + pack.PadX
} }
@ -147,14 +150,20 @@ func (w *Frame) computePacked(e render.Engine) {
// If we have extra space in the Frame and any expanding widgets, let the // If we have extra space in the Frame and any expanding widgets, let the
// expanding widgets grow and share the remaining space. // expanding widgets grow and share the remaining space.
computedSize := render.NewRect(maxWidth, maxHeight) computedSize := render.NewRect(maxWidth, maxHeight)
if len(expanded) > 0 && !frameSize.IsZero() && frameSize.Bigger(computedSize) { if len(expanded) > 0 && !frameSize.IsZero() { // && frameSize.Bigger(computedSize) {
// Divy up the size available. // Divy up the size available.
growBy := render.Rect{ growBy := render.Rect{
W: ((frameSize.W - computedSize.W) / len(expanded)) - w.BoxThickness(4), W: ((frameSize.W - computedSize.W) / len(expanded)) - w.BoxThickness(4),
H: ((frameSize.H - computedSize.H) / len(expanded)) - w.BoxThickness(4), H: ((frameSize.H - computedSize.H) / len(expanded)) - w.BoxThickness(4),
} }
for _, pw := range expanded { for _, pw := range expanded {
pw.widget.ResizeBy(growBy) // Grow the widget but maintain its auto-size flag, in case the widget
// was not given an explicit size before.
size := pw.widget.Size()
pw.widget.ResizeAuto(render.Rect{
W: size.W + growBy.W,
H: size.H + growBy.H,
})
pw.widget.Compute(e) pw.widget.Compute(e)
} }
} }
@ -190,10 +199,16 @@ func (w *Frame) computePacked(e render.Engine) {
) )
if pack.Side.IsNorth() || pack.Side.IsSouth() { if pack.Side.IsNorth() || pack.Side.IsSouth() {
// Aligned to the top or bottom. If the widget Fills horizontally,
// resize it so its Width matches the frame's Width.
if pack.FillX && resize.W < innerFrameSize.W { if pack.FillX && resize.W < innerFrameSize.W {
resize.W = innerFrameSize.W - w.BoxThickness(2) resize.W = innerFrameSize.W - w.BoxThickness(2) // TODO: child.BoxThickness instead??
resized = true resized = true
} }
// If it does not Fill horizontally and there is extra horizontal
// space, center the widget inside the space. TODO: Anchor option
// could align the widget to the left or right instead of center.
if resize.W < innerFrameSize.W-w.BoxThickness(4) { if resize.W < innerFrameSize.W-w.BoxThickness(4) {
if pack.Side.IsCenter() { if pack.Side.IsCenter() {
point.X = (innerFrameSize.W / 2) - (resize.W / 2) point.X = (innerFrameSize.W / 2) - (resize.W / 2)
@ -206,19 +221,19 @@ func (w *Frame) computePacked(e render.Engine) {
moved = true moved = true
} }
} else if pack.Side.IsWest() || pack.Side.IsEast() { } else if pack.Side.IsWest() || pack.Side.IsEast() {
// Similar logic to the above, but widget is packed against the
// left or right edge. Handle vertical Fill to grow the widget.
if pack.FillY && resize.H < innerFrameSize.H { if pack.FillY && resize.H < innerFrameSize.H {
resize.H = innerFrameSize.H - w.BoxThickness(2) // BoxThickness(2) for parent + child resize.H = innerFrameSize.H - w.BoxThickness(2) // TODO: child.BoxThickness instead??
// point.Y -= (w.BoxThickness(4) + child.BoxThickness(2))
moved = true
resized = true resized = true
} }
// Vertically align the widgets. // Vertically align the widgets.
if resize.H < innerFrameSize.H { if resize.H < innerFrameSize.H {
if pack.Side.IsMiddle() { if pack.Side.IsMiddle() {
point.Y = (innerFrameSize.H / 2) - (resize.H / 2) - w.BoxThickness(1) point.Y = (innerFrameSize.H / 2) - (resize.H / 2) // - w.BoxThickness(1)
} else if pack.Side.IsNorth() { } else if pack.Side.IsNorth() {
point.Y = pack.PadY - w.BoxThickness(4) point.Y = pack.PadY // - w.BoxThickness(4)
} else if pack.Side.IsSouth() { } else if pack.Side.IsSouth() {
point.Y = innerFrameSize.H - resize.H - pack.PadY point.Y = innerFrameSize.H - resize.H - pack.PadY
} }
@ -229,7 +244,7 @@ func (w *Frame) computePacked(e render.Engine) {
} }
if resized && size != resize { if resized && size != resize {
child.Resize(resize) child.ResizeAuto(resize)
child.Compute(e) child.Compute(e)
} }
if moved { if moved {
@ -237,6 +252,9 @@ func (w *Frame) computePacked(e render.Engine) {
} }
} }
// TODO: the Frame should ResizeAuto so it doesn't mark fixedSize=true.
// Currently there's a bug where frames will grow when the window grows but
// never shrink again when the window shrinks.
// if !w.FixedSize() { // if !w.FixedSize() {
w.Resize(render.NewRect( w.Resize(render.NewRect(
frameSize.W-w.BoxThickness(2), frameSize.W-w.BoxThickness(2),

View File

@ -1,6 +1,8 @@
package ui package ui
import "git.kirsle.net/go/render" import (
"git.kirsle.net/go/render"
)
// AbsolutePosition computes a widget's absolute X,Y position on the // AbsolutePosition computes a widget's absolute X,Y position on the
// window on screen by crawling its parent widget tree. // window on screen by crawling its parent widget tree.

View File

@ -101,11 +101,6 @@ func (w *Label) Compute(e render.Engine) {
H: maxRect.H + (padY * 2), H: maxRect.H + (padY * 2),
}) })
} }
w.MoveTo(render.Point{
X: maxRect.X + w.BoxThickness(1),
Y: maxRect.Y + w.BoxThickness(1),
})
} }
// Present the label widget. // Present the label widget.

View File

@ -7,6 +7,7 @@ import (
"time" "time"
"git.kirsle.net/go/render" "git.kirsle.net/go/render"
"git.kirsle.net/go/render/event"
"git.kirsle.net/go/render/sdl" "git.kirsle.net/go/render/sdl"
) )
@ -17,20 +18,22 @@ var (
// MainWindow is the parent window of a UI application. // MainWindow is the parent window of a UI application.
type MainWindow struct { type MainWindow struct {
Engine render.Engine Engine render.Engine
supervisor *Supervisor supervisor *Supervisor
frame *Frame frame *Frame
w int loopCallbacks []func(*event.State)
h int w int
h int
} }
// NewMainWindow initializes the MainWindow. You should probably only have one // NewMainWindow initializes the MainWindow. You should probably only have one
// of these per application. // of these per application.
func NewMainWindow(title string) (*MainWindow, error) { func NewMainWindow(title string) (*MainWindow, error) {
mw := &MainWindow{ mw := &MainWindow{
w: 800, w: 800,
h: 600, h: 600,
supervisor: NewSupervisor(), supervisor: NewSupervisor(),
loopCallbacks: []func(*event.State){},
} }
mw.Engine = sdl.New( mw.Engine = sdl.New(
@ -64,6 +67,11 @@ func (mw *MainWindow) Pack(w Widget, pack Pack) {
mw.frame.Pack(w, pack) mw.frame.Pack(w, pack)
} }
// Frame returns the window's main frame, if needed.
func (mw *MainWindow) Frame() *Frame {
return mw.frame
}
// resized handles the window being resized. // resized handles the window being resized.
func (mw *MainWindow) resized() { func (mw *MainWindow) resized() {
mw.frame.Resize(render.Rect{ mw.frame.Resize(render.Rect{
@ -82,6 +90,14 @@ func (mw *MainWindow) Present() {
mw.supervisor.Present(mw.Engine) mw.supervisor.Present(mw.Engine)
} }
// OnLoop registers a function to be called on every loop of the main window.
// This enables your application to register global event handlers or whatnot.
// The function is called between the event polling and the updating of any UI
// elements.
func (mw *MainWindow) OnLoop(callback func(*event.State)) {
mw.loopCallbacks = append(mw.loopCallbacks, callback)
}
// MainLoop starts the main event loop and blocks until there's an error. // MainLoop starts the main event loop and blocks until there's an error.
func (mw *MainWindow) MainLoop() error { func (mw *MainWindow) MainLoop() error {
for true { for true {
@ -114,11 +130,16 @@ func (mw *MainWindow) Loop() error {
} }
} }
// Ping any loop callbacks.
for _, cb := range mw.loopCallbacks {
cb(ev)
}
mw.frame.Compute(mw.Engine) mw.frame.Compute(mw.Engine)
// Render the child widgets. // Render the child widgets.
mw.supervisor.Loop(ev) mw.supervisor.Loop(ev)
mw.supervisor.Present(mw.Engine) mw.frame.Present(mw.Engine, mw.frame.Point())
mw.Engine.Present() mw.Engine.Present()
// Delay to maintain target frames per second. // Delay to maintain target frames per second.

View File

@ -2,6 +2,7 @@ package ui
import ( import (
"errors" "errors"
"fmt"
"sync" "sync"
"git.kirsle.net/go/render" "git.kirsle.net/go/render"
@ -165,7 +166,7 @@ func (s *Supervisor) Hovering(cursor render.Point) (hovering, outside []WidgetSl
for child := range s.Widgets() { for child := range s.Widgets() {
var ( var (
w = child.widget w = child.widget
P = w.Point() P = AbsolutePosition(w)
S = w.Size() S = w.Size()
P2 = render.Point{ P2 = render.Point{
X: P.X + S.W, X: P.X + S.W,
@ -204,10 +205,11 @@ func (s *Supervisor) Present(e render.Engine) {
s.lock.RLock() s.lock.RLock()
defer s.lock.RUnlock() defer s.lock.RUnlock()
for child := range s.Widgets() { fmt.Println("!!! ui.Supervisor.Present() is deprecated")
var w = child.widget // for child := range s.Widgets() {
w.Present(e, w.Point()) // var w = child.widget
} // w.Present(e, w.Point())
// }
} }
// Add a widget to be supervised. // Add a widget to be supervised.

View File

@ -1,8 +1,6 @@
package ui package ui
import ( import (
"fmt"
"git.kirsle.net/go/render" "git.kirsle.net/go/render"
"git.kirsle.net/go/ui/theme" "git.kirsle.net/go/ui/theme"
) )
@ -67,8 +65,8 @@ type Widget interface {
// Container widgets like Frames can wire up associations between the // Container widgets like Frames can wire up associations between the
// child widgets and the parent. // child widgets and the parent.
Parent() (parent Widget, ok bool) Parent() (parent Widget, ok bool)
Adopt(parent Widget) // for the container to assign itself the parent SetParent(parent Widget) // for the container to assign itself the parent
Children() []Widget // for containers to return their children Children() []Widget // for containers to return their children
// Run any render computations; by the end the widget must know its // Run any render computations; by the end the widget must know its
// Width and Height. For example the Label widget will render itself onto // Width and Height. For example the Label widget will render itself onto
@ -254,18 +252,13 @@ func (w *BaseWidget) ResizeBy(v render.Rect) {
// ResizeAuto sets the size of the widget but doesn't set the fixedSize flag. // ResizeAuto sets the size of the widget but doesn't set the fixedSize flag.
func (w *BaseWidget) ResizeAuto(v render.Rect) { func (w *BaseWidget) ResizeAuto(v render.Rect) {
if w.ID() == "Frame<Window Body>" {
fmt.Printf("%s: ResizeAuto Called: %+v\n",
w.ID(),
v,
)
}
w.width = v.W w.width = v.W
w.height = v.H w.height = v.H
} }
// BoxThickness returns the full sum of the padding, border and outline. // BoxThickness returns the full sum of the padding, border and outline.
// m = multiplier, i.e., 1 or 2 // m = multiplier, i.e., 1 or 2. If m=1 this returns the box thickness of one
// edge of the widget, if m=2 it would account for both edges of the widget.
func (w *BaseWidget) BoxThickness(m int) int { func (w *BaseWidget) BoxThickness(m int) int {
if m == 0 { if m == 0 {
m = 1 m = 1
@ -279,10 +272,10 @@ func (w *BaseWidget) Parent() (Widget, bool) {
return w.parent, w.hasParent return w.parent, w.hasParent
} }
// Adopt sets the widget's parent. This function is called by container // SetParent sets the widget's parent. This function is called by container
// widgets like Frame when they add a child widget to their care. // widgets like Frame when they add a child widget to their care.
// Pass a nil parent to unset the parent. // Pass a nil parent to unset the parent.
func (w *BaseWidget) Adopt(parent Widget) { func (w *BaseWidget) SetParent(parent Widget) {
if parent == nil { if parent == nil {
w.hasParent = false w.hasParent = false
w.parent = nil w.parent = nil

View File

@ -66,6 +66,9 @@ func NewWindow(title string) *Window {
}) })
w.content = content w.content = content
// Set up parent/child relationships
w.body.SetParent(w)
return w return w
} }