Browse Source

Code Layout Refactor

* All private Doodle source code into the pkg/ folder.
* Potentially public code into the lib/ folder.
* Centralize the logger into a subpackage.
menus
Noah Petherbridge 2 years ago
commit
7661f9873c
15 changed files with 1867 additions and 0 deletions
  1. +122
    -0
      button.go
  2. +118
    -0
      check_button.go
  3. +65
    -0
      checkbox.go
  4. +23
    -0
      debug.go
  5. +28
    -0
      dragdrop.go
  6. +91
    -0
      frame.go
  7. +318
    -0
      frame_pack.go
  8. +38
    -0
      functions.go
  9. +82
    -0
      image.go
  10. +125
    -0
      label.go
  11. +14
    -0
      log.go
  12. +222
    -0
      supervisor.go
  13. +12
    -0
      theme/theme.go
  14. +495
    -0
      widget.go
  15. +114
    -0
      window.go

+ 122
- 0
button.go View File

@@ -0,0 +1,122 @@
package ui

import (
"errors"
"fmt"

"git.kirsle.net/apps/doodle/lib/render"
"git.kirsle.net/apps/doodle/lib/ui/theme"
)

// Button is a clickable button.
type Button struct {
BaseWidget
child Widget

// Private options.
hovering bool
clicked bool
}

// NewButton creates a new Button.
func NewButton(name string, child Widget) *Button {
w := &Button{
child: child,
}
w.IDFunc(func() string {
return fmt.Sprintf("Button<%s>", name)
})

w.Configure(Config{
BorderSize: 2,
BorderStyle: BorderRaised,
OutlineSize: 1,
OutlineColor: theme.ButtonOutlineColor,
Background: theme.ButtonBackgroundColor,
})

w.Handle(MouseOver, func(p render.Point) {
w.hovering = true
w.SetBackground(theme.ButtonHoverColor)
})
w.Handle(MouseOut, func(p render.Point) {
w.hovering = false
w.SetBackground(theme.ButtonBackgroundColor)
})

w.Handle(MouseDown, func(p render.Point) {
w.clicked = true
w.SetBorderStyle(BorderSunken)
})
w.Handle(MouseUp, func(p render.Point) {
w.clicked = false
w.SetBorderStyle(BorderRaised)
})

return w
}

// Children returns the button's child widget.
func (w *Button) Children() []Widget {
return []Widget{w.child}
}

// Compute the size of the button.
func (w *Button) Compute(e render.Engine) {
// Compute the size of the inner widget first.
w.child.Compute(e)

// Auto-resize only if we haven't been given a fixed size.
if !w.FixedSize() {
size := w.child.Size()
w.Resize(render.Rect{
W: size.W + w.BoxThickness(2),
H: size.H + w.BoxThickness(2),
})
}
}

// SetText conveniently sets the button text, for Label children only.
func (w *Button) SetText(text string) error {
if label, ok := w.child.(*Label); ok {
label.Text = text
}
return errors.New("child is not a Label widget")
}

// Present the button.
func (w *Button) Present(e render.Engine, P render.Point) {
if w.Hidden() {
return
}

w.Compute(e)
w.MoveTo(P)
var (
S = w.Size()
ChildSize = w.child.Size()
)

// Draw the widget's border and everything.
w.DrawBox(e, P)

// Offset further if we are currently sunken.
var clickOffset int32
if w.clicked {
clickOffset++
}

// Where to place the child widget.
moveTo := render.Point{
X: P.X + w.BoxThickness(1) + clickOffset,
Y: P.Y + w.BoxThickness(1) + clickOffset,
}

// If we're bigger than we need to be, center the child widget.
if S.Bigger(ChildSize) {
moveTo.X = P.X + (S.W / 2) - (ChildSize.W / 2)
}

// Draw the text label inside.
w.child.Present(e, moveTo)
}

+ 118
- 0
check_button.go View File

@@ -0,0 +1,118 @@
package ui

import (
"fmt"
"strconv"

"git.kirsle.net/apps/doodle/lib/render"
"git.kirsle.net/apps/doodle/lib/ui/theme"
)

// CheckButton implements a checkbox and radiobox widget. It's based on a
// Button and holds a boolean or string pointer (boolean for checkbox,
// string for radio).
type CheckButton struct {
Button
BoolVar *bool
StringVar *string
Value string
}

// NewCheckButton creates a new CheckButton.
func NewCheckButton(name string, boolVar *bool, child Widget) *CheckButton {
w := &CheckButton{
BoolVar: boolVar,
}
w.Button.child = child
w.IDFunc(func() string {
return fmt.Sprintf("CheckButton<%s %+v>", name, w.BoolVar)
})

w.setup()
return w
}

// NewRadioButton creates a CheckButton bound to a string variable.
func NewRadioButton(name string, stringVar *string, value string, child Widget) *CheckButton {
w := &CheckButton{
StringVar: stringVar,
Value: value,
}
w.Button.child = child
w.IDFunc(func() string {
return fmt.Sprintf(`RadioButton<%s "%s" %s>`, name, w.Value, strconv.FormatBool(*w.StringVar == w.Value))
})
w.setup()
return w
}

// Compute to re-evaluate the button state (in the case of radio buttons where
// a different button will affect the state of this one when clicked).
func (w *CheckButton) Compute(e render.Engine) {
if w.StringVar != nil {
// Radio button, always re-assign the border style in case a sister
// radio button has changed the value.
if *w.StringVar == w.Value {
w.SetBorderStyle(BorderSunken)
} else {
w.SetBorderStyle(BorderRaised)
}
}
w.Button.Compute(e)
}

