Drag Doodads Onto Levels in Edit Mode

Add the ability to drag and drop Doodads onto the level. The Doodad
buttons on the palette now trigger a Drag/Drop behavior when clicked,
and a "blueprint colored" version of the Doodad follows your cursor,
centered on it.

Actors are assigned a random UUID ID when they are placed into a level.

The Canvas gained a MaskColor property that forces all pixels in the
drawing to render as the same color. This is a visual-only effect, and
is used when dragging Doodads in so they render as "blueprints" instead
of their actual colors until they are dropped.

Fix the chunk bitmap cache system so it saves in the $XDG_CACHE_FOLDER
instead of /tmp and has better names. They go into
`~/.config/doodle/chunks/` and have UUID file names -- but they
disappear quickly! As soon as they are cached into SDL2 they are removed
from disk.

Other changes:

- UI: Add Hovering() method that returns the widgets that are beneath
      a point (your cursor) and those that are not, for easy querying
      for event propagation.
- UI: Add ability to return an ErrStopPropagation to tell the master
      Scene (outside the UI) not to continue sending events to other
      parts of the code, so that you don't draw pixels during a drag
      event.
pull/1/head
Noah 2018-10-20 15:42:49 -07:00
parent 20771fbe13
commit 0044b72943
16 changed files with 602 additions and 188 deletions

View File

@ -15,3 +15,9 @@ var (
// Default size for a new Doodad.
DoodadSize = 100
)
// Edit Mode Values
var (
// Number of Doodads per row in the palette.
UIDoodadsPerRow = 2
)

View File

@ -36,4 +36,7 @@ var (
Padding: 4,
Color: render.Black,
}
// Color for draggable doodad.
DragColor = render.MustHexColor("#0099FF")
)

18
docs/UI Ideas.md Normal file
View File

@ -0,0 +1,18 @@
# UI Toolkit Ideas
The UI toolkit was loosely inspired by Tk and could copy more of their ideas.
* **Anchor vs. Side:** currently use Anchor to mean Side when packing widgets
into a Frame. It should be renamed to Side, and then Anchor should be how a
widget centers itself in its space, making it easy to have a Center Middle
widget inside a large frame.
* **Hover Background:** currently the Button sets its own color with its own
events, but this could be moved into the BaseWidget. Tk analog is
`activeBackground`
* **Mouse Cursor:** the BaseWidget should provide a way to configure a mouse
cursor when hovering over the widget.
## Label
* **Text Justify:** when multiple lines of text, align them all to the
left, center, or right.

View File

@ -3,14 +3,16 @@ package doodads
import (
"git.kirsle.net/apps/doodle/balance"
"git.kirsle.net/apps/doodle/level"
"git.kirsle.net/apps/doodle/render"
)
// Doodad is a reusable component for Levels that have scripts and graphics.
type Doodad struct {
level.Base
Palette *level.Palette `json:"palette"`
Script string `json:"script"`
Layers []Layer `json:"layers"`
Filename string `json:"-"` // used internally, not saved in json
Palette *level.Palette `json:"palette"`
Script string `json:"script"`
Layers []Layer `json:"layers"`
}
// Layer holds a layer of drawing data for a Doodad.
@ -39,6 +41,20 @@ func New(size int) *Doodad {
}
}
// ChunkSize returns the chunk size of the Doodad's first layer.
func (d *Doodad) ChunkSize() int {
return d.Layers[0].Chunker.Size
}
// Rect returns a rect of the ChunkSize for scaling a Canvas widget.
func (d *Doodad) Rect() render.Rect {
var size = d.ChunkSize()
return render.Rect{
W: int32(size),
H: int32(size),
}
}
// Inflate attaches the pixels to their swatches after loading from disk.
func (d *Doodad) Inflate() {
d.Palette.Inflate()

View File

@ -6,6 +6,7 @@ import (
"fmt"
"io/ioutil"
"os"
"path/filepath"
)
// ToJSON serializes the doodad as JSON.
@ -29,6 +30,7 @@ func (d *Doodad) WriteJSON(filename string) error {
return fmt.Errorf("Doodad.WriteJSON: WriteFile error: %s", err)
}
d.Filename = filepath.Base(filename)
return nil
}
@ -49,6 +51,7 @@ func LoadJSON(filename string) (*Doodad, error) {
}
// Inflate the chunk metadata to map the pixels to their palette indexes.
d.Filename = filepath.Base(filename)
d.Inflate()
return d, err
}

