diff --git a/.gitignore b/.gitignore index 8b63e0e..688cbc0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ fonts/ +maps/ +bin/ screenshot-*.png map-*.json diff --git a/cmd/doodle/main.go b/cmd/doodle/main.go index 4890f35..10926f6 100644 --- a/cmd/doodle/main.go +++ b/cmd/doodle/main.go @@ -40,6 +40,7 @@ func main() { ) app := doodle.New(debug, engine) + app.SetupEngine() if filename != "" { if edit { app.EditLevel(filename) diff --git a/doodads/doodads.go b/doodads/doodads.go index 167af25..6667b68 100644 --- a/doodads/doodads.go +++ b/doodads/doodads.go @@ -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, }) { diff --git a/doodads/player.go b/doodads/player.go index baee1a6..0b63c1c 100644 --- a/doodads/player.go +++ b/doodads/player.go @@ -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, diff --git a/doodle.go b/doodle.go index 7f35d03..8c14ff7 100644 --- a/doodle.go +++ b/doodle.go @@ -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 diff --git a/editor_scene.go b/editor_scene.go index 7bd088c..ce03c98 100644 --- a/editor_scene.go +++ b/editor_scene.go @@ -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()), }) } diff --git a/editor_ui.go b/editor_ui.go index 6202ffa..0fd8538 100644 --- a/editor_ui.go +++ b/editor_ui.go @@ -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: ", + StatusFilenameText: "Filename: ", } 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, diff --git a/fps.go b/fps.go index c8cb70a..dd8eb82 100644 --- a/fps.go +++ b/fps.go @@ -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 ) diff --git a/render/grid.go b/level/grid.go similarity index 54% rename from render/grid.go rename to level/grid.go index 2c32f2f..f774476 100644 --- a/render/grid.go +++ b/level/grid.go @@ -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, }) diff --git a/level/json.go b/level/json.go index fbb83ca..53aef71 100644 --- a/level/json.go +++ b/level/json.go @@ -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 } diff --git a/level/palette.go b/level/palette.go new file mode 100644 index 0000000..a13d0b4 --- /dev/null +++ b/level/palette.go @@ -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 + } + } +} diff --git a/level/types.go b/level/types.go index 9bfc1e8..9c9f72c 100644 --- a/level/types.go +++ b/level/types.go @@ -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"` -} diff --git a/play_scene.go b/play_scene.go index 9ba3fe7..0b66ada 100644 --- a/play_scene.go +++ b/play_scene.go @@ -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 } diff --git a/render/color.go b/render/color.go new file mode 100644 index 0000000..d660803 --- /dev/null +++ b/render/color.go @@ -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) +} diff --git a/render/interface.go b/render/interface.go index aa6824a..09c5b16 100644 --- a/render/interface.go +++ b/render/interface.go @@ -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. diff --git a/render/sdl/canvas.go b/render/sdl/canvas.go index eee8d50..9536508 100644 --- a/render/sdl/canvas.go +++ b/render/sdl/canvas.go @@ -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) } diff --git a/ui/check_button.go b/ui/check_button.go index ba4501d..257eb79 100644 --- a/ui/check_button.go +++ b/ui/check_button.go @@ -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 } diff --git a/ui/checkbox.go b/ui/checkbox.go index ab5f16b..38130e8 100644 --- a/ui/checkbox.go +++ b/ui/checkbox.go @@ -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) diff --git a/ui/label.go b/ui/label.go index 57e049e..f3b2766 100644 --- a/ui/label.go +++ b/ui/label.go @@ -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{ diff --git a/ui/window.go b/ui/window.go new file mode 100644 index 0000000..32001ff --- /dev/null +++ b/ui/window.go @@ -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...) +}