Add Palette Window and Palette Support to Edit Mode

* Add ui.Window to easily create reusable windows with titles.
* Add a palette window (panel) to the right edge of the Edit Mode.
  * Has Radio Buttons listing the colors available in the palette.
* Add palette support to Edit Mode so when you draw pixels, they take
  on the color and attributes of the currently selected Swatch in your
  palette.
* Revise the on-disk format to better serialize the Palette object to
  JSON.
* Break Play Mode: collision detection fails because the Grid key
  elements are now full Pixel objects (which retain their Palette and
  Swatch properties).
  * The Grid will need to be re-worked to separate X,Y coordinates from
    the Pixel metadata to just test "is something there, and what is
    it?"
chunks
Noah 2018-08-10 17:19:47 -07:00
parent 8624a28ea9
commit e1cbff8c3f
20 changed files with 685 additions and 198 deletions

2
.gitignore vendored
View File

@ -1,3 +1,5 @@
fonts/
maps/
bin/
screenshot-*.png
map-*.json

View File

@ -40,6 +40,7 @@ func main() {
)
app := doodle.New(debug, engine)
app.SetupEngine()
if filename != "" {
if edit {
app.EditLevel(filename)

View File

@ -68,7 +68,7 @@ const (
)
// CollidesWithGrid checks if a Doodad collides with level geometry.
func CollidesWithGrid(d Doodad, grid *render.Grid, target render.Point) (*Collide, bool) {
func CollidesWithGrid(d Doodad, grid *level.Grid, target render.Point) (*Collide, bool) {
var (
P = d.Position()
S = d.Size()
@ -280,7 +280,7 @@ func GetCollisionBox(box render.Rect) CollisionBox {
// ScanBoundingBox scans all of the pixels in a bounding box on the grid and
// returns if any of them intersect with level geometry.
func (c *Collide) ScanBoundingBox(box render.Rect, grid *render.Grid) bool {
func (c *Collide) ScanBoundingBox(box render.Rect, grid *level.Grid) bool {
col := GetCollisionBox(box)
c.ScanGridLine(col.Top[0], col.Top[1], grid, Top)
@ -293,9 +293,9 @@ func (c *Collide) ScanBoundingBox(box render.Rect, grid *render.Grid) bool {
// ScanGridLine scans all of the pixels between p1 and p2 on the grid and tests
// for any pixels to be set, implying a collision between level geometry and the
// bounding boxes of the doodad.
func (c *Collide) ScanGridLine(p1, p2 render.Point, grid *render.Grid, side Side) {
func (c *Collide) ScanGridLine(p1, p2 render.Point, grid *level.Grid, side Side) {
for point := range render.IterLine2(p1, p2) {
if grid.Exists(level.Pixel{
if grid.Exists(&level.Pixel{
X: point.X,
Y: point.Y,
}) {

View File

@ -72,7 +72,7 @@ func (p *Player) SetGrounded(v bool) {
// Draw the player sprite.
func (p *Player) Draw(e render.Engine) {
e.DrawBox(render.Color{255, 255, 153, 255}, render.Rect{
e.DrawBox(render.RGBA(255, 255, 153, 255), render.Rect{
X: p.point.X,
Y: p.point.Y,
W: p.size.W,

View File

@ -20,8 +20,9 @@ const (
// Doodle is the game object.
type Doodle struct {
Debug bool
Engine render.Engine
Debug bool
Engine render.Engine
engineReady bool
startTime time.Time
running bool
@ -54,12 +55,22 @@ func New(debug bool, engine render.Engine) *Doodle {
return d
}
// Run initializes SDL and starts the main loop.
func (d *Doodle) Run() error {
// Set up the render engine.
// SetupEngine sets up the rendering engine.
func (d *Doodle) SetupEngine() error {
if err := d.Engine.Setup(); err != nil {
return err
}
d.engineReady = true
return nil
}
// Run initializes SDL and starts the main loop.
func (d *Doodle) Run() error {
if !d.engineReady {
if err := d.SetupEngine(); err != nil {
return err
}
}
// Set up the default scene.
if d.Scene == nil {
@ -156,10 +167,9 @@ func (d *Doodle) NewMap() {
// EditLevel loads a map from JSON into the EditorScene.
func (d *Doodle) EditLevel(filename string) error {
log.Info("Loading level from file: %s", filename)
scene := &EditorScene{}
err := scene.LoadLevel(filename)
if err != nil {
return err
scene := &EditorScene{
Filename: filename,
OpenFile: true,
}
d.Goto(scene)
return nil

View File

@ -18,14 +18,17 @@ type EditorScene struct {
// Configuration for the scene initializer.
OpenFile bool
Filename string
Canvas render.Grid
Canvas level.Grid
UI *EditorUI
Palette *level.Palette // Full palette of swatches for this level
Swatch *level.Swatch // actively selected painting swatch
// History of all the pixels placed by the user.
pixelHistory []level.Pixel
pixelHistory []*level.Pixel
lastPixel *level.Pixel // last pixel placed while mouse down and dragging
canvas render.Grid
canvas level.Grid
filename string // Last saved filename.
// Canvas size
@ -48,7 +51,7 @@ func (s *EditorScene) Setup(d *Doodle) error {
if s.OpenFile {
log.Debug("EditorScene: Loading map from filename at %s", s.filename)
if err := s.LoadLevel(s.filename); err != nil {
return err
d.Flash("LoadLevel error: %s", err)
}
}
}
@ -58,16 +61,23 @@ func (s *EditorScene) Setup(d *Doodle) error {
s.Canvas = nil
}
s.UI = NewEditorUI(d)
s.Palette = level.DefaultPalette()
if len(s.Palette.Swatches) > 0 {
s.Swatch = s.Palette.Swatches[0]
s.Palette.ActiveSwatch = s.Swatch.Name
}
// Initialize the user interface. It references the palette and such so it
// must be initialized after those things.
s.UI = NewEditorUI(d, s)
d.Flash("Editor Mode. Press 'P' to play this map.")
if s.pixelHistory == nil {
s.pixelHistory = []level.Pixel{}
s.pixelHistory = []*level.Pixel{}
}
if s.canvas == nil {
log.Debug("EditorScene: Setting default canvas to an empty grid")
s.canvas = render.Grid{}
s.canvas = level.Grid{}
}
s.width = d.width // TODO: canvas width = copy the window size
s.height = d.height
@ -100,28 +110,31 @@ func (s *EditorScene) Loop(d *Doodle, ev *events.State) error {
if ev.Button1.Now {
// log.Warn("Button1: %+v", ev.Button1)
lastPixel := s.lastPixel
pixel := level.Pixel{
X: ev.CursorX.Now,
Y: ev.CursorY.Now,
pixel := &level.Pixel{
X: ev.CursorX.Now,
Y: ev.CursorY.Now,
Palette: s.Palette,
Swatch: s.Swatch,
}
// Append unique new pixels.
if len(s.pixelHistory) == 0 || s.pixelHistory[len(s.pixelHistory)-1] != pixel {
if lastPixel != nil {
// Draw the pixels in between.
if *lastPixel != pixel {
if lastPixel != pixel {
for point := range render.IterLine(lastPixel.X, lastPixel.Y, pixel.X, pixel.Y) {
dot := level.Pixel{
dot := &level.Pixel{
X: point.X,
Y: point.Y,
Palette: lastPixel.Palette,
Swatch: lastPixel.Swatch,
}
s.canvas[dot] = nil
}
}
}
s.lastPixel = &pixel
s.lastPixel = pixel
s.pixelHistory = append(s.pixelHistory, pixel)
// Save in the pixel canvas map.
@ -145,19 +158,20 @@ func (s *EditorScene) Draw(d *Doodle) error {
// LoadLevel loads a level from disk.
func (s *EditorScene) LoadLevel(filename string) error {
s.filename = filename
s.pixelHistory = []level.Pixel{}
s.canvas = render.Grid{}
s.pixelHistory = []*level.Pixel{}
s.canvas = level.Grid{}
m, err := level.LoadJSON(filename)
if err != nil {
return err
}
for _, point := range m.Pixels {
pixel := level.Pixel{
X: point.X,
Y: point.Y,
}
s.Palette = m.Palette
if len(s.Palette.Swatches) > 0 {
s.Swatch = m.Palette.Swatches[0]
}
for _, pixel := range m.Pixels {
s.pixelHistory = append(s.pixelHistory, pixel)
s.canvas[pixel] = nil
}
@ -168,26 +182,19 @@ func (s *EditorScene) LoadLevel(filename string) error {
// SaveLevel saves the level to disk.
func (s *EditorScene) SaveLevel(filename string) {
s.filename = filename
m := level.Level{
Version: 1,
Title: "Alpha",
Author: os.Getenv("USER"),
Width: s.width,
Height: s.height,
Palette: []level.Palette{
level.Palette{
Color: "#000000",
Solid: true,
},
},
Pixels: []level.Pixel{},
}
m := level.New()
m.Title = "Alpha"
m.Author = os.Getenv("USER")
m.Width = s.width
m.Height = s.height
m.Palette = s.Palette
for pixel := range s.canvas {
m.Pixels = append(m.Pixels, level.Pixel{
X: pixel.X,
Y: pixel.Y,
Palette: 0,
m.Pixels = append(m.Pixels, &level.Pixel{
X: pixel.X,
Y: pixel.Y,
PaletteIndex: int32(pixel.Swatch.Index()),
})
}

View File

@ -11,42 +11,110 @@ import (
// EditorUI manages the user interface for the Editor Scene.
type EditorUI struct {
d *Doodle
d *Doodle
Scene *EditorScene
// Variables
StatusMouseText string
StatusMouseText string
StatusPaletteText string
StatusFilenameText string
// Widgets
Supervisor *ui.Supervisor
Palette *ui.Window
StatusBar *ui.Frame
}
// NewEditorUI initializes the Editor UI.
func NewEditorUI(d *Doodle) *EditorUI {
func NewEditorUI(d *Doodle, s *EditorScene) *EditorUI {
u := &EditorUI{
d: d,
Supervisor: ui.NewSupervisor(),
StatusMouseText: ".",
d: d,
Scene: s,
Supervisor: ui.NewSupervisor(),
StatusMouseText: "Cursor: (waiting)",
StatusPaletteText: "Swatch: <none>",
StatusFilenameText: "Filename: <none>",
}
u.StatusBar = u.SetupStatusBar(d)
u.Palette = u.SetupPalette(d)
return u
}
// Loop to process events and update the UI.
func (u *EditorUI) Loop(ev *events.State) {
u.Supervisor.Loop(ev)
u.StatusMouseText = fmt.Sprintf("Mouse: (%d,%d)",
ev.CursorX.Now,
ev.CursorY.Now,
)
u.StatusPaletteText = fmt.Sprintf("Swatch: %s",
u.Scene.Swatch,
)
// Statusbar filename label.
filename := "untitled.map"
if u.Scene.filename != "" {
filename = u.Scene.filename
}
u.StatusFilenameText = fmt.Sprintf("Filename: %s",
filename,
)
u.StatusBar.Compute(u.d.Engine)
u.Supervisor.Loop(ev)
u.Palette.Compute(u.d.Engine)
}
// Present the UI to the screen.
func (u *EditorUI) Present(e render.Engine) {
u.Palette.Present(e, u.Palette.Point())
u.StatusBar.Present(e, u.StatusBar.Point())
}
// SetupPalette sets up the palette panel.
func (u *EditorUI) SetupPalette(d *Doodle) *ui.Window {
window := ui.NewWindow("Palette")
window.Configure(ui.Config{
Width: 150,
Height: u.d.height - u.StatusBar.Size().H,
})
window.MoveTo(render.NewPoint(
u.d.width-window.BoxSize().W,
0,
))
// Handler function for the radio buttons being clicked.
onClick := func(p render.Point) {
name := u.Scene.Palette.ActiveSwatch
swatch, ok := u.Scene.Palette.Get(name)
if !ok {
log.Error("Palette onClick: couldn't get swatch named '%s' from palette", name)
return
}
u.Scene.Swatch = swatch
}
// Draw the radio buttons for the palette.
for _, swatch := range u.Scene.Palette.Swatches {
label := ui.NewLabel(ui.Label{
Text: swatch.Name,
Font: balance.StatusFont,
})
label.Font.Color = swatch.Color.Darken(40)
btn := ui.NewRadioButton("palette", &u.Scene.Palette.ActiveSwatch, swatch.Name, label)
btn.Handle("MouseUp", onClick)
u.Supervisor.Add(btn)
window.Pack(btn, ui.Pack{
Anchor: ui.N,
Fill: true,
})
}
return window
}
// SetupStatusBar sets up the status bar widget along the bottom of the window.
func (u *EditorUI) SetupStatusBar(d *Doodle) *ui.Frame {
frame := ui.NewFrame("Status Bar")
@ -57,50 +125,58 @@ func (u *EditorUI) SetupStatusBar(d *Doodle) *ui.Frame {
Width: d.width,
})
cursorLabel := ui.NewLabel(ui.Label{
TextVariable: &u.StatusMouseText,
Font: balance.StatusFont,
})
cursorLabel.Configure(ui.Config{
style := ui.Config{
Background: render.Grey,
BorderStyle: ui.BorderSunken,
BorderColor: render.Grey,
BorderSize: 1,
}
cursorLabel := ui.NewLabel(ui.Label{
TextVariable: &u.StatusMouseText,
Font: balance.StatusFont,
})
cursorLabel.Configure(style)
cursorLabel.Compute(d.Engine)
frame.Pack(cursorLabel, ui.Pack{
Anchor: ui.W,
})
paletteLabel := ui.NewLabel(ui.Label{
TextVariable: &u.StatusPaletteText,
Font: balance.StatusFont,
})
paletteLabel.Configure(style)
paletteLabel.Compute(d.Engine)
frame.Pack(paletteLabel, ui.Pack{
Anchor: ui.W,
})
filenameLabel := ui.NewLabel(ui.Label{
Text: "Filename: untitled.map",
Font: balance.StatusFont,
})
filenameLabel.Configure(ui.Config{
Background: render.Grey,
BorderStyle: ui.BorderSunken,
BorderColor: render.Grey,
BorderSize: 1,
TextVariable: &u.StatusFilenameText,
Font: balance.StatusFont,
})
filenameLabel.Configure(style)
filenameLabel.Compute(d.Engine)
frame.Pack(filenameLabel, ui.Pack{
Anchor: ui.W,
})
extraLabel := ui.NewLabel(ui.Label{
Text: "blah",
Font: balance.StatusFont,
})
extraLabel.Configure(ui.Config{
Background: render.Grey,
BorderStyle: ui.BorderSunken,
BorderColor: render.Grey,
BorderSize: 1,
})
extraLabel.Compute(d.Engine)
frame.Pack(extraLabel, ui.Pack{
Anchor: ui.E,
})
// TODO: right-aligned labels clip out of bounds
// extraLabel := ui.NewLabel(ui.Label{
// Text: "blah",
// Font: balance.StatusFont,
// })
// extraLabel.Configure(ui.Config{
// Background: render.Grey,
// BorderStyle: ui.BorderSunken,
// BorderColor: render.Grey,
// BorderSize: 1,
// })
// extraLabel.Compute(d.Engine)
// frame.Pack(extraLabel, ui.Pack{
// Anchor: ui.E,
// })
frame.Resize(render.Rect{
W: d.width,

2
fps.go
View File

@ -13,7 +13,7 @@ const maxSamples = 100
// Debug mode options, these can be enabled in the dev console
// like: boolProp DebugOverlay true
var (
DebugOverlay = true
DebugOverlay = false
DebugCollision = true
)

View File

@ -1,14 +1,14 @@
package render
package level
import (
"git.kirsle.net/apps/doodle/level"
"git.kirsle.net/apps/doodle/render"
)
// Grid is a 2D grid of pixels in X,Y notation.
type Grid map[level.Pixel]interface{}
type Grid map[*Pixel]interface{}
// Exists returns true if the point exists on the grid.
func (g *Grid) Exists(p level.Pixel) bool {
func (g *Grid) Exists(p *Pixel) bool {
if _, ok := (*g)[p]; ok {
return true
}
@ -16,9 +16,10 @@ func (g *Grid) Exists(p level.Pixel) bool {
}
// Draw the grid efficiently.
func (g *Grid) Draw(e Engine) {
func (g *Grid) Draw(e render.Engine) {
for pixel := range *g {
e.DrawPoint(Black, Point{
color := pixel.Swatch.Color
e.DrawPoint(color, render.Point{
X: pixel.X,
Y: pixel.Y,
})

View File

@ -3,6 +3,7 @@ package level
import (
"bytes"
"encoding/json"
"fmt"
"os"
)
@ -16,15 +17,30 @@ func (m *Level) ToJSON() ([]byte, error) {
}
// LoadJSON loads a map from JSON file.
func LoadJSON(filename string) (Level, error) {
func LoadJSON(filename string) (*Level, error) {
fh, err := os.Open(filename)
if err != nil {
return Level{}, err
return nil, err
}
defer fh.Close()
m := Level{}
m := New()
decoder := json.NewDecoder(fh)
err = decoder.Decode(&m)
if err != nil {
return m, err
}
// Inflate the private instance values.
for _, px := range m.Pixels {
if int(px.PaletteIndex) > len(m.Palette.Swatches) {
return nil, fmt.Errorf(
"pixel %s references palette index %d but there are only %d swatches in the palette",
px, px.PaletteIndex, len(m.Palette.Swatches),
)
}
px.Palette = m.Palette
px.Swatch = m.Palette.Swatches[px.PaletteIndex]
}
return m, err
}

90
level/palette.go Normal file
View File

@ -0,0 +1,90 @@
package level
import (
"git.kirsle.net/apps/doodle/render"
)
// DefaultPalette returns a sensible default palette.
func DefaultPalette() *Palette {
return &Palette{
Swatches: []*Swatch{
&Swatch{
Name: "solid",
Color: render.Black,
Solid: true,
},
&Swatch{
Name: "decoration",
Color: render.Grey,
},
&Swatch{
Name: "fire",
Color: render.Red,
Fire: true,
},
&Swatch{
Name: "water",
Color: render.Blue,
Water: true,
},
},
}
}
// Palette holds an index of colors used in a drawing.
type Palette struct {
Swatches []*Swatch `json:"swatches"`
// Private runtime values
ActiveSwatch string `json:"-"` // name of the actively selected color
byName map[string]int // Cache map of swatches by name
}
// Swatch holds details about a single value in the palette.
type Swatch struct {
Name string `json:"name"`
Color render.Color `json:"color"`
// Optional attributes.
Solid bool `json:"solid,omitempty"`
Fire bool `json:"fire,omitempty"`
Water bool `json:"water,omitempty"`
// Private runtime attributes.
index int // position in the Palette, for reverse of `Palette.byName`
}
func (s Swatch) String() string {
return s.Name
}
// Index returns the Swatch's position in the palette.
func (s Swatch) Index() int {
return s.index
}
// Get a swatch by name.
func (p *Palette) Get(name string) (result *Swatch, exists bool) {
p.update()
if index, ok := p.byName[name]; ok && index < len(p.Swatches) {
result = p.Swatches[index]
exists = true
}
return
}
// update the internal caches and such.
func (p *Palette) update() {
// Initialize the name cache if nil or if the size disagrees with the
// length of the swatches available.
if p.byName == nil || len(p.byName) != len(p.Swatches) {
// Initialize the name cache.
p.byName = map[string]int{}
for i, swatch := range p.Swatches {
swatch.index = i
p.byName[swatch.Name] = i
}
}
}

View File

@ -20,25 +20,42 @@ type Level struct {
// The Palette holds the unique "colors" used in this map file, and their
// properties (solid, fire, slippery, etc.)
Palette []Palette `json:"palette"`
Palette *Palette `json:"palette"`
// Pixels is a 2D array indexed by [X][Y]. The cell values are indexes into
// the Palette.
Pixels []Pixel `json:"pixels"`
Pixels []*Pixel `json:"pixels"`
}
// New creates a blank level object with all its members initialized.
func New() *Level {
return &Level{
Version: 1,
Pixels: []*Pixel{},
Palette: &Palette{},
}
}
// Pixel associates a coordinate with a palette index.
type Pixel struct {
X int32 `json:"x"`
Y int32 `json:"y"`
Palette int32 `json:"p"`
X int32 `json:"x"`
Y int32 `json:"y"`
PaletteIndex int32 `json:"p"`
// Private runtime values.
Palette *Palette `json:"-"` // pointer to its palette, TODO: needed?
Swatch *Swatch `json:"-"` // pointer to its swatch, for when rendered.
}
func (p Pixel) String() string {
return fmt.Sprintf("Pixel<%s '%s' (%d,%d)>", p.Swatch.Color, p.Swatch.Name, p.X, p.Y)
}
// MarshalJSON serializes a Pixel compactly as a simple list.
func (p Pixel) MarshalJSON() ([]byte, error) {
return []byte(fmt.Sprintf(
`[%d, %d, %d]`,
p.X, p.Y, p.Palette,
p.X, p.Y, p.PaletteIndex,
)), nil
}
@ -52,17 +69,8 @@ func (p *Pixel) UnmarshalJSON(text []byte) error {
p.X = triplet[0]
p.Y = triplet[1]
p.Palette = triplet[2]
if len(triplet) > 2 {
p.PaletteIndex = triplet[2]
}
return nil
}
// Palette are the unique pixel attributes that this map uses, and serves
// as a lookup table for the Pixels.
type Palette struct {
// Required attributes.
Color string `json:"color"`
// Optional attributes.
Solid bool `json:"solid,omitempty"`
Fire bool `json:"fire,omitempty"`
}

View File

@ -11,10 +11,10 @@ import (
type PlayScene struct {
// Configuration attributes.
Filename string
Canvas render.Grid
Canvas level.Grid
// Private variables.
canvas render.Grid
canvas level.Grid
// Canvas size
width int32
@ -46,7 +46,7 @@ func (s *PlayScene) Setup(d *Doodle) error {
if s.canvas == nil {
log.Debug("PlayScene.Setup: no grid given, initializing empty grid")
s.canvas = render.Grid{}
s.canvas = level.Grid{}
}
s.width = d.width // TODO: canvas width = copy the window size
@ -128,18 +128,14 @@ func (s *PlayScene) movePlayer(ev *events.State) {
// LoadLevel loads a level from disk.
func (s *PlayScene) LoadLevel(filename string) error {
s.canvas = render.Grid{}
s.canvas = level.Grid{}
m, err := level.LoadJSON(filename)
if err != nil {
return err
}
for _, point := range m.Pixels {
pixel := level.Pixel{
X: point.X,
Y: point.Y,
}
for _, pixel := range m.Pixels {
s.canvas[pixel] = nil
}

150
render/color.go Normal file
View File

@ -0,0 +1,150 @@
package render
import (
"encoding/json"
"errors"
"fmt"
"regexp"
"strconv"
)
var (
// Regexps to parse hex color codes. Three formats are supported:
// * reHexColor3 uses only 3 hex characters, like #F90
// * reHexColor6 uses standard 6 characters, like #FF9900
// * reHexColor8 is the standard 6 plus alpha channel, like #FF9900FF
reHexColor3 = regexp.MustCompile(`^([A-Fa-f0-9])([A-Fa-f0-9])([A-Fa-f0-9])$`)
reHexColor6 = regexp.MustCompile(`^([A-Fa-f0-9]{2})([A-Fa-f0-9]{2})([A-Fa-f0-9]{2})$`)
reHexColor8 = regexp.MustCompile(`^([A-Fa-f0-9]{2})([A-Fa-f0-9]{2})([A-Fa-f0-9]{2})([A-Fa-f0-9]{2})$`)
)
// Color holds an RGBA color value.
type Color struct {
Red uint8
Green uint8
Blue uint8
Alpha uint8
}
// RGBA creates a new Color.
func RGBA(r, g, b, a uint8) Color {
return Color{
Red: r,
Green: g,
Blue: b,
Alpha: a,
}
}
// HexColor parses a color from hexadecimal code.
func HexColor(hex string) (Color, error) {
c := Black // default color
if len(hex) > 0 && hex[0] == '#' {
hex = hex[1:]
}
var m []string
if len(hex) == 3 {
m = reHexColor3.FindStringSubmatch(hex)
} else if len(hex) == 6 {
m = reHexColor6.FindStringSubmatch(hex)
} else if len(hex) == 8 {
m = reHexColor8.FindStringSubmatch(hex)
} else {
return c, errors.New("not a valid length for color code; only 3, 6 and 8 supported")
}
// Any luck?
if m == nil {
return c, errors.New("not a valid hex color code")
}
// Parse the color values. 16=base, 8=bit size
red, _ := strconv.ParseUint(m[1], 16, 8)
green, _ := strconv.ParseUint(m[2], 16, 8)
blue, _ := strconv.ParseUint(m[3], 16, 8)
// Alpha channel available?
var alpha uint64 = 255
if len(m) == 5 {
alpha, _ = strconv.ParseUint(m[4], 16, 8)
}
c.Red = uint8(red)
c.Green = uint8(green)
c.Blue = uint8(blue)
c.Alpha = uint8(alpha)
return c, nil
}
func (c Color) String() string {
return fmt.Sprintf(
"Color<#%02x%02x%02x>",
c.Red, c.Green, c.Blue,
)
}
// MarshalJSON serializes the Color for JSON.
func (c Color) MarshalJSON() ([]byte, error) {
return []byte(fmt.Sprintf(
`"#%02x%02x%02x"`,
c.Red, c.Green, c.Blue,
)), nil
}
// UnmarshalJSON reloads the Color from JSON.
func (c *Color) UnmarshalJSON(b []byte) error {
var hex string
err := json.Unmarshal(b, &hex)
if err != nil {
return err
}
parsed, err := HexColor(hex)
if err != nil {
return err
}
c.Red = parsed.Red
c.Blue = parsed.Blue
c.Green = parsed.Green
c.Alpha = parsed.Alpha
return nil
}
// Add a relative color value to the color.
func (c Color) Add(r, g, b, a int32) Color {
var (
R = int32(c.Red) + r
G = int32(c.Green) + g
B = int32(c.Blue) + b
A = int32(c.Alpha) + a
)
cap8 := func(v int32) uint8 {
if v > 255 {
v = 255
} else if v < 0 {
v = 0
}
return uint8(v)
}
return Color{
Red: cap8(R),
Green: cap8(G),
Blue: cap8(B),
Alpha: cap8(A),
}
}
// Lighten a color value.
func (c Color) Lighten(v int32) Color {
return c.Add(v, v, v, 0)
}
// Darken a color value.
func (c Color) Darken(v int32) Color {
return c.Add(-v, -v, -v, 0)
}

View File

@ -38,67 +38,6 @@ type Engine interface {
Loop() error // maybe?
}
// Color holds an RGBA color value.
type Color struct {
Red uint8
Green uint8
Blue uint8
Alpha uint8
}
// RGBA creates a new Color.
func RGBA(r, g, b, a uint8) Color {
return Color{
Red: r,
Green: g,
Blue: b,
Alpha: a,
}
}
func (c Color) String() string {
return fmt.Sprintf(
"Color<#%02x%02x%02x>",
c.Red, c.Green, c.Blue,
)
}
// Add a relative color value to the color.
func (c Color) Add(r, g, b, a int32) Color {
var (
R = int32(c.Red) + r
G = int32(c.Green) + g
B = int32(c.Blue) + b
A = int32(c.Alpha) + a
)
cap8 := func(v int32) uint8 {
if v > 255 {
v = 255
} else if v < 0 {
v = 0
}
return uint8(v)
}
return Color{
Red: cap8(R),
Green: cap8(G),
Blue: cap8(B),
Alpha: cap8(A),
}
}
// Lighten a color value.
func (c Color) Lighten(v int32) Color {
return c.Add(v, v, v, 0)
}
// Darken a color value.
func (c Color) Darken(v int32) Color {
return c.Add(-v, -v, -v, 0)
}
// Point holds an X,Y coordinate value.
type Point struct {
X int32
@ -165,7 +104,12 @@ type Text struct {
}
func (t Text) String() string {
return fmt.Sprintf("Text<%s>", t.Text)
return fmt.Sprintf(`Text<"%s" %dpx %s>`, t.Text, t.Size, t.Color)
}
// IsZero returns if the Text is the zero value.
func (t Text) IsZero() bool {
return t.Text == "" && t.Size == 0 && t.Color == Invisible && t.Padding == 0 && t.Stroke == Invisible && t.Shadow == Invisible
}
// Common color names.

View File

@ -17,7 +17,7 @@ func (r *Renderer) Clear(color render.Color) {
// DrawPoint puts a color at a pixel.
func (r *Renderer) DrawPoint(color render.Color, point render.Point) {
if color != r.lastColor {
r.renderer.SetDrawColor(color.Red, color.Blue, color.Green, color.Alpha)
r.renderer.SetDrawColor(color.Red, color.Green, color.Blue, color.Alpha)
}
r.renderer.DrawPoint(point.X, point.Y)
}

View File

@ -2,16 +2,20 @@ package ui
import (
"fmt"
"strconv"
"git.kirsle.net/apps/doodle/render"
"git.kirsle.net/apps/doodle/ui/theme"
)
// CheckButton is a button that is bound to a boolean variable and stays clicked
// once pressed, until clicked again to release.
// 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
BoolVar *bool
StringVar *string
Value string
}
// NewCheckButton creates a new CheckButton.
@ -24,6 +28,41 @@ func NewCheckButton(name string, boolVar *bool, child Widget) *CheckButton {
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 {
@ -57,16 +96,23 @@ func NewCheckButton(name string, boolVar *bool, child Widget) *CheckButton {
})
w.Handle("MouseDown", func(p render.Point) {
var sunken bool
if w.BoolVar != nil {
if *w.BoolVar {
*w.BoolVar = false
w.SetBorderStyle(BorderRaised)
} else {
*w.BoolVar = true
w.SetBorderStyle(BorderSunken)
sunken = true
}
} else if w.StringVar != nil {
*w.StringVar = w.Value
sunken = true
}
if sunken {
w.SetBorderStyle(BorderSunken)
} else {
w.SetBorderStyle(BorderRaised)
}
})
return w
}

View File

@ -11,12 +11,26 @@ type Checkbox struct {
// 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{
button: NewCheckButton(name+"_button", boolVar, mark),
child: child,
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()
@ -39,6 +53,11 @@ func NewCheckbox(name string, boolVar *bool, child Widget) *Checkbox {
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)

View File

@ -6,6 +6,12 @@ import (
"git.kirsle.net/apps/doodle/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
@ -24,10 +30,13 @@ func NewLabel(c Label) *Label {
w := &Label{
Text: c.Text,
TextVariable: c.TextVariable,
Font: c.Font,
Font: DefaultFont,
}
if !c.Font.IsZero() {
w.Font = c.Font
}
w.IDFunc(func() string {
return fmt.Sprintf("Label<%s>", w.text().Text)
return fmt.Sprintf(`Label<"%s">`, w.text().Text)
})
return w
}
@ -43,9 +52,19 @@ func (w *Label) text() render.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) {
rect, _ := e.ComputeTextRect(w.text())
rect, err := e.ComputeTextRect(w.text())
if err != nil {
log.Error("%s: failed to compute text rect: %s", w, err)
return
}
if !w.FixedSize() {
w.resizeAuto(render.Rect{

102
ui/window.go Normal file
View File

@ -0,0 +1,102 @@
package ui
import (
"fmt"
"git.kirsle.net/apps/doodle/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
}
// 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)
}
// 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...)
}