View File

@ -2,14 +2,13 @@ package doodle
import (
"fmt"
"path/filepath"
"strconv"
"git.kirsle.net/apps/doodle/balance"
"git.kirsle.net/apps/doodle/doodads"
"git.kirsle.net/apps/doodle/enum"
"git.kirsle.net/apps/doodle/events"
"git.kirsle.net/apps/doodle/level"
"git.kirsle.net/apps/doodle/pkg/userdir"
"git.kirsle.net/apps/doodle/render"
"git.kirsle.net/apps/doodle/ui"
"git.kirsle.net/apps/doodle/uix"
@ -29,8 +28,8 @@ type EditorUI struct {
StatusPaletteText string
StatusFilenameText string
StatusScrollText string
selectedSwatch string // name of selected swatch in palette
selectedDoodad string
selectedSwatch string // name of selected swatch in palette
cursor render.Point // remember the cursor position in Loop
// Widgets
Supervisor *ui.Supervisor
@ -44,6 +43,9 @@ type EditorUI struct {
PaletteTab *ui.Frame
DoodadTab *ui.Frame
// Draggable Doodad canvas.
DraggableActor *DraggableActor
// Palette variables.
paletteTab string // selected tab, Palette or Doodads
}
@ -144,47 +146,60 @@ func (u *EditorUI) Resized(d *Doodle) {
}
// Loop to process events and update the UI.
func (u *EditorUI) Loop(ev *events.State) {
u.Supervisor.Loop(ev)
func (u *EditorUI) Loop(ev *events.State) error {
u.cursor = render.NewPoint(ev.CursorX.Now, ev.CursorY.Now)
// Loop the UI and see whether we're told to stop event propagation.
var stopPropagation bool
if err := u.Supervisor.Loop(ev); err != nil {
if err == ui.ErrStopPropagation {
stopPropagation = true
} else {
return err
}
}
// Update status bar labels.
{
var P = u.Workspace.Point()
debugWorldIndex = render.NewPoint(
ev.CursorX.Now-P.X-u.Canvas.Scroll.X,
ev.CursorY.Now-P.Y-u.Canvas.Scroll.Y,
)
u.StatusMouseText = fmt.Sprintf("Mouse: (%d,%d) Px: (%s)",
debugWorldIndex = u.Canvas.WorldIndexAt(u.cursor)
u.StatusMouseText = fmt.Sprintf("Rel:(%d,%d) Abs:(%s)",
ev.CursorX.Now,
ev.CursorY.Now,
debugWorldIndex,
)
}
u.StatusPaletteText = fmt.Sprintf("Swatch: %s",
u.Canvas.Palette.ActiveSwatch,
)
u.StatusScrollText = fmt.Sprintf("Scroll: %s Viewport: %s",
u.Canvas.Scroll,
u.Canvas.Viewport(),
)
u.StatusPaletteText = fmt.Sprintf("Swatch: %s",
u.Canvas.Palette.ActiveSwatch,
)
u.StatusScrollText = fmt.Sprintf("Scroll: %s Viewport: %s",
u.Canvas.Scroll,
u.Canvas.Viewport(),
)
// Statusbar filename label.
filename := "untitled.map"
fileType := "Level"
if u.Scene.filename != "" {
filename = u.Scene.filename
// Statusbar filename label.
filename := "untitled.map"
fileType := "Level"
if u.Scene.filename != "" {
filename = u.Scene.filename
}
if u.Scene.DrawingType == enum.DoodadDrawing {
fileType = "Doodad"
}
u.StatusFilenameText = fmt.Sprintf("Filename: %s (%s)",
filepath.Base(filename),
fileType,
)
}
if u.Scene.DrawingType == enum.DoodadDrawing {
fileType = "Doodad"
}
u.StatusFilenameText = fmt.Sprintf("Filename: %s (%s)",
filename,
fileType,
)
// Recompute widgets.
u.MenuBar.Compute(u.d.Engine)
u.StatusBar.Compute(u.d.Engine)
u.Palette.Compute(u.d.Engine)
u.Canvas.Loop(ev)
// Only forward events to the Canvas if the UI hasn't stopped them.
if !stopPropagation {
u.Canvas.Loop(ev)
}
return nil
}
// Present the UI to the screen.
@ -199,6 +214,17 @@ func (u *EditorUI) Present(e render.Engine) {
u.MenuBar.Present(e, u.MenuBar.Point())
u.StatusBar.Present(e, u.StatusBar.Point())
u.Workspace.Present(e, u.Workspace.Point())
// Are we dragging a Doodad canvas?
if u.Supervisor.IsDragging() {
if actor := u.DraggableActor; actor != nil {
var size = actor.canvas.Size()
actor.canvas.Present(u.d.Engine, render.NewPoint(
u.cursor.X-(size.W/2),
u.cursor.Y-(size.H/2),
))
}
}
}
// SetupWorkspace configures the main Workspace frame that takes up the full
@ -214,9 +240,40 @@ func (u *EditorUI) SetupCanvas(d *Doodle) *uix.Canvas {
drawing := uix.NewCanvas(balance.ChunkSize, true)
drawing.Name = "edit-canvas"
drawing.Palette = level.DefaultPalette()
drawing.SetBackground(render.White)
if len(drawing.Palette.Swatches) > 0 {
drawing.SetSwatch(drawing.Palette.Swatches[0])
}
// Set up the drop handler for draggable doodads.
// NOTE: The drag event begins at editor_ui_doodad.go when configuring the
// Doodad Palette buttons.
drawing.Handle(ui.Drop, func(e render.Point) {
log.Info("Drawing canvas has received a drop!")
var P = ui.AbsolutePosition(drawing)
// Was it an actor from the Doodad Palette?
if actor := u.DraggableActor; actor != nil {
log.Info("Actor is a %s", actor.doodad.Filename)
if u.Scene.Level == nil {
u.d.Flash("Can't drop doodads onto doodad drawings!")
return
}
size := actor.canvas.Size()
u.Scene.Level.Actors.Add(&level.Actor{
// Uncenter the drawing from the cursor.
Point: render.Point{
X: (u.cursor.X - drawing.Scroll.X - (size.W / 2)) - P.X,
Y: (u.cursor.Y - drawing.Scroll.Y - (size.H / 2)) - P.Y,
},
Filename: actor.doodad.Filename,
})
drawing.InstallActors(u.Scene.Level.Actors)
}
})
u.Supervisor.Add(drawing)
return drawing
}
@ -395,106 +452,28 @@ func (u *EditorUI) SetupPalette(d *Doodle) *ui.Window {
// Doodad frame.
{
u.DoodadTab = ui.NewFrame("Doodad Tab")
frame, err := u.setupDoodadFrame(d.Engine, window)
if err != nil {
d.Flash(err.Error())
}
// Even if there was an error (userdir.ListDoodads couldn't read the
// config folder on disk or whatever) the Frame is still valid but
// empty, which is still the intended behavior.
u.DoodadTab = frame
u.DoodadTab.Hide()
window.Pack(u.DoodadTab, ui.Pack{
Anchor: ui.N,
Fill: true,
})
doodadsAvailable, err := userdir.ListDoodads()
if err != nil {
d.Flash("ListDoodads: %s", err)
}
var buttonSize = (paletteWidth - window.BoxThickness(2)) / 2
// Draw the doodad buttons in a grid 2 wide.
var row *ui.Frame
for i, filename := range doodadsAvailable {
si := fmt.Sprintf("%d", i)
if row == nil || i%2 == 0 {
row = ui.NewFrame("Doodad Row " + si)
row.SetBackground(balance.WindowBackground)
u.DoodadTab.Pack(row, ui.Pack{
Anchor: ui.N,
Fill: true,
// Expand: true,
})
}
doodad, err := doodads.LoadJSON(userdir.DoodadPath(filename))
if err != nil {
log.Error(err.Error())
doodad = doodads.New(balance.DoodadSize)
}
can := uix.NewCanvas(int(buttonSize), true)
can.Name = filename
can.LoadDoodad(doodad)
btn := ui.NewRadioButton(filename, &u.selectedDoodad, si, can)
btn.Resize(render.NewRect(
buttonSize-2, // TODO: without the -2 the button border
buttonSize-2, // rests on top of the window border.
))
u.Supervisor.Add(btn)
row.Pack(btn, ui.Pack{
Anchor: ui.W,
})
// Resize the canvas to fill the button interior.
btnSize := btn.Size()
can.Resize(render.NewRect(
btnSize.W-btn.BoxThickness(2),
btnSize.H-btn.BoxThickness(2),
))
btn.Compute(d.Engine)
}
}
// Color Palette Frame.
{
u.PaletteTab = ui.NewFrame("Palette Tab")
u.PaletteTab.SetBackground(balance.WindowBackground)
window.Pack(u.PaletteTab, ui.Pack{
Anchor: ui.N,
Fill: true,
})
// Handler function for the radio buttons being clicked.
onClick := func(p render.Point) {
name := u.selectedSwatch
swatch, ok := u.Canvas.Palette.Get(name)
if !ok {
log.Error("Palette onClick: couldn't get swatch named '%s' from palette", name)
return
}
log.Info("Set swatch: %s", swatch)
u.Canvas.SetSwatch(swatch)
}
// Draw the radio buttons for the palette.
if u.Canvas != nil && u.Canvas.Palette != nil {
for _, swatch := range u.Canvas.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.selectedSwatch, swatch.Name, label)
btn.Handle(ui.Click, onClick)
u.Supervisor.Add(btn)
u.PaletteTab.Pack(btn, ui.Pack{
Anchor: ui.N,
Fill: true,
PadY: 4,
})
}
}
}
u.PaletteTab = u.setupPaletteFrame(window)
window.Pack(u.PaletteTab, ui.Pack{
Anchor: ui.N,
Fill: true,
})
return window
}

115
editor_ui_doodad.go Normal file
View File

@ -0,0 +1,115 @@
package doodle
// XXX REFACTOR XXX
// This function only uses EditorUI and not Doodle and is a candidate for
// refactor into a subpackage if EditorUI itself can ever be decoupled.
import (
"fmt"
"git.kirsle.net/apps/doodle/balance"
"git.kirsle.net/apps/doodle/doodads"
"git.kirsle.net/apps/doodle/pkg/userdir"
"git.kirsle.net/apps/doodle/render"
"git.kirsle.net/apps/doodle/ui"
"git.kirsle.net/apps/doodle/uix"
)
// DraggableActor is a Doodad being dragged from the Doodad palette.
type DraggableActor struct {
canvas *uix.Canvas
doodad *doodads.Doodad
}
// setupDoodadFrame configures the Doodad Palette tab for Edit Mode.
// This is a subroutine of editor_ui.go#SetupPalette()
//
// Can return an error if userdir.ListDoodads() returns an error (like directory
// not found), but it will *ALWAYS* return a valid ui.Frame -- it will just be
// empty and uninitialized.
func (u *EditorUI) setupDoodadFrame(e render.Engine, window *ui.Window) (*ui.Frame, error) {
var (
frame = ui.NewFrame("Doodad Tab")
perRow = balance.UIDoodadsPerRow
)
doodadsAvailable, err := userdir.ListDoodads()
if err != nil {
return frame, fmt.Errorf(
"setupDoodadFrame: userdir.ListDoodads: %s",
err,
)
}
var buttonSize = (paletteWidth - window.BoxThickness(2)) / int32(perRow)
// Draw the doodad buttons in a grid `perRow` buttons wide.
var (
row *ui.Frame
rowCount int // for labeling the ui.Frame for each row
)
for i, filename := range doodadsAvailable {
if row == nil || i%perRow == 0 {
rowCount++
row = ui.NewFrame(fmt.Sprintf("Doodad Row %d", rowCount))
row.SetBackground(balance.WindowBackground)
frame.Pack(row, ui.Pack{
Anchor: ui.N,
Fill: true,
})
}
func(filename string) {
doodad, err := doodads.LoadJSON(userdir.DoodadPath(filename))
if err != nil {
log.Error(err.Error())
doodad = doodads.New(balance.DoodadSize)
}
can := uix.NewCanvas(int(buttonSize), true)
can.Name = filename
can.SetBackground(render.White)
can.LoadDoodad(doodad)
btn := ui.NewButton(filename, can)
btn.Resize(render.NewRect(
buttonSize-2, // TODO: without the -2 the button border
buttonSize-2, // rests on top of the window border.
))
row.Pack(btn, ui.Pack{
Anchor: ui.W,
})
// Begin the drag event to grab this Doodad.
// NOTE: The drag target is the EditorUI.Canvas in
// editor_ui.go#SetupCanvas()
btn.Handle(ui.MouseDown, func(e render.Point) {
u.Supervisor.DragStart()
// Create the canvas to render on the mouse cursor.
drawing := uix.NewCanvas(doodad.Layers[0].Chunker.Size, false)
drawing.LoadDoodad(doodad)
drawing.Resize(doodad.Rect())
drawing.SetBackground(render.RGBA(0, 0, 1, 0)) // TODO: invisible becomes white
drawing.MaskColor = balance.DragColor // blueprint effect
u.DraggableActor = &DraggableActor{
canvas: drawing,
doodad: doodad,
}
})
u.Supervisor.Add(btn)
// Resize the canvas to fill the button interior.
btnSize := btn.Size()
can.Resize(render.NewRect(
btnSize.W-btn.BoxThickness(2),
btnSize.H-btn.BoxThickness(2),
),
)
btn.Compute(e)
}(filename)
}
return frame, nil
}

49
editor_ui_palette.go Normal file
View File

@ -0,0 +1,49 @@
package doodle
import (
"git.kirsle.net/apps/doodle/balance"
"git.kirsle.net/apps/doodle/render"
"git.kirsle.net/apps/doodle/ui"
)
// setupPaletteFrame configures the Color Palette tab for Edit Mode.
// This is a subroutine of editor_ui.go#SetupPalette()
func (u *EditorUI) setupPaletteFrame(window *ui.Window) *ui.Frame {
frame := ui.NewFrame("Palette Tab")
frame.SetBackground(balance.WindowBackground)
// Handler function for the radio buttons being clicked.
onClick := func(p render.Point) {
name := u.selectedSwatch
swatch, ok := u.Canvas.Palette.Get(name)
if !ok {
log.Error("Palette onClick: couldn't get swatch named '%s' from palette", name)
return
}
log.Info("Set swatch: %s", swatch)
u.Canvas.SetSwatch(swatch)
}
// Draw the radio buttons for the palette.
if u.Canvas != nil && u.Canvas.Palette != nil {
for _, swatch := range u.Canvas.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.selectedSwatch, swatch.Name, label)
btn.Handle(ui.Click, onClick)
u.Supervisor.Add(btn)
frame.Pack(btn, ui.Pack{
Anchor: ui.N,
Fill: true,
PadY: 4,
})
}
}
return frame
}

