diff --git a/balance/numbers.go b/balance/numbers.go index a491619..2149fca 100644 --- a/balance/numbers.go +++ b/balance/numbers.go @@ -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 +) diff --git a/balance/theme.go b/balance/theme.go index a2c4aee..192a2ad 100644 --- a/balance/theme.go +++ b/balance/theme.go @@ -36,4 +36,7 @@ var ( Padding: 4, Color: render.Black, } + + // Color for draggable doodad. + DragColor = render.MustHexColor("#0099FF") ) diff --git a/docs/UI Ideas.md b/docs/UI Ideas.md new file mode 100644 index 0000000..31122f9 --- /dev/null +++ b/docs/UI Ideas.md @@ -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. diff --git a/doodads/doodad.go b/doodads/doodad.go index ca5fc7d..ea2fa33 100644 --- a/doodads/doodad.go +++ b/doodads/doodad.go @@ -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() diff --git a/doodads/json.go b/doodads/json.go index 9ce0547..30c406a 100644 --- a/doodads/json.go +++ b/doodads/json.go @@ -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 } diff --git a/editor_ui.go b/editor_ui.go index 8691410..6b5e962 100644 --- a/editor_ui.go +++ b/editor_ui.go @@ -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 } diff --git a/editor_ui_doodad.go b/editor_ui_doodad.go new file mode 100644 index 0000000..d06f7a6 --- /dev/null +++ b/editor_ui_doodad.go @@ -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 +} diff --git a/editor_ui_palette.go b/editor_ui_palette.go new file mode 100644 index 0000000..d48c6fc --- /dev/null +++ b/editor_ui_palette.go @@ -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 +} diff --git a/level/actors.go b/level/actors.go index c912e14..8256864 100644 --- a/level/actors.go +++ b/level/actors.go @@ -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. diff --git a/level/chunk.go b/level/chunk.go index 5349795..a0aa78d 100644 --- a/level/chunk.go +++ b/level/chunk.go @@ -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(), ) } diff --git a/level/types.go b/level/types.go index a396cbf..276b06f 100644 --- a/level/types.go +++ b/level/types.go @@ -45,6 +45,7 @@ func New() *Level { }, Chunker: NewChunker(balance.ChunkSize), Palette: &Palette{}, + Actors: ActorMap{}, } } diff --git a/pkg/userdir/userdir.go b/pkg/userdir/userdir.go index ac3b814..5675239 100644 --- a/pkg/userdir/userdir.go +++ b/pkg/userdir/userdir.go @@ -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 diff --git a/render/sdl/texture.go b/render/sdl/texture.go index 5d7ad39..192da48 100644 --- a/render/sdl/texture.go +++ b/render/sdl/texture.go @@ -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) diff --git a/ui/dragdrop.go b/ui/dragdrop.go new file mode 100644 index 0000000..ac1fea0 --- /dev/null +++ b/ui/dragdrop.go @@ -0,0 +1,28 @@ +package ui + +// DragDrop is a state machine to manage draggable UI components. +type DragDrop struct { + isDragging bool +} + +// NewDragDrop initializes the DragDrop struct. Normally your Supervisor +// will manage the drag/drop object, but you can use your own if you don't +// use a Supervisor. +func NewDragDrop() *DragDrop { + return &DragDrop{} +} + +// IsDragging returns whether the drag state is active. +func (dd *DragDrop) IsDragging() bool { + return dd.isDragging +} + +// Start the drag state. +func (dd *DragDrop) Start() { + dd.isDragging = true +} + +// Stop dragging. +func (dd *DragDrop) Stop() { + dd.isDragging = false +} diff --git a/ui/supervisor.go b/ui/supervisor.go index cb9914b..43ae0bd 100644 --- a/ui/supervisor.go +++ b/ui/supervisor.go @@ -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() } diff --git a/uix/canvas.go b/uix/canvas.go index 8b9ef78..476283c 100644 --- a/uix/canvas.go +++ b/uix/canvas.go @@ -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,