// setup the common things between checkboxes and radioboxes.
func (w *CheckButton) setup() {
var borderStyle BorderStyle = BorderRaised
if w.BoolVar != nil {
if *w.BoolVar == true {
borderStyle = BorderSunken
}
}

w.Configure(Config{
BorderSize: 2,
BorderStyle: borderStyle,
OutlineSize: 1,
OutlineColor: theme.ButtonOutlineColor,
Background: theme.ButtonBackgroundColor,
})

w.Handle(MouseOver, func(p render.Point) {
w.hovering = true
w.SetBackground(theme.ButtonHoverColor)
})
w.Handle(MouseOut, func(p render.Point) {
w.hovering = false
w.SetBackground(theme.ButtonBackgroundColor)
})

w.Handle(MouseDown, func(p render.Point) {
w.clicked = true
w.SetBorderStyle(BorderSunken)
})
w.Handle(MouseUp, func(p render.Point) {
w.clicked = false
})

w.Handle(Click, func(p render.Point) {
var sunken bool
if w.BoolVar != nil {
if *w.BoolVar {
*w.BoolVar = false
} else {
*w.BoolVar = true
sunken = true
}
} else if w.StringVar != nil {
*w.StringVar = w.Value
sunken = true
}

if sunken {
w.SetBorderStyle(BorderSunken)
} else {
w.SetBorderStyle(BorderRaised)
}
})
}

+ 65
- 0
checkbox.go View File

@@ -0,0 +1,65 @@
package ui

import "git.kirsle.net/apps/doodle/lib/render"

// Checkbox combines a CheckButton with a widget like a Label.
type Checkbox struct {
Frame
button *CheckButton
child Widget
}

// NewCheckbox creates a new Checkbox.
func NewCheckbox(name string, boolVar *bool, child Widget) *Checkbox {
return makeCheckbox(name, boolVar, nil, "", child)
}

// NewRadiobox creates a new Checkbox in radio mode.
func NewRadiobox(name string, stringVar *string, value string, child Widget) *Checkbox {
return makeCheckbox(name, nil, stringVar, value, child)
}

// makeCheckbox constructs an appropriate type of checkbox.
func makeCheckbox(name string, boolVar *bool, stringVar *string, value string, child Widget) *Checkbox {
// Our custom checkbutton widget.
mark := NewFrame(name + "_mark")

w := &Checkbox{
child: child,
}
if boolVar != nil {
w.button = NewCheckButton(name+"_button", boolVar, mark)
} else if stringVar != nil {
w.button = NewRadioButton(name+"_button", stringVar, value, mark)
}
w.Frame.Setup()

// Forward clicks on the child widget to the CheckButton.
for _, e := range []Event{MouseOver, MouseOut, MouseUp, MouseDown} {
func(e Event) {
w.child.Handle(e, func(p render.Point) {
w.button.Event(e, p)
})
}(e)
}

w.Pack(w.button, Pack{
Anchor: W,
})
w.Pack(w.child, Pack{
Anchor: W,
})

return w
}

// Child returns the child widget.
func (w *Checkbox) Child() Widget {
return w.child
}

// Supervise the checkbutton inside the widget.
func (w *Checkbox) Supervise(s *Supervisor) {
s.Add(w.button)
s.Add(w.child)
}

+ 23
- 0
debug.go View File

@@ -0,0 +1,23 @@
package ui

import "strings"

// WidgetTree returns a string representing the tree of widgets starting
// at a given widget.
func WidgetTree(root Widget) []string {
var crawl func(int, Widget) []string
crawl = func(depth int, node Widget) []string {
var (
prefix = strings.Repeat(" ", depth)
lines = []string{prefix + node.ID()}
)

for _, child := range node.Children() {
lines = append(lines, crawl(depth+1, child)...)
}

return lines
}

return crawl(0, root)
}

+ 28
- 0
dragdrop.go View File

@@ -0,0 +1,28 @@
package ui

// DragDrop is a state machine to manage draggable UI components.
type DragDrop struct {
isDragging bool
}

// NewDragDrop initializes the DragDrop struct. Normally your Supervisor
// will manage the drag/drop object, but you can use your own if you don't
// use a Supervisor.
func NewDragDrop() *DragDrop {
return &DragDrop{}
}

// IsDragging returns whether the drag state is active.
func (dd *DragDrop) IsDragging() bool {
return dd.isDragging
}

// Start the drag state.
func (dd *DragDrop) Start() {
dd.isDragging = true
}

// Stop dragging.
func (dd *DragDrop) Stop() {
dd.isDragging = false
}

+ 91
- 0
frame.go View File

@@ -0,0 +1,91 @@
package ui

import (
"fmt"

"git.kirsle.net/apps/doodle/lib/render"
)

// Frame is a widget that contains other widgets.
type Frame struct {
Name string
BaseWidget
packs map[Anchor][]packedWidget
widgets []Widget
}

// NewFrame creates a new Frame.
func NewFrame(name string) *Frame {
w := &Frame{
Name: name,
packs: map[Anchor][]packedWidget{},
widgets: []Widget{},
}
w.IDFunc(func() string {
return fmt.Sprintf("Frame<%s>",
name,
)
})
return w
}

// 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{}
}
if w.widgets == nil {
w.widgets = []Widget{}
}
}

// Children returns all of the child widgets.
func (w *Frame) Children() []Widget {
return w.widgets
}

// Compute the size of the Frame.
func (w *Frame) Compute(e render.Engine) {
w.computePacked(e)
}