View File

@ -1,6 +1,9 @@
package level
import "git.kirsle.net/apps/doodle/render"
import (
"git.kirsle.net/apps/doodle/render"
uuid "github.com/satori/go.uuid"
)
// ActorMap holds the doodad information by their ID in the level data.
type ActorMap map[string]*Actor
@ -12,6 +15,15 @@ func (m ActorMap) Inflate() {
}
}
// Add a new Actor to the map. If it doesn't already have an ID it will be
// given a random UUIDv4 ID.
func (m ActorMap) Add(a *Actor) {
if a.id == "" {
a.id = uuid.Must(uuid.NewV4()).String()
}
m[a.id] = a
}
// Actor is an instance of a Doodad in the level.
type Actor struct {
id string // NOTE: read only, use ID() to access.

View File

@ -8,7 +8,9 @@ import (
"os"
"git.kirsle.net/apps/doodle/balance"
"git.kirsle.net/apps/doodle/pkg/userdir"
"git.kirsle.net/apps/doodle/render"
"github.com/satori/go.uuid"
"golang.org/x/image/bmp"
)
@ -28,8 +30,10 @@ type Chunk struct {
Size int
// Texture cache properties so we don't redraw pixel-by-pixel every frame.
texture render.Texturer
dirty bool
uuid uuid.UUID
texture render.Texturer
textureMasked render.Texturer
dirty bool
}
// JSONChunk holds a lightweight (interface-free) copy of the Chunk for
@ -63,26 +67,66 @@ func NewChunk() *Chunk {
// Texture will return a cached texture for the rendering engine for this
// chunk's pixel data. If the cache is dirty it will be rebuilt in this func.
func (c *Chunk) Texture(e render.Engine, name string) render.Texturer {
func (c *Chunk) Texture(e render.Engine) render.Texturer {
if c.texture == nil || c.dirty {
err := c.ToBitmap("/tmp/" + name + ".bmp")
if err != nil {
log.Error("Texture: %s", err)
}
tex, err := e.NewBitmap("/tmp/" + name + ".bmp")
// Generate the normal bitmap and one with a color mask if applicable.
bitmap := c.toBitmap(render.Invisible)
defer os.Remove(bitmap)
tex, err := e.NewBitmap(bitmap)
if err != nil {
log.Error("Texture: %s", err)
}
c.texture = tex
c.textureMasked = nil // invalidate until next call
c.dirty = false
}
return c.texture
}
// TextureMasked returns a cached texture with the ColorMask applied.
func (c *Chunk) TextureMasked(e render.Engine, mask render.Color) render.Texturer {
if c.textureMasked == nil {
// Generate the normal bitmap and one with a color mask if applicable.
bitmap := c.toBitmap(mask)
defer os.Remove(bitmap)
tex, err := e.NewBitmap(bitmap)
if err != nil {
log.Error("Texture: %s", err)
}
c.textureMasked = tex
}
return c.textureMasked
}
// toBitmap puts the texture in a well named bitmap path in the cache folder.
func (c *Chunk) toBitmap(mask render.Color) string {
// Generate a unique filename for this chunk cache.
var filename string
if c.uuid == uuid.Nil {
c.uuid = uuid.Must(uuid.NewV4())
}
filename = c.uuid.String()
if mask != render.Invisible {
filename += fmt.Sprintf("-%02x%02x%02x%02x",
mask.Red, mask.Green, mask.Blue, mask.Alpha,
)
}
// Get the temp bitmap image.
bitmap := userdir.CacheFilename("chunk", filename+".bmp")
err := c.ToBitmap(bitmap, mask)
if err != nil {
log.Error("Texture: %s", err)
}
return bitmap
}
// ToBitmap exports the chunk's pixels as a bitmap image.
func (c *Chunk) ToBitmap(filename string) error {
func (c *Chunk) ToBitmap(filename string, mask render.Color) error {
canvas := c.SizePositive()
imgSize := image.Rectangle{
Min: image.Point{},
@ -117,10 +161,14 @@ func (c *Chunk) ToBitmap(filename string) error {
// Blot all the pixels onto it.
for px := range c.Iter() {
var color = px.Swatch.Color
if mask != render.Invisible {
color = mask
}
img.Set(
int(px.X-pointOffset.X),
int(px.Y-pointOffset.Y),
px.Swatch.Color.ToColor(),
color.ToColor(),
)
}

View File

@ -45,6 +45,7 @@ func New() *Level {
},
Chunker: NewChunker(balance.ChunkSize),
Palette: &Palette{},
Actors: ActorMap{},
}
}

View File

@ -55,6 +55,16 @@ func DoodadPath(filename string) string {
return resolvePath(DoodadDirectory, filename, extDoodad)
}
// CacheFilename returns a path to a file in the cache folder. Send in path
// components and not literal slashes, like
// CacheFilename("images", "chunks", "id.bmp")
func CacheFilename(filename ...string) string {
paths := append([]string{CacheDirectory}, filename...)
dir := paths[:len(paths)-1]
configdir.MakePath(filepath.Join(dir...))
return filepath.Join(paths[0], filepath.Join(paths[1:]...))
}
// ListDoodads returns a listing of all available doodads.
func ListDoodads() ([]string, error) {
var names []string

View File

@ -38,6 +38,10 @@ func (r *Renderer) NewBitmap(filename string) (render.Texturer, error) {
}
defer surface.Free()
// TODO: chroma key color hardcoded to white here
key := sdl.MapRGB(surface.Format, 255, 255, 255)
surface.SetColorKey(true, key)
tex, err := r.renderer.CreateTextureFromSurface(surface)
if err != nil {
return nil, fmt.Errorf("NewBitmap: create texture: %s", err)

28
ui/dragdrop.go Normal file
View File

@ -0,0 +1,28 @@
package ui
// DragDrop is a state machine to manage draggable UI components.
type DragDrop struct {
isDragging bool
}
// NewDragDrop initializes the DragDrop struct. Normally your Supervisor
// will manage the drag/drop object, but you can use your own if you don't
// use a Supervisor.
func NewDragDrop() *DragDrop {
return &DragDrop{}
}
// IsDragging returns whether the drag state is active.
func (dd *DragDrop) IsDragging() bool {
return dd.isDragging
}
// Start the drag state.
func (dd *DragDrop) Start() {
dd.isDragging = true
}
// Stop dragging.
func (dd *DragDrop) Stop() {
dd.isDragging = false
}

View File

@ -1,6 +1,7 @@
package ui
import (
"errors"
"sync"
"git.kirsle.net/apps/doodle/events"
@ -21,6 +22,7 @@ const (
KeyDown
KeyUp
KeyPress
Drop
)
// Supervisor keeps track of widgets of interest to notify them about
@ -28,22 +30,56 @@ const (
// vicinity.
type Supervisor struct {
lock sync.RWMutex
widgets []Widget
hovering map[int]interface{}
clicked map[int]interface{}
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: []Widget{},
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.
func (s *Supervisor) Loop(ev *events.State) {
//
// Useful errors returned by this may be:
// - ErrStopPropagation
func (s *Supervisor) Loop(ev *events.State) error {
var (
XY = render.Point{
X: ev.CursorX.Now,
@ -52,14 +88,84 @@ func (s *Supervisor) Loop(ev *events.State) {
)
// See if we are hovering over any widgets.
for id, w := range s.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?
log.Info("Supervisor: STOP DRAGGING")
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{
@ -69,36 +175,29 @@ func (s *Supervisor) Loop(ev *events.State) {
)
if XY.X >= P.X && XY.X <= P2.X && XY.Y >= P.Y && XY.Y <= P2.Y {
// 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)
}
// Cursor intersects the widget.
hovering = append(hovering, child)
} else {
// 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)
}
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.
@ -106,7 +205,8 @@ func (s *Supervisor) Present(e render.Engine) {
s.lock.RLock()
defer s.lock.RUnlock()
for _, w := range s.widgets {
for child := range s.Widgets() {
var w = child.widget
w.Present(e, w.Point())
}
}
@ -114,6 +214,10 @@ func (s *Supervisor) Present(e render.Engine) {
// Add a widget to be supervised.
func (s *Supervisor) Add(w Widget) {
s.lock.Lock()
s.widgets = append(s.widgets, w)
s.widgets[s.serial] = WidgetSlot{
id: s.serial,
widget: w,
}
s.serial++
s.lock.Unlock()
}

View File

@ -18,9 +18,16 @@ type Canvas struct {
ui.Frame
Palette *level.Palette
// Set to true to allow clicking to edit this canvas.
Editable bool
Scrollable bool
// Editable and Scrollable go hand in hand and, if you initialize a
// NewCanvas() with editable=true, they are both enabled.
Editable bool // Clicking will edit pixels of this canvas.
Scrollable bool // Cursor keys will scroll the viewport of this canvas.
// MaskColor will force every pixel to render as this color regardless of
// the palette index of that pixel. Otherwise pixels behave the same and
// the palette does work as normal. Set to render.Invisible (zero value)
// to remove the mask.
MaskColor render.Color
// Underlying chunk data for the drawing.
chunks *level.Chunker
@ -111,6 +118,9 @@ func (w *Canvas) InstallActors(actors level.ActorMap) error {
can := NewCanvas(int(size), false)
can.Name = id
can.actor = actor
// TODO: if the Background is render.Invisible it gets defaulted to
// White somewhere and the Doodad masks the level drawing behind it.
can.SetBackground(render.RGBA(0, 0, 1, 0))
can.LoadDoodad(doodad)
can.Resize(render.NewRect(size, size))
w.actors = append(w.actors, &Actor{
@ -128,8 +138,6 @@ func (w *Canvas) SetSwatch(s *level.Swatch) {
// setup common configs between both initializers of the canvas.
func (w *Canvas) setup() {
w.SetBackground(render.White)
// XXX: Debug code.
if balance.DebugCanvasBorder != render.Invisible {
w.Configure(ui.Config{
@ -138,13 +146,6 @@ func (w *Canvas) setup() {
BorderStyle: ui.BorderSolid,
})
}
w.Handle(ui.MouseOver, func(p render.Point) {
w.SetBackground(render.Yellow)
})
w.Handle(ui.MouseOut, func(p render.Point) {
w.SetBackground(render.SkyBlue)
})
}
// Loop is called on the scene's event loop to handle mouse interaction with
@ -258,6 +259,18 @@ func (w *Canvas) ViewportRelative() render.Rect {
}
}
// WorldIndexAt returns the World Index that corresponds to a Screen Pixel
// on the screen. If the screen pixel is the mouse coordinate (relative to
// the application window) this will return the World Index of the pixel below
// the mouse cursor.
func (w *Canvas) WorldIndexAt(screenPixel render.Point) render.Point {
var P = ui.AbsolutePosition(w)
return render.Point{
X: screenPixel.X - P.X - w.Scroll.X,
Y: screenPixel.Y - P.Y - w.Scroll.Y,
}
}
// Chunker returns the underlying Chunker object.
func (w *Canvas) Chunker() *level.Chunker {
return w.chunks
@ -297,7 +310,12 @@ func (w *Canvas) Present(e render.Engine, p render.Point) {
// Get the chunks in the viewport and cache their textures.
for coord := range w.chunks.IterViewportChunks(Viewport) {
if chunk, ok := w.chunks.GetChunk(coord); ok {
tex := chunk.Texture(e, w.Name+coord.String())
var tex render.Texturer
if w.MaskColor != render.Invisible {
tex = chunk.TextureMasked(e, w.MaskColor)
} else {
tex = chunk.Texture(e)
}
src := render.Rect{
W: tex.Size().W,
H: tex.Size().H,