From 7661f9873c22c1d5e28176ff3e65c9cb716d8765 Mon Sep 17 00:00:00 2001 From: Noah Petherbridge Date: Tue, 9 Apr 2019 17:35:44 -0700 Subject: [PATCH] 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. --- button.go | 122 ++++++++++++ check_button.go | 118 ++++++++++++ checkbox.go | 65 +++++++ debug.go | 23 +++ dragdrop.go | 28 +++ frame.go | 91 +++++++++ frame_pack.go | 318 +++++++++++++++++++++++++++++++ functions.go | 38 ++++ image.go | 82 ++++++++ label.go | 125 ++++++++++++ log.go | 14 ++ supervisor.go | 222 ++++++++++++++++++++++ theme/theme.go | 12 ++ widget.go | 495 ++++++++++++++++++++++++++++++++++++++++++++++++ window.go | 114 +++++++++++ 15 files changed, 1867 insertions(+) create mode 100644 button.go create mode 100644 check_button.go create mode 100644 checkbox.go create mode 100644 debug.go create mode 100644 dragdrop.go create mode 100644 frame.go create mode 100644 frame_pack.go create mode 100644 functions.go create mode 100644 image.go create mode 100644 label.go create mode 100644 log.go create mode 100644 supervisor.go create mode 100644 theme/theme.go create mode 100644 widget.go create mode 100644 window.go diff --git a/button.go b/button.go new file mode 100644 index 0000000..708c88a --- /dev/null +++ b/button.go @@ -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) +} diff --git a/check_button.go b/check_button.go new file mode 100644 index 0000000..838667c --- /dev/null +++ b/check_button.go @@ -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) + } + }) +} diff --git a/checkbox.go b/checkbox.go new file mode 100644 index 0000000..e98d7d2 --- /dev/null +++ b/checkbox.go @@ -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) +} diff --git a/debug.go b/debug.go new file mode 100644 index 0000000..2111668 --- /dev/null +++ b/debug.go @@ -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) +} diff --git a/dragdrop.go b/dragdrop.go new file mode 100644 index 0000000..ac1fea0 --- /dev/null +++ b/dragdrop.go @@ -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 +} diff --git a/frame.go b/frame.go new file mode 100644 index 0000000..fe3be40 --- /dev/null +++ b/frame.go @@ -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) + } +} diff --git a/frame_pack.go b/frame_pack.go new file mode 100644 index 0000000..a4fe769 --- /dev/null +++ b/frame_pack.go @@ -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 +) diff --git a/functions.go b/functions.go new file mode 100644 index 0000000..9780a30 --- /dev/null +++ b/functions.go @@ -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. + } +} diff --git a/image.go b/image.go new file mode 100644 index 0000000..32d57fd --- /dev/null +++ b/image.go @@ -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) +} diff --git a/label.go b/label.go new file mode 100644 index 0000000..7601562 --- /dev/null +++ b/label.go @@ -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), + }) + } +} diff --git a/log.go b/log.go new file mode 100644 index 0000000..4ddc06b --- /dev/null +++ b/log.go @@ -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, + }) +} diff --git a/supervisor.go b/supervisor.go new file mode 100644 index 0000000..8c69a73 --- /dev/null +++ b/supervisor.go @@ -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() +} diff --git a/theme/theme.go b/theme/theme.go new file mode 100644 index 0000000..deaf460 --- /dev/null +++ b/theme/theme.go @@ -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 +) diff --git a/widget.go b/widget.go new file mode 100644 index 0000000..e7b2328 --- /dev/null +++ b/widget.go @@ -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" + }) + } + 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) {} diff --git a/window.go b/window.go new file mode 100644 index 0000000..a956e7f --- /dev/null +++ b/window.go @@ -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...) +}