// Present the Frame.
func (w *Frame) Present(e render.Engine, P render.Point) {
if w.Hidden() {
return
}

var (
S = w.Size()
)

// Draw the widget's border and everything.
w.DrawBox(e, P)

// Draw the background color.
e.DrawBox(w.Background(), render.Rect{
X: P.X + w.BoxThickness(1),
Y: P.Y + w.BoxThickness(1),
W: S.W - w.BoxThickness(2),
H: S.H - w.BoxThickness(2),
})

// Draw the widgets.
for _, child := range w.widgets {
// child.Compute(e)
p := child.Point()
moveTo := render.NewPoint(
P.X+p.X+w.BoxThickness(1),
P.Y+p.Y+w.BoxThickness(1),
)
// if child.ID() == "Canvas" {
// log.Debug("Frame X=%d Child X=%d Box=%d Point=%s", P.X, p.X, w.BoxThickness(1), p)
// log.Debug("Frame Y=%d Child Y=%d Box=%d MoveTo=%s", P.Y, p.Y, w.BoxThickness(1), moveTo)
// }
// child.MoveTo(moveTo) // TODO: if uncommented the child will creep down the parent each tick
// if child.ID() == "Canvas" {
// log.Debug("New Point: %s", child.Point())
// }
child.Present(e, moveTo)
}
}

+ 318
- 0
frame_pack.go View File

@@ -0,0 +1,318 @@
package ui

import (
"git.kirsle.net/apps/doodle/lib/render"
)

// Pack provides configuration fields for Frame.Pack().
type Pack struct {
// Side of the parent to anchor the position to, like N, SE, W. Default
// is Center.
Anchor Anchor

// 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

Padding int32 // Equal padding on X and Y.
PadX int32
PadY int32
Expand bool // Widget should grow its allocated space to better fill the parent.
}

// Pack a widget along a side of the frame.
func (w *Frame) Pack(child Widget, config ...Pack) {
var C Pack
if len(config) > 0 {
C = config[0]
}

// Initialize the pack list for this anchor?
if _, ok := w.packs[C.Anchor]; !ok {
w.packs[C.Anchor] = []packedWidget{}
}

// Padding: if the user only provided Padding add it to both
// the X and Y value. If the user additionally provided the X
// and Y value, it will add to the base padding as you'd expect.
C.PadX += C.Padding
C.PadY += C.Padding

// Fill: true implies both directions.
if C.Fill {
C.FillX = true
C.FillY = true
}

// Adopt the child widget so it can access the Frame.
child.Adopt(w)

w.packs[C.Anchor] = append(w.packs[C.Anchor], packedWidget{
widget: child,
pack: C,
})
w.widgets = append(w.widgets, child)
}

// computePacked processes all the Pack layout widgets in the Frame.
func (w *Frame) computePacked(e render.Engine) {
var (
frameSize = w.BoxSize()

// maxWidth and maxHeight are always the computed minimum dimensions
// 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,
// but we still calculate how much space the widgets _actually_ take
// so we can expand them to fill remaining space in fixed size widgets.
maxWidth int32
maxHeight int32
visited = []packedWidget{}
expanded = []packedWidget{}
)

// Iterate through all anchored directions 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 {
continue
}

var (
x int32
y int32
yDirection int32 = 1
xDirection int32 = 1
)

if anchor.IsSouth() { // TODO: these need tuning
y = frameSize.H - w.BoxThickness(4)
yDirection = -1 * w.BoxThickness(4) // parent + child BoxThickness(1) = 2
} else if anchor == E {
x = frameSize.W - w.BoxThickness(4)
xDirection = -1 - w.BoxThickness(4) // - w.BoxThickness(2)
}

for _, packedWidget := range w.packs[anchor] {

child := packedWidget.widget
pack := packedWidget.pack
child.Compute(e)

if child.Hidden() {
continue
}

x += pack.PadX
y += pack.PadY

var (
// point = child.Point()
size = child.Size()
yStep = y * yDirection
xStep = x * xDirection
)

if xStep+size.W+(pack.PadX*2) > maxWidth {
maxWidth = xStep + size.W + (pack.PadX * 2)
}
if yStep+size.H+(pack.PadY*2) > maxHeight {
maxHeight = yStep + size.H + (pack.PadY * 2)
}

if anchor.IsSouth() {
y -= size.H - pack.PadY
}
if anchor.IsEast() {
x -= size.W - pack.PadX
}

child.MoveTo(render.NewPoint(x, y))

if anchor.IsNorth() {
y += size.H + pack.PadY
}
if anchor == W {
x += size.W + pack.PadX
}

visited = append(visited, packedWidget)
if pack.Expand { // TODO: don't fuck with children of fixed size
expanded = append(expanded, packedWidget)
}
}
}

// 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 len(expanded) > 0 && !frameSize.IsZero() && frameSize.Bigger(computedSize) {
// Divy up the size available.
growBy := render.Rect{
W: ((frameSize.W - computedSize.W) / int32(len(expanded))), // - w.BoxThickness(2),
H: ((frameSize.H - computedSize.H) / int32(len(expanded))), // - w.BoxThickness(2),
}
for _, pw := range expanded {
pw.widget.ResizeBy(growBy)
pw.widget.Compute(e)
}
}

// If we're not using a fixed Frame size, use the dynamically computed one.
if !w.FixedSize() {
frameSize = render.NewRect(maxWidth, maxHeight)
} else {
// If either of the sizes were left zero, use the dynamically computed one.
if frameSize.W == 0 {
frameSize.W = maxWidth
}
if frameSize.H == 0 {
frameSize.H = maxHeight
}
}

