diff --git a/lib/ui/constants.go b/lib/ui/constants.go new file mode 100644 index 0000000..f5bb0c5 --- /dev/null +++ b/lib/ui/constants.go @@ -0,0 +1,9 @@ +package ui + +// Constants for the UI toolkit. +const ( + // Pack.Fill values. + FillBoth = "both" + FillX = "x" + FillY = "y" +) diff --git a/lib/ui/eg/layout/main.go b/lib/ui/eg/layout/main.go new file mode 100644 index 0000000..14220c9 --- /dev/null +++ b/lib/ui/eg/layout/main.go @@ -0,0 +1,104 @@ +package main + +import ( + "time" + + "git.kirsle.net/apps/doodle/lib/render" + "git.kirsle.net/apps/doodle/lib/render/sdl" + "git.kirsle.net/apps/doodle/lib/ui" +) + +var TargetFPS = 1000 / 60 + +func main() { + engine := sdl.New("Test Layout GUI", 1024, 768) + + if err := engine.Setup(); err != nil { + panic(err) + } + + super := ui.NewSupervisor() + + window := ui.NewWindow("Test Window") + window.Configure(ui.Config{ + Width: 750, + Height: 400, + Background: render.Grey, + }) + window.MoveTo(render.NewPoint(80, 80)) + + leftPanel := ui.NewFrame("Left Panel") + leftPanel.Configure(ui.Config{ + // AutoResize: true, + Width: 200, + Background: render.SkyBlue, + BorderStyle: ui.BorderRaised, + BorderSize: 2, + }) + window.Pack(leftPanel, ui.Pack{ + Side: ui.Left, + Fill: ui.FillY, + Expand: true, + }) + + body := ui.NewFrame("Body Panel") + body.Configure(ui.Config{ + Background: render.RGBA(255, 0, 0, 64), + BorderStyle: ui.BorderSunken, + BorderSize: 2, + }) + window.Pack(body, ui.Pack{ + Side: ui.Left, + Expand: true, + }) + + label1 := ui.NewLabel(ui.Label{ + Text: "Hello world!", + Font: render.Text{ + Size: 24, + Color: render.Red, + Stroke: render.Purple, + }, + }) + body.Pack(label1, ui.Pack{ + Side: ui.Top, + }) + + window.Frame().SetBackground(render.Yellow) + window.Compute(engine) + // window.Present(engine, window.Point()) + + super.Add(window) + super.MainLoop(engine) + // + for true { + + start := time.Now() + engine.Clear(render.White) + + // poll for events + ev, err := engine.Poll() + if err != nil { + panic(err) + } + + // escape key to close the window + if ev.EscapeKey.Now { + break + } + + super.Loop(ev) + window.Compute(engine) + window.Present(engine, window.Point()) + engine.Present() + + // Delay to maintain the target frames per second. + var delay uint32 + elapsed := time.Now().Sub(start) + tmp := elapsed / time.Millisecond + if TargetFPS-int(tmp) > 0 { // make sure it won't roll under + delay = uint32(TargetFPS - int(tmp)) + } + engine.Delay(delay) + } +} diff --git a/lib/ui/frame.go b/lib/ui/frame.go index fe3be40..2a0c029 100644 --- a/lib/ui/frame.go +++ b/lib/ui/frame.go @@ -10,7 +10,7 @@ import ( type Frame struct { Name string BaseWidget - packs map[Anchor][]packedWidget + packs map[Side][]packedWidget widgets []Widget } @@ -18,7 +18,7 @@ type Frame struct { func NewFrame(name string) *Frame { w := &Frame{ Name: name, - packs: map[Anchor][]packedWidget{}, + packs: map[Side][]packedWidget{}, widgets: []Widget{}, } w.IDFunc(func() string { @@ -32,7 +32,7 @@ func NewFrame(name string) *Frame { // Setup ensures all the Frame's data is initialized and not null. func (w *Frame) Setup() { if w.packs == nil { - w.packs = map[Anchor][]packedWidget{} + w.packs = map[Side][]packedWidget{} } if w.widgets == nil { w.widgets = []Widget{} diff --git a/lib/ui/frame_pack.go b/lib/ui/frame_pack.go index a4fe769..d175591 100644 --- a/lib/ui/frame_pack.go +++ b/lib/ui/frame_pack.go @@ -1,6 +1,8 @@ package ui import ( + "fmt" + "git.kirsle.net/apps/doodle/lib/render" ) @@ -9,12 +11,13 @@ type Pack struct { // Side of the parent to anchor the position to, like N, SE, W. Default // is Center. Anchor Anchor + Side Side // If the widget is smaller than its allocated space, grow the widget // to fill its space in the Frame. - Fill bool - FillX bool - FillY bool + Fill string // "x", "y", "both" or "" for none + fillX bool + fillY bool Padding int32 // Equal padding on X and Y. PadX int32 @@ -30,8 +33,8 @@ func (w *Frame) Pack(child Widget, config ...Pack) { } // Initialize the pack list for this anchor? - if _, ok := w.packs[C.Anchor]; !ok { - w.packs[C.Anchor] = []packedWidget{} + if _, ok := w.packs[C.Side]; !ok { + w.packs[C.Side] = []packedWidget{} } // Padding: if the user only provided Padding add it to both @@ -40,16 +43,14 @@ func (w *Frame) Pack(child Widget, config ...Pack) { C.PadX += C.Padding C.PadY += C.Padding - // Fill: true implies both directions. - if C.Fill { - C.FillX = true - C.FillY = true - } + // Cache the full X and Y booleans. + C.fillX = C.Fill == FillX || C.Fill == FillBoth + C.fillY = C.Fill == FillY || C.Fill == FillBoth // Adopt the child widget so it can access the Frame. child.Adopt(w) - w.packs[C.Anchor] = append(w.packs[C.Anchor], packedWidget{ + w.packs[C.Side] = append(w.packs[C.Side], packedWidget{ widget: child, pack: C, }) @@ -72,10 +73,10 @@ func (w *Frame) computePacked(e render.Engine) { expanded = []packedWidget{} ) - // Iterate through all anchored directions and compute how much space to + // Iterate through all packed sides and compute how much space to // reserve to contain all of their widgets. - for anchor := AnchorMin; anchor <= AnchorMax; anchor++ { - if _, ok := w.packs[anchor]; !ok { + for side := SideMin; side <= SideMax; side++ { + if _, ok := w.packs[side]; !ok { continue } @@ -86,15 +87,15 @@ func (w *Frame) computePacked(e render.Engine) { xDirection int32 = 1 ) - if anchor.IsSouth() { // TODO: these need tuning + if side == Bottom { // TODO: these need tuning y = frameSize.H - w.BoxThickness(4) yDirection = -1 * w.BoxThickness(4) // parent + child BoxThickness(1) = 2 - } else if anchor == E { + } else if side == Right { x = frameSize.W - w.BoxThickness(4) xDirection = -1 - w.BoxThickness(4) // - w.BoxThickness(2) } - for _, packedWidget := range w.packs[anchor] { + for _, packedWidget := range w.packs[side] { child := packedWidget.widget pack := packedWidget.pack @@ -121,19 +122,17 @@ func (w *Frame) computePacked(e render.Engine) { maxHeight = yStep + size.H + (pack.PadY * 2) } - if anchor.IsSouth() { + if side == Bottom { y -= size.H - pack.PadY - } - if anchor.IsEast() { + } else if side == Right { x -= size.W - pack.PadX } child.MoveTo(render.NewPoint(x, y)) - if anchor.IsNorth() { + if side == Top { y += size.H + pack.PadY - } - if anchor == W { + } else if side == Left { x += size.W + pack.PadX } @@ -147,6 +146,13 @@ func (w *Frame) computePacked(e render.Engine) { // If we have extra space in the Frame and any expanding widgets, let the // expanding widgets grow and share the remaining space. computedSize := render.NewRect(maxWidth, maxHeight) + if w.fixedWidth > 0 { + computedSize.W = w.fixedWidth + } + if w.fixedHeight > 0 { + computedSize.H = w.fixedHeight + } + if len(expanded) > 0 && !frameSize.IsZero() && frameSize.Bigger(computedSize) { // Divy up the size available. growBy := render.Rect{ @@ -154,7 +160,8 @@ func (w *Frame) computePacked(e render.Engine) { H: ((frameSize.H - computedSize.H) / int32(len(expanded))), // - w.BoxThickness(2), } for _, pw := range expanded { - pw.widget.ResizeBy(growBy) + fmt.Printf("expand %s by %s (comp size %s)\n", pw.widget.ID(), growBy, computedSize) + pw.widget.ResizeAuto(growBy) pw.widget.Compute(e) } } @@ -172,7 +179,7 @@ func (w *Frame) computePacked(e render.Engine) { } } - // Rescan all the widgets in this anchor to re-center them + // Rescan all the widgets in this side to re-center them // in their space. innerFrameSize := render.NewRect( frameSize.W-w.BoxThickness(2), @@ -189,8 +196,8 @@ func (w *Frame) computePacked(e render.Engine) { moved bool ) - if pack.Anchor.IsNorth() || pack.Anchor.IsSouth() { - if pack.FillX && resize.W < innerFrameSize.W { + if pack.Side == Top || pack.Side == Bottom { + if pack.fillX && resize.W < innerFrameSize.W { resize.W = innerFrameSize.W - w.BoxThickness(2) resized = true } @@ -205,8 +212,8 @@ func (w *Frame) computePacked(e render.Engine) { moved = true } - } else if pack.Anchor.IsWest() || pack.Anchor.IsEast() { - if pack.FillY && resize.H < innerFrameSize.H { + } else if pack.Side == Left || pack.Side == Right { + if pack.fillY && resize.H < innerFrameSize.H { resize.H = innerFrameSize.H - w.BoxThickness(2) // BoxThickness(2) for parent + child // point.Y -= (w.BoxThickness(4) + child.BoxThickness(2)) moved = true @@ -225,11 +232,12 @@ func (w *Frame) computePacked(e render.Engine) { moved = true } } else { - panic("unsupported pack.Anchor") + panic("unsupported pack.Side") } if resized && size != resize { - child.Resize(resize) + fmt.Printf("fill: resize %s to %s\n", child.ID(), resize) + child.ResizeAuto(resize) child.Compute(e) } if moved { @@ -238,7 +246,7 @@ func (w *Frame) computePacked(e render.Engine) { } // if !w.FixedSize() { - w.Resize(render.NewRect( + w.ResizeAuto(render.NewRect( frameSize.W-w.BoxThickness(2), frameSize.H-w.BoxThickness(2), )) @@ -248,7 +256,10 @@ func (w *Frame) computePacked(e render.Engine) { // Anchor is a cardinal direction. type Anchor uint8 -// Anchor values. +// Side of a parent widget to pack children against. +type Side uint8 + +// Anchor and Side constants. const ( Center Anchor = iota N @@ -259,12 +270,20 @@ const ( SW W NW + + Top Side = iota + Left + Right + Bottom ) -// Range of Anchor values. +// Range of Anchor and Side values. const ( AnchorMin = Center AnchorMax = NW + + SideMin = Top + SideMax = Bottom ) // IsNorth returns if the anchor is N, NE or NW. diff --git a/lib/ui/label.go b/lib/ui/label.go index d970637..cf18910 100644 --- a/lib/ui/label.go +++ b/lib/ui/label.go @@ -91,7 +91,7 @@ func (w *Label) Compute(e render.Engine) { ) if !w.FixedSize() { - w.resizeAuto(render.Rect{ + w.ResizeAuto(render.Rect{ W: maxRect.W + (padX * 2), H: maxRect.H + (padY * 2), }) diff --git a/lib/ui/supervisor.go b/lib/ui/supervisor.go index 8c69a73..1eb4526 100644 --- a/lib/ui/supervisor.go +++ b/lib/ui/supervisor.go @@ -3,6 +3,7 @@ package ui import ( "errors" "sync" + "time" "git.kirsle.net/apps/doodle/lib/events" "git.kirsle.net/apps/doodle/lib/render" @@ -29,12 +30,13 @@ const ( // interaction events such as mouse hovers and clicks in their general // vicinity. type Supervisor struct { - lock sync.RWMutex - serial int // ID number of each widget added in order - widgets map[int]WidgetSlot // map of widget ID to WidgetSlot - hovering map[int]interface{} // map of widgets under the cursor - clicked map[int]interface{} // map of widgets being clicked - dd *DragDrop + lock sync.RWMutex + targetFPS int + serial int // ID number of each widget added in order + widgets map[int]WidgetSlot // map of widget ID to WidgetSlot + hovering map[int]interface{} // map of widgets under the cursor + clicked map[int]interface{} // map of widgets being clicked + dd *DragDrop } // WidgetSlot holds a widget with a unique ID number in a sorted list. @@ -46,10 +48,11 @@ type WidgetSlot struct { // NewSupervisor creates a supervisor. func NewSupervisor() *Supervisor { return &Supervisor{ - widgets: map[int]WidgetSlot{}, - hovering: map[int]interface{}{}, - clicked: map[int]interface{}{}, - dd: NewDragDrop(), + targetFPS: 1000 / 60, + widgets: map[int]WidgetSlot{}, + hovering: map[int]interface{}{}, + clicked: map[int]interface{}{}, + dd: NewDragDrop(), } } @@ -75,6 +78,43 @@ var ( ErrStopPropagation = errors.New("stop all event propagation") ) +// MainLoop starts the UI main loop, for UI-only applications. +func (s *Supervisor) MainLoop(e render.Engine) error { + for true { + start := time.Now() + e.Clear(render.Green) + + // Poll for events. + ev, err := e.Poll() + if err != nil { + return err + } + + // TODO: escape key to exit the main loop + if ev.EscapeKey.Now { + return nil + } + + s.Loop(ev) + + // Render the widgets under our care. + s.Present(e) + + // Commit the pixels to screen. + e.Present() + + // Delay to maintain the target FPS. + var delay uint32 + elapsed := time.Now().Sub(start) + tmp := elapsed / time.Millisecond + if s.targetFPS-int(tmp) > 0 { + delay = uint32(s.targetFPS - int(tmp)) + } + e.Delay(delay) + } + return nil +} + // Loop to check events and pass them to managed widgets. // // Useful errors returned by this may be: @@ -211,12 +251,15 @@ func (s *Supervisor) Present(e render.Engine) { } // Add a widget to be supervised. -func (s *Supervisor) Add(w Widget) { +func (s *Supervisor) Add(w ...Widget) { s.lock.Lock() - s.widgets[s.serial] = WidgetSlot{ - id: s.serial, - widget: w, + + for _, child := range w { + s.widgets[s.serial] = WidgetSlot{ + id: s.serial, + widget: child, + } + s.serial++ } - s.serial++ s.lock.Unlock() } diff --git a/lib/ui/widget.go b/lib/ui/widget.go index e7b2328..14ff0cf 100644 --- a/lib/ui/widget.go +++ b/lib/ui/widget.go @@ -1,6 +1,8 @@ package ui import ( + "fmt" + "git.kirsle.net/apps/doodle/lib/render" "git.kirsle.net/apps/doodle/lib/ui/theme" ) @@ -24,11 +26,13 @@ type Widget interface { Point() render.Point MoveTo(render.Point) MoveBy(render.Point) - Size() render.Rect // Return the Width and Height of the widget. - FixedSize() bool // Return whether the size is fixed (true) or automatic (false) - BoxSize() render.Rect // Return the full size including the border and outline. + Size() render.Rect // Return the Width and Height of the widget. + FixedSize() bool // Return whether the size is fixed (true) or automatic (false) + FixedSizes() (int32, int32) // Return whether the W and H are fixed in size + BoxSize() render.Rect // Return the full size including the border and outline. Resize(render.Rect) ResizeBy(render.Rect) + ResizeAuto(render.Rect) Rect() render.Rect // Return the full absolute rect combining the Size() and Point() Handle(Event, func(render.Point)) @@ -107,6 +111,8 @@ type BaseWidget struct { hidden bool width int32 height int32 + fixedWidth int32 // values manually configured by the user, + fixedHeight int32 // and do not change point render.Point margin int32 background render.Color @@ -152,9 +158,11 @@ func (w *BaseWidget) Configure(c Config) { w.fixedSize = !c.AutoResize if c.Width != 0 { w.width = c.Width + w.fixedWidth = w.width } if c.Height != 0 { w.height = c.Height + w.fixedHeight = w.height } } @@ -235,6 +243,12 @@ func (w *BaseWidget) FixedSize() bool { return w.fixedSize } +// FixedSizes returns whether the widget's Width or Height were manually set +// to hard-coded values, respectively. +func (w *BaseWidget) FixedSizes() (int32, int32) { + return w.fixedWidth, w.fixedHeight +} + // Resize sets the size of the widget to the .W and .H attributes of a rect. func (w *BaseWidget) Resize(v render.Rect) { w.fixedSize = true @@ -249,10 +263,17 @@ func (w *BaseWidget) ResizeBy(v render.Rect) { w.height += v.H } -// resizeAuto sets the size of the widget but doesn't set the fixedSize flag. -func (w *BaseWidget) resizeAuto(v render.Rect) { - w.width = v.W - w.height = v.H +// ResizeAuto sets the size of the widget but doesn't set the fixedSize flag. +func (w *BaseWidget) ResizeAuto(v render.Rect) { + if w.fixedWidth == 0 { + w.width = v.W + } + if w.fixedHeight == 0 { + w.height = v.H + } + fmt.Printf("ResizeAuto(%s): fixed size=%d,%d new size=%s\n", + w.ID(), w.fixedWidth, w.fixedHeight, w.Size(), + ) } // BoxThickness returns the full sum of the padding, border and outline. diff --git a/lib/ui/window.go b/lib/ui/window.go index a956e7f..5e380f3 100644 --- a/lib/ui/window.go +++ b/lib/ui/window.go @@ -50,8 +50,8 @@ func NewWindow(title string) *Window { Background: render.Blue, }) w.body.Pack(titleBar, Pack{ - Anchor: N, - Fill: true, + Side: Top, + Fill: FillX, }) w.titleBar = titleBar @@ -61,8 +61,8 @@ func NewWindow(title string) *Window { Background: render.Grey, }) w.body.Pack(content, Pack{ - Anchor: N, - Fill: true, + Side: Top, + Fill: FillBoth, }) w.content = content @@ -81,6 +81,11 @@ func (w *Window) TitleBar() *Label { return w.titleBar } +// Frame returns the content frame of the window. +func (w *Window) Frame() *Frame { + return w.content +} + // Configure the widget. Color and style changes are passed down to the inner // content frame of the window. func (w *Window) Configure(C Config) { @@ -100,7 +105,23 @@ func (w *Window) ConfigureTitle(C Config) { // Compute the window. func (w *Window) Compute(e render.Engine) { + var size = w.Size() + w.body.Compute(e) + + // Assign a manual Height to the title bar using its naturally computed + // height, but leave the Width empty so the frame packer can stretch it + // horizontally. + w.titleBar.Configure(Config{ + Height: w.titleBar.Size().H, + }) + + // Shrink down the content frame to leave room for the title bar. + w.content.Resize(render.Rect{ + W: size.W - w.BoxThickness(2) - w.titleBar.BoxThickness(2), + H: size.H - w.titleBar.Size().H - w.BoxThickness(4) - + ((w.titleBar.Font.Padding + w.titleBar.Font.PadY) * 2), + }) } // Present the window. diff --git a/pkg/guitest_scene.go b/pkg/guitest_scene.go index 5ebcf28..49488eb 100644 --- a/pkg/guitest_scene.go +++ b/pkg/guitest_scene.go @@ -16,7 +16,7 @@ type GUITestScene struct { // Private widgets. Frame *ui.Frame - Window *ui.Frame + Window *ui.Window } // Name of the scene. @@ -28,8 +28,7 @@ func (s *GUITestScene) Name() string { func (s *GUITestScene) Setup(d *Doodle) error { s.Supervisor = ui.NewSupervisor() - window := ui.NewFrame("window") - s.Window = window + window := ui.NewWindow("Widget Toolkit") window.Configure(ui.Config{ Width: 750, Height: 450, @@ -37,33 +36,7 @@ func (s *GUITestScene) Setup(d *Doodle) error { BorderStyle: ui.BorderRaised, BorderSize: 2, }) - - // Title Bar - titleBar := ui.NewLabel(ui.Label{ - Text: "Widget Toolkit", - Font: render.Text{ - Size: 12, - Color: render.White, - Stroke: render.DarkBlue, - }, - }) - titleBar.Configure(ui.Config{ - Background: render.Blue, - }) - window.Pack(titleBar, ui.Pack{ - Anchor: ui.N, - Fill: true, - }) - - // Window Body - body := ui.NewFrame("Window Body") - body.Configure(ui.Config{ - Background: render.Yellow, - }) - window.Pack(body, ui.Pack{ - Anchor: ui.N, - Expand: true, - }) + s.Window = window // Left Frame leftFrame := ui.NewFrame("Left Frame") @@ -73,9 +46,9 @@ func (s *GUITestScene) Setup(d *Doodle) error { BorderSize: 4, Width: 100, }) - body.Pack(leftFrame, ui.Pack{ - Anchor: ui.W, - FillY: true, + window.Pack(leftFrame, ui.Pack{ + Side: ui.Left, + FillY: true, }) // Some left frame buttons. @@ -89,9 +62,9 @@ func (s *GUITestScene) Setup(d *Doodle) error { }) s.Supervisor.Add(btn) leftFrame.Pack(btn, ui.Pack{ - Anchor: ui.N, - FillX: true, - PadY: 2, + Side: ui.Top, + FillX: true, + PadY: 2, }) } @@ -101,8 +74,8 @@ func (s *GUITestScene) Setup(d *Doodle) error { Background: render.White, BorderSize: 0, }) - body.Pack(frame, ui.Pack{ - Anchor: ui.W, + window.Pack(frame, ui.Pack{ + Side: ui.Left, Expand: true, Fill: true, }) @@ -115,9 +88,9 @@ func (s *GUITestScene) Setup(d *Doodle) error { BorderSize: 2, Width: 80, }) - body.Pack(rightFrame, ui.Pack{ - Anchor: ui.W, - Fill: true, + window.Pack(rightFrame, ui.Pack{ + Side: ui.Right, + Fill: true, }) // A grid of buttons. @@ -139,7 +112,7 @@ func (s *GUITestScene) Setup(d *Doodle) error { d.Flash("%s clicked", btn) }) rowFrame.Pack(btn, ui.Pack{ - Anchor: ui.W, + Side: ui.Left, Expand: true, FillX: true, }) @@ -147,8 +120,8 @@ func (s *GUITestScene) Setup(d *Doodle) error { })(row, col, rowFrame) } rightFrame.Pack(rowFrame, ui.Pack{ - Anchor: ui.N, - Fill: true, + Side: ui.Top, + Fill: true, }) } @@ -160,7 +133,8 @@ func (s *GUITestScene) Setup(d *Doodle) error { Color: render.Black, }, }), ui.Pack{ - Anchor: ui.NW, + Side: ui.Top, + Anchor: ui.W, Padding: 2, }) @@ -172,7 +146,8 @@ func (s *GUITestScene) Setup(d *Doodle) error { }), ) frame.Pack(cb, ui.Pack{ - Anchor: ui.NW, + Side: ui.Top, + Anchor: ui.W, Padding: 4, }) cb.Supervise(s.Supervisor) @@ -184,7 +159,8 @@ func (s *GUITestScene) Setup(d *Doodle) error { Color: render.Red, }, }), ui.Pack{ - Anchor: ui.SE, + Side: ui.Bottom, + Anchor: ui.E, Padding: 8, }) frame.Pack(ui.NewLabel(ui.Label{ @@ -194,7 +170,8 @@ func (s *GUITestScene) Setup(d *Doodle) error { Color: render.Blue, }, }), ui.Pack{ - Anchor: ui.SE, + Side: ui.Bottom, + Anchor: ui.E, Padding: 8, }) @@ -204,7 +181,7 @@ func (s *GUITestScene) Setup(d *Doodle) error { Background: render.Grey, }) window.Pack(btnFrame, ui.Pack{ - Anchor: ui.N, + Side: ui.Top, }) button1 := ui.NewButton("Button1", ui.NewLabel(ui.Label{ @@ -228,13 +205,12 @@ func (s *GUITestScene) Setup(d *Doodle) error { }) }) - var align = ui.W btnFrame.Pack(button1, ui.Pack{ - Anchor: align, + Side: ui.Left, Padding: 20, }) btnFrame.Pack(button2, ui.Pack{ - Anchor: align, + Side: ui.Left, Padding: 20, })