// Rescan all the widgets in this anchor to re-center them
// in their space.
innerFrameSize := render.NewRect(
frameSize.W-w.BoxThickness(2),
frameSize.H-w.BoxThickness(2),
)
for _, pw := range visited {
var (
child = pw.widget
pack = pw.pack
point = child.Point()
size = child.Size()
resize = size
resized bool
moved bool
)

if pack.Anchor.IsNorth() || pack.Anchor.IsSouth() {
if pack.FillX && resize.W < innerFrameSize.W {
resize.W = innerFrameSize.W - w.BoxThickness(2)
resized = true
}
if resize.W < innerFrameSize.W-w.BoxThickness(4) {
if pack.Anchor.IsCenter() {
point.X = (innerFrameSize.W / 2) - (resize.W / 2)
} else if pack.Anchor.IsWest() {
point.X = pack.PadX
} else if pack.Anchor.IsEast() {
point.X = innerFrameSize.W - resize.W - pack.PadX
}

moved = true
}
} else if pack.Anchor.IsWest() || pack.Anchor.IsEast() {
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
resized = true
}

// Vertically align the widgets.
if resize.H < innerFrameSize.H {
if pack.Anchor.IsMiddle() {
point.Y = (innerFrameSize.H / 2) - (resize.H / 2) - w.BoxThickness(1)
} else if pack.Anchor.IsNorth() {
point.Y = pack.PadY - w.BoxThickness(4)
} else if pack.Anchor.IsSouth() {
point.Y = innerFrameSize.H - resize.H - pack.PadY
}
moved = true
}
} else {
panic("unsupported pack.Anchor")
}

if resized && size != resize {
child.Resize(resize)
child.Compute(e)
}
if moved {
child.MoveTo(point)
}
}

// if !w.FixedSize() {
w.Resize(render.NewRect(
frameSize.W-w.BoxThickness(2),
frameSize.H-w.BoxThickness(2),
))
// }
}

// Anchor is a cardinal direction.
type Anchor uint8

// Anchor values.
const (
Center Anchor = iota
N
NE
E
SE
S
SW
W
NW
)

// Range of Anchor values.
const (
AnchorMin = Center
AnchorMax = NW
)

// IsNorth returns if the anchor is N, NE or NW.
func (a Anchor) IsNorth() bool {
return a == N || a == NE || a == NW
}

// IsSouth returns if the anchor is S, SE or SW.
func (a Anchor) IsSouth() bool {
return a == S || a == SE || a == SW
}

// IsEast returns if the anchor is E, NE or SE.
func (a Anchor) IsEast() bool {
return a == E || a == NE || a == SE
}

// IsWest returns if the anchor is W, NW or SW.
func (a Anchor) IsWest() bool {
return a == W || a == NW || a == SW
}

// IsCenter returns if the anchor is Center, N or S, to determine
// whether to align text as centered for North/South anchors.
func (a Anchor) IsCenter() bool {
return a == Center || a == N || a == S
}

// IsMiddle returns if the anchor is Center, E or W, to determine
// whether to align text as middled for East/West anchors.
func (a Anchor) IsMiddle() bool {
return a == Center || a == W || a == E
}

type packLayout struct {
widgets []packedWidget
}

type packedWidget struct {
widget Widget
pack Pack
fill uint8
}

// packedWidget.fill values
const (
fillNone uint8 = iota
fillX
fillY
fillBoth
)

+ 38
- 0
functions.go View File

@@ -0,0 +1,38 @@
package ui

import "git.kirsle.net/apps/doodle/lib/render"

// AbsolutePosition computes a widget's absolute X,Y position on the
// window on screen by crawling its parent widget tree.
func AbsolutePosition(w Widget) render.Point {
abs := w.Point()

var (
node = w
ok bool
)

for {
node, ok = node.Parent()
if !ok { // reached the top of the tree
return abs
}

abs.Add(node.Point())
}
}

// AbsoluteRect returns a Rect() offset with the absolute position.
func AbsoluteRect(w Widget) render.Rect {
var (
P = AbsolutePosition(w)
R = w.Rect()
)
return render.Rect{
X: P.X,
Y: P.Y,
W: R.W + P.X,
H: R.H, // TODO: the Canvas in EditMode lets you draw pixels
// below the status bar if we do `+ R.Y` here.
}
}

+ 82
- 0
image.go View File

@@ -0,0 +1,82 @@
package ui

import (
"fmt"
"path/filepath"
"strings"

"git.kirsle.net/apps/doodle/lib/render"
)

// ImageType for supported image formats.
type ImageType string

// Supported image formats.
const (
BMP ImageType = "bmp"
PNG = "png"
)

// Image is a widget that is backed by an image file.
type Image struct {
BaseWidget

// Configurable fields for the constructor.
Type ImageType
texture render.Texturer
}

// NewImage creates a new Image.
func NewImage(c Image) *Image {
w := &Image{
Type: c.Type,
}
if w.Type == "" {
w.Type = BMP
}

w.IDFunc(func() string {
return fmt.Sprintf(`Image<"%s">`, w.Type)
})
return w
}

// OpenImage initializes an Image with a given file name.
//
// The file extension is important and should be a supported ImageType.
func OpenImage(e render.Engine, filename string) (*Image, error) {
w := &Image{}
switch strings.ToLower(filepath.Ext(filename)) {
case ".bmp":
w.Type = BMP
case ".png":
w.Type = PNG
default:
return nil, fmt.Errorf("OpenImage: %s: not a supported image type", filename)
}

tex, err := e.NewBitmap(filename)
if err != nil {
return nil, err
}

w.texture = tex
return w, nil
}

// Compute the widget.
func (w *Image) Compute(e render.Engine) {
w.Resize(w.texture.Size())
}

// Present the widget.
func (w *Image) Present(e render.Engine, p render.Point) {
size := w.texture.Size()
dst := render.Rect{
X: p.X,
Y: p.Y,
W: size.W,
H: size.H,
}
e.Copy(w.texture, size, dst)
}

+ 125
- 0
label.go View File

@@ -0,0 +1,125 @@
package ui

import (
"fmt"
"strings"

"git.kirsle.net/apps/doodle/lib/render"
)

// DefaultFont is the default font settings used for a Label.
var DefaultFont = render.Text{
Size: 12,
Color: render.Black,
}

// Label is a simple text label widget.
type Label struct {
BaseWidget

// Configurable fields for the constructor.
Text string
TextVariable *string
Font render.Text

width int32
height int32
lineHeight int
}

// NewLabel creates a new label.
func NewLabel(c Label) *Label {
w := &Label{
Text: c.Text,
TextVariable: c.TextVariable,
Font: DefaultFont,
}
if !c.Font.IsZero() {
w.Font = c.Font
}
w.IDFunc(func() string {
return fmt.Sprintf(`Label<"%s">`, w.text().Text)
})
return w
}

// text returns the label's displayed text, coming from the TextVariable if
// available or else the Text attribute instead.
func (w *Label) text() render.Text {
if w.TextVariable != nil {
w.Font.Text = *w.TextVariable
return w.Font
}
w.Font.Text = w.Text
return w.Font
}

// Value returns the current text value displayed in the widget, whether it was
// the hardcoded value or a TextVariable.
func (w *Label) Value() string {
return w.text().Text
}

// Compute the size of the label widget.
func (w *Label) Compute(e render.Engine) {
text := w.text()
lines := strings.Split(text.Text, "\n")

// Max rect to encompass all lines of text.
var maxRect = render.Rect{}
for _, line := range lines {
text.Text = line // only this line at this time.
rect, err := e.ComputeTextRect(text)
if err != nil {
panic(fmt.Sprintf("%s: failed to compute text rect: %s", w, err)) // TODO return an error
return
}

if rect.W > maxRect.W {
maxRect.W = rect.W
}
maxRect.H += rect.H
w.lineHeight = int(rect.H)
}

var (
padX = w.Font.Padding + w.Font.PadX
padY = w.Font.Padding + w.Font.PadY
)

if !w.FixedSize() {
w.resizeAuto(render.Rect{
W: maxRect.W + (padX * 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.
func (w *Label) Present(e render.Engine, P render.Point) {
if w.Hidden() {
return
}

border := w.BoxThickness(1)

var (
text = w.text()
padX = w.Font.Padding + w.Font.PadX
padY = w.Font.Padding + w.Font.PadY
)

w.DrawBox(e, P)
for i, line := range strings.Split(text.Text, "\n") {
text.Text = line
e.DrawText(text, render.Point{
X: P.X + border + padX,
Y: P.Y + border + padY + int32(i*w.lineHeight),
})
}
}

+ 14
- 0
log.go View File

@@ -0,0 +1,14 @@
package ui

import "github.com/kirsle/golog"

var log *golog.Logger

func init() {
log = golog.GetLogger("ui")
log.Configure(&golog.Config{
Level: golog.DebugLevel,
Theme: golog.DarkTheme,
Colors: golog.ExtendedColor,
})
}

+ 222
- 0
supervisor.go View File

@@ -0,0 +1,222 @@
package ui

import (
"errors"
"sync"

"git.kirsle.net/apps/doodle/lib/events"
"git.kirsle.net/apps/doodle/lib/render"
)

// Event is a named event that the supervisor will send.
type Event int

// Events.
const (
NullEvent Event = iota
MouseOver
MouseOut
MouseDown
MouseUp
Click
KeyDown
KeyUp
KeyPress
Drop
)

// Supervisor keeps track of widgets of interest to notify them about
// 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
}

// WidgetSlot holds a widget with a unique ID number in a sorted list.
type WidgetSlot struct {
id int
widget Widget
}

// NewSupervisor creates a supervisor.
func NewSupervisor() *Supervisor {
return &Supervisor{
widgets: map[int]WidgetSlot{},
hovering: map[int]interface{}{},
clicked: map[int]interface{}{},
dd: NewDragDrop(),
}
}

// DragStart sets the drag state.
func (s *Supervisor) DragStart() {
s.dd.Start()
}

// DragStop stops the drag state.
func (s *Supervisor) DragStop() {
s.dd.Stop()
}

// IsDragging returns whether the drag state is enabled.
func (s *Supervisor) IsDragging() bool {
return s.dd.IsDragging()
}

// Error messages that may be returned by Supervisor.Loop()
var (
// The caller should STOP forwarding any mouse or keyboard events to any
// other handles for the remainder of this tick.
ErrStopPropagation = errors.New("stop all event propagation")
)

// Loop to check events and pass them to managed widgets.
//
// Useful errors returned by this may be:
// - ErrStopPropagation
func (s *Supervisor) Loop(ev *events.State) error {
var (
XY = render.Point{
X: ev.CursorX.Now,
Y: ev.CursorY.Now,
}
)

// See if we are hovering over any widgets.
hovering, outside := s.Hovering(XY)

// If we are dragging something around, do not trigger any mouse events
// to other widgets but DO notify any widget we dropped on top of!
if s.dd.IsDragging() {
if !ev.Button1.Now && !ev.Button2.Now {
// The mouse has been released. TODO: make mouse button important?
for _, child := range hovering {
child.widget.Event(Drop, XY)
}
s.DragStop()
}
return ErrStopPropagation
}

for _, child := range hovering {
var (
id = child.id
w = child.widget
)
if w.Hidden() {
// TODO: somehow the Supervisor wasn't triggering hidden widgets
// anyway, but I don't know why. Adding this check for safety.
continue
}

// Cursor has intersected the widget.
if _, ok := s.hovering[id]; !ok {
w.Event(MouseOver, XY)
s.hovering[id] = nil
}

_, isClicked := s.clicked[id]
if ev.Button1.Now {
if !isClicked {
w.Event(MouseDown, XY)
s.clicked[id] = nil
}
} else if isClicked {
w.Event(MouseUp, XY)
w.Event(Click, XY)
delete(s.clicked, id)
}
}
for _, child := range outside {
var (
id = child.id
w = child.widget
)

// Cursor is not intersecting the widget.
if _, ok := s.hovering[id]; ok {
w.Event(MouseOut, XY)
delete(s.hovering, id)
}

if _, ok := s.clicked[id]; ok {
w.Event(MouseUp, XY)
delete(s.clicked, id)
}
}

return nil
}

// Hovering returns all of the widgets managed by Supervisor that are under
// the mouse cursor. Returns the set of widgets below the cursor and the set
// of widgets not below the cursor.
func (s *Supervisor) Hovering(cursor render.Point) (hovering, outside []WidgetSlot) {
var XY = cursor // for shorthand
hovering = []WidgetSlot{}
outside = []WidgetSlot{}

// Check all the widgets under our care.
for child := range s.Widgets() {
var (
w = child.widget
P = w.Point()
S = w.Size()
P2 = render.Point{
X: P.X + S.W,
Y: P.Y + S.H,
}
)

if XY.X >= P.X && XY.X <= P2.X && XY.Y >= P.Y && XY.Y <= P2.Y {
// Cursor intersects the widget.
hovering = append(hovering, child)
} else {
outside = append(outside, child)
}
}

return hovering, outside
}

// Widgets returns a channel of widgets managed by the supervisor in the order
// they were added.
func (s *Supervisor) Widgets() <-chan WidgetSlot {
pipe := make(chan WidgetSlot)
go func() {
for i := 0; i < s.serial; i++ {
if w, ok := s.widgets[i]; ok {
pipe <- w
}
}
close(pipe)
}()
return pipe
}

// Present all widgets managed by the supervisor.
func (s *Supervisor) Present(e render.Engine) {
s.lock.RLock()
defer s.lock.RUnlock()

for child := range s.Widgets() {
var w = child.widget
w.Present(e, w.Point())
}
}

// Add a widget to be supervised.
func (s *Supervisor) Add(w Widget) {
s.lock.Lock()
s.widgets[s.serial] = WidgetSlot{
id: s.serial,
widget: w,
}
s.serial++
s.lock.Unlock()
}

+ 12
- 0
theme/theme.go View File

@@ -0,0 +1,12 @@
package theme

import "git.kirsle.net/apps/doodle/lib/render"

// Color schemes.
var (
ButtonBackgroundColor = render.RGBA(200, 200, 200, 255)
ButtonHoverColor = render.RGBA(200, 255, 255, 255)
ButtonOutlineColor = render.Black

BorderColorOffset int32 = 40
)

+ 495
- 0
widget.go View File

@@ -0,0 +1,495 @@
package ui

import (
"git.kirsle.net/apps/doodle/lib/render"
"git.kirsle.net/apps/doodle/lib/ui/theme"
)

// BorderStyle options for widget.SetBorderStyle()
type BorderStyle string

// Styles for a widget border.
const (
BorderNone BorderStyle = ""
BorderSolid BorderStyle = "solid"
BorderRaised = "raised"
BorderSunken = "sunken"
)

// Widget is a user interface element.
type Widget interface {
ID() string // Get the widget's string ID.
IDFunc(func() string) // Set a function that returns the widget's ID.
String() string
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.
Resize(render.Rect)
ResizeBy(render.Rect)
Rect() render.Rect // Return the full absolute rect combining the Size() and Point()

Handle(Event, func(render.Point))
Event(Event, render.Point) // called internally to trigger an event

// Thickness of the padding + border + outline.
BoxThickness(multiplier int32) int32
DrawBox(render.Engine, render.Point)

// Widget configuration getters.
Margin() int32 // Margin away from other widgets
SetMargin(int32) //
Background() render.Color // Background color
SetBackground(render.Color) //
Foreground() render.Color // Foreground color
SetForeground(render.Color) //
BorderStyle() BorderStyle // Border style: none, raised, sunken
SetBorderStyle(BorderStyle) //
BorderColor() render.Color // Border color (default is Background)
SetBorderColor(render.Color) //
BorderSize() int32 // Border size (default 0)
SetBorderSize(int32) //
OutlineColor() render.Color // Outline color (default Invisible)
SetOutlineColor(render.Color) //
OutlineSize() int32 // Outline size (default 0)
SetOutlineSize(int32) //

// Visibility
Hide()
Show()
Hidden() bool

// Container widgets like Frames can wire up associations between the
// child widgets and the parent.
Parent() (parent Widget, ok bool)
Adopt(parent Widget) // for the container to assign itself the parent
Children() []Widget // for containers to return their children

// Run any render computations; by the end the widget must know its
// Width and Height. For example the Label widget will render itself onto
// an SDL Surface and then it will know its bounding box, but not before.
Compute(render.Engine)

// Render the final widget onto the drawing engine.
Present(render.Engine, render.Point)
}

// Config holds common base widget configs for quick configuration.
type Config struct {
// Size management. If you provide a non-zero value for Width and Height,
// the widget will be resized and the "fixedSize" flag is set, meaning it
// will not re-compute its size dynamically. To set the size while also
// keeping the auto-resize property, pass AutoResize=true too. This is
// mainly used internally when widgets are calculating their automatic sizes.
AutoResize bool
Width int32
Height int32
Margin int32
MarginX int32
MarginY int32
Background render.Color
Foreground render.Color
BorderSize int32
BorderStyle BorderStyle
BorderColor render.Color
OutlineSize int32
OutlineColor render.Color
}

// BaseWidget holds common functionality for all widgets, such as managing
// their widths and heights.
type BaseWidget struct {
id string
idFunc func() string
fixedSize bool
hidden bool
width int32
height int32
point render.Point
margin int32
background render.Color
foreground render.Color
borderStyle BorderStyle
borderColor render.Color
borderSize int32
outlineColor render.Color
outlineSize int32
handlers map[Event][]func(render.Point)
hasParent bool
parent Widget
}

// SetID sets a string name for your widget, helpful for debugging purposes.
func (w *BaseWidget) SetID(id string) {
w.id = id
}

// ID returns the ID that the widget calls itself by.
func (w *BaseWidget) ID() string {
if w.idFunc == nil {
w.IDFunc(func() string {
return "Widget<Untitled>"
})
}
return w.idFunc()
}

// IDFunc sets an ID function.
func (w *BaseWidget) IDFunc(fn func() string) {
w.idFunc = fn
}

func (w *BaseWidget) String() string {
return w.ID()
}

// Configure the base widget with all the common properties at once. Any
// property left as the zero value will not update the widget.
func (w *BaseWidget) Configure(c Config) {
if c.Width != 0 || c.Height != 0 {
w.fixedSize = !c.AutoResize
if c.Width != 0 {
w.width = c.Width
}
if c.Height != 0 {
w.height = c.Height
}
}

if c.Margin != 0 {
w.margin = c.Margin
}
if c.Background != render.Invisible {
w.background = c.Background
}
if c.Foreground != render.Invisible {
w.foreground = c.Foreground
}
if c.BorderColor != render.Invisible {
w.borderColor = c.BorderColor
}
if c.OutlineColor != render.Invisible {
w.outlineColor = c.OutlineColor
}

if c.BorderSize != 0 {
w.borderSize = c.BorderSize
}
if c.BorderStyle != BorderNone {
w.borderStyle = c.BorderStyle
}
if c.OutlineSize != 0 {
w.outlineSize = c.OutlineSize
}
}

// Rect returns the widget's absolute rectangle, the combined Size and Point.
func (w *BaseWidget) Rect() render.Rect {
return render.Rect{
X: w.point.X,
Y: w.point.Y,
W: w.width,
H: w.height,
}
}

// Point returns the X,Y position of the widget on the window.
func (w *BaseWidget) Point() render.Point {
return w.point
}

// MoveTo updates the X,Y position to the new point.
func (w *BaseWidget) MoveTo(v render.Point) {
w.point = v
}

// MoveBy adds the X,Y values to the widget's current position.
func (w *BaseWidget) MoveBy(v render.Point) {
w.point.X += v.X
w.point.Y += v.Y
}

// Size returns the box with W and H attributes containing the size of the
// widget. The X,Y attributes of the box are ignored and zero.
func (w *BaseWidget) Size() render.Rect {
return render.Rect{
W: w.width,
H: w.height,
}
}

// BoxSize returns the full rendered size of the widget including its box
// thickness (border, padding and outline).
func (w *BaseWidget) BoxSize() render.Rect {
return render.Rect{
W: w.width + w.BoxThickness(2),
H: w.height + w.BoxThickness(2),
}
}

// FixedSize returns whether the widget's size has been hard-coded by the user
// (true) or if it automatically resizes based on its contents (false).
func (w *BaseWidget) FixedSize() bool {
return w.fixedSize
}

// 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
w.width = v.W
w.height = v.H
}

// ResizeBy resizes by a relative amount.
func (w *BaseWidget) ResizeBy(v render.Rect) {
w.fixedSize = true
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) {
w.width = v.W
w.height = v.H
}

// BoxThickness returns the full sum of the padding, border and outline.
// m = multiplier, i.e., 1 or 2
func (w *BaseWidget) BoxThickness(m int32) int32 {
if m == 0 {
m = 1
}
return (w.Margin() * m) + (w.BorderSize() * m) + (w.OutlineSize() * m)
}

// Parent returns the parent widget, like a Frame, and a boolean indicating
// whether the widget had a parent.
func (w *BaseWidget) Parent() (Widget, bool) {
return w.parent, w.hasParent
}

// Adopt sets the widget's parent. This function is called by container
// widgets like Frame when they add a child widget to their care.
// Pass a nil parent to unset the parent.
func (w *BaseWidget) Adopt(parent Widget) {
if parent == nil {
w.hasParent = false
w.parent = nil
} else {
w.hasParent = true
w.parent = parent
}
}

// Children returns the widget's children, to be implemented by containers.
// The default implementation returns an empty slice.
func (w *BaseWidget) Children() []Widget {
return []Widget{}
}

// Hide the widget from being rendered.
func (w *BaseWidget) Hide() {
w.hidden = true
}

// Show the widget.
func (w *BaseWidget) Show() {
w.hidden = false
}

// Hidden returns whether the widget is hidden. If this widget is not hidden,
// but it has a parent, this will recursively crawl the parents to see if any
// of them are hidden.
func (w *BaseWidget) Hidden() bool {
if w.hidden {
return true
}

if parent, ok := w.Parent(); ok {
return parent.Hidden()
}

return false
}

// DrawBox draws the border and outline.
func (w *BaseWidget) DrawBox(e render.Engine, P render.Point) {
var (
S = w.Size()
outline = w.OutlineSize()
border = w.BorderSize()
borderColor = w.BorderColor()
highlight = borderColor.Lighten(theme.BorderColorOffset)
shadow = borderColor.Darken(theme.BorderColorOffset)
color render.Color
box = render.Rect{
X: P.X,
Y: P.Y,
W: S.W,
H: S.H,
}
)

if borderColor == render.Invisible {
borderColor = render.Red
}

// Draw the outline layer as the full size of the widget.
if outline > 0 && w.OutlineColor() != render.Invisible {
e.DrawBox(w.OutlineColor(), render.Rect{
X: P.X,
Y: P.Y,
W: S.W,
H: S.H,
})
}
box.X += outline
box.Y += outline
box.W -= outline * 2
box.H -= outline * 2

// Highlight on the top left edge.
if border > 0 {
if w.BorderStyle() == BorderRaised {
color = highlight
} else if w.BorderStyle() == BorderSunken {
color = shadow
} else {
color = borderColor
}
e.DrawBox(color, box)
}

// Shadow on the bottom right edge.
box.X += border
box.Y += border
box.W -= border
box.H -= border
if w.BorderSize() > 0 {
if w.BorderStyle() == BorderRaised {
color = shadow
} else if w.BorderStyle() == BorderSunken {
color = highlight
} else {
color = borderColor
}
e.DrawBox(color, box)
}

// Background color of the button.
box.W -= border
box.H -= border
if w.Background() != render.Invisible {
e.DrawBox(w.Background(), box)
}
}

// Margin returns the margin width.
func (w *BaseWidget) Margin() int32 {
return w.margin
}

// SetMargin sets the margin width.
func (w *BaseWidget) SetMargin(v int32) {
w.margin = v
}

// Background returns the background color.
func (w *BaseWidget) Background() render.Color {
return w.background
}

// SetBackground sets the color.
func (w *BaseWidget) SetBackground(c render.Color) {
w.background = c
}

// Foreground returns the foreground color.
func (w *BaseWidget) Foreground() render.Color {
return w.foreground
}

// SetForeground sets the color.
func (w *BaseWidget) SetForeground(c render.Color) {
w.foreground = c
}

// BorderStyle returns the border style.
func (w *BaseWidget) BorderStyle() BorderStyle {
return w.borderStyle
}

// SetBorderStyle sets the border style.
func (w *BaseWidget) SetBorderStyle(v BorderStyle) {
w.borderStyle = v
}

// BorderColor returns the border color, or defaults to the background color.
func (w *BaseWidget) BorderColor() render.Color {
if w.borderColor == render.Invisible {
return w.Background()
}
return w.borderColor
}

// SetBorderColor sets the border color.
func (w *BaseWidget) SetBorderColor(c render.Color) {
w.borderColor = c
}

// BorderSize returns the border thickness.
func (w *BaseWidget) BorderSize() int32 {
return w.borderSize
}

// SetBorderSize sets the border thickness.
func (w *BaseWidget) SetBorderSize(v int32) {
w.borderSize = v
}

// OutlineColor returns the background color.
func (w *BaseWidget) OutlineColor() render.Color {
return w.outlineColor
}

// SetOutlineColor sets the color.
func (w *BaseWidget) SetOutlineColor(c render.Color) {
w.outlineColor = c
}

// OutlineSize returns the outline thickness.
func (w *BaseWidget) OutlineSize() int32 {
return w.outlineSize
}

// SetOutlineSize sets the outline thickness.
func (w *BaseWidget) SetOutlineSize(v int32) {
w.outlineSize = v
}

// Event is called internally by Doodle to trigger an event.
func (w *BaseWidget) Event(event Event, p render.Point) {
if handlers, ok := w.handlers[event]; ok {
for _, fn := range handlers {
fn(p)
}
}
}

// Handle an event in the widget.
func (w *BaseWidget) Handle(event Event, fn func(render.Point)) {
if w.handlers == nil {
w.handlers = map[Event][]func(render.Point){}
}

if _, ok := w.handlers[event]; !ok {
w.handlers[event] = []func(render.Point){}
}

w.handlers[event] = append(w.handlers[event], fn)
}

// OnMouseOut should be overridden on widgets who want this event.
func (w *BaseWidget) OnMouseOut(render.Point) {}

+ 114
- 0
window.go View File

@@ -0,0 +1,114 @@
package ui

import (
"fmt"

"git.kirsle.net/apps/doodle/lib/render"
)

// Window is a frame with a title bar.
type Window struct {
BaseWidget
Title string
Active bool

// Private widgets.
body *Frame
titleBar *Label
content *Frame
}

// NewWindow creates a new window.
func NewWindow(title string) *Window {
w := &Window{
Title: title,
body: NewFrame("body:" + title),
}
w.IDFunc(func() string {
return fmt.Sprintf("Window<%s>",
w.Title,
)
})

w.body.Configure(Config{
Background: render.Grey,
BorderSize: 2,
BorderStyle: BorderRaised,
})

// Title bar widget.
titleBar := NewLabel(Label{
TextVariable: &w.Title,
Font: render.Text{
Color: render.White,
Size: 10,
Stroke: render.DarkBlue,
Padding: 2,
},
})
titleBar.Configure(Config{
Background: render.Blue,
})
w.body.Pack(titleBar, Pack{
Anchor: N,
Fill: true,
})
w.titleBar = titleBar

// Window content frame.
content := NewFrame("content:" + title)
content.Configure(Config{
Background: render.Grey,
})
w.body.Pack(content, Pack{
Anchor: N,
Fill: true,
})
w.content = content

return w
}

// Children returns the window's child widgets.
func (w *Window) Children() []Widget {
return []Widget{
w.body,
}
}

// TitleBar returns the title bar widget.
func (w *Window) TitleBar() *Label {
return w.titleBar
}

// Configure the widget. Color and style changes are passed down to the inner
// content frame of the window.
func (w *Window) Configure(C Config) {
w.BaseWidget.Configure(C)
w.body.Configure(C)

// Don't pass dimensions down any further than the body.
C.Width = 0
C.Height = 0
w.content.Configure(C)
}

// ConfigureTitle configures the title bar widget.
func (w *Window) ConfigureTitle(C Config) {
w.titleBar.Configure(C)
}

// Compute the window.
func (w *Window) Compute(e render.Engine) {
w.body.Compute(e)
}

// Present the window.
func (w *Window) Present(e render.Engine, P render.Point) {
w.body.Present(e, P)
}

// Pack a widget into the window's frame.
func (w *Window) Pack(child Widget, config ...Pack) {
w.content.Pack(child, config...)
}

Loading…
Cancel
Save