doodle/pkg/shell.go
Noah Petherbridge 26b1ac88dd Shift to scroll slowly + Doodads on Q
* Holding Shift while pressing arrow keys in the editor will scroll by
  just 1 pixel per tick to aid in precise debugging with the Zoom In/Out
  feature.
* The keybinds used in canvas_editable.go to catch the arrow keys are
  updated to use our nice keybind package. As a consequence, the WASD
  keys will also scroll the level.
* The "d for Doodads" keybind is renamed "q" so as not to open the
  Doodads window whenever scrolling right using the WASD keys.
2021-07-13 18:04:25 -07:00

371 lines
7.8 KiB
Go

package doodle
import (
"bytes"
"fmt"
"strings"
"git.kirsle.net/apps/doodle/pkg/balance"
"git.kirsle.net/apps/doodle/pkg/keybind"
"git.kirsle.net/apps/doodle/pkg/log"
"git.kirsle.net/apps/doodle/pkg/shmem"
"git.kirsle.net/go/render"
"git.kirsle.net/go/render/event"
"git.kirsle.net/go/ui"
"github.com/robertkrimen/otto"
)
// Flash a message to the user.
func (d *Doodle) Flash(template string, v ...interface{}) {
log.Warn(template, v...)
d.shell.Write(fmt.Sprintf(template, v...))
}
// Prompt the user for a question in the dev console.
func (d *Doodle) Prompt(question string, callback func(string)) {
d.shell.Prompt = question
d.shell.callback = callback
d.shell.Open = true
}
// Shell implements the developer console in-game.
type Shell struct {
parent *Doodle
Open bool
Prompt string
Repl bool
callback func(string) // for prompt answers only
Text string
History []string
Output []string
Flashes []Flash
// Blinky cursor variables.
cursor byte // cursor symbol
cursorFlip uint64 // ticks until cursor flip
cursorRate uint64
// Paging through history variables.
historyPaging bool
historyIndex int
// JavaScript shell interpreter.
js *otto.Otto
}
// Flash holds a message to flash on screen.
type Flash struct {
Text string
Expires uint64 // tick that it expires
}
// NewShell initializes the shell helper (the "Shellper").
func NewShell(d *Doodle) Shell {
s := Shell{
parent: d,
History: []string{},
Output: []string{},
Flashes: []Flash{},
Prompt: ">",
cursor: '_',
cursorRate: balance.ShellCursorBlinkRate,
js: otto.New(),
}
// Make the Doodle instance available to the shell.
bindings := map[string]interface{}{
"d": d,
"RGBA": render.RGBA,
"Point": render.NewPoint,
"Rect": render.NewRect,
"Tree": func(w ui.Widget) string {
for _, row := range ui.WidgetTree(w) {
d.Flash(row)
}
return ""
},
}
for name, v := range bindings {
err := s.js.Set(name, v)
if err != nil {
log.Error("Failed to make `%s` available to JS shell: %s", name, err)
}
}
return s
}
// Close the shell, resetting its internal state.
func (s *Shell) Close() {
log.Debug("Shell: closing shell")
s.Open = false
s.Repl = false
s.Prompt = ">"
s.callback = nil
s.Text = ""
s.historyPaging = false
s.historyIndex = 0
}
// Execute a command in the shell.
func (s *Shell) Execute(input string) {
command := s.Parse(input)
if command.Raw != "" {
s.Output = append(s.Output, s.Prompt+command.Raw)
s.History = append(s.History, command.Raw)
}
// Are we answering a Prompt?
if s.callback != nil {
log.Info("Invoking prompt callback:")
s.callback(command.Raw)
s.Close()
return
}
if command.Command == "clear" {
s.Output = []string{}
} else {
err := command.Run(s.parent)
if err != nil {
s.Write(err.Error())
}
}
// Reset the text buffer in the shell.
if s.Repl {
s.Text = "$ "
} else {
s.Text = ""
}
}
// Write a line of output text to the console.
func (s *Shell) Write(line string) {
s.Output = append(s.Output, line)
s.Flashes = append(s.Flashes, Flash{
Text: line,
Expires: shmem.Tick + balance.FlashTTL,
})
}
// Parse the command line.
func (s *Shell) Parse(input string) Command {
input = strings.TrimSpace(input)
if len(input) == 0 {
return Command{}
}
var (
inQuote bool
buffer = bytes.NewBuffer([]byte{})
words = []string{}
)
for i := 0; i < len(input); i++ {
char := input[i]
switch char {
case ' ':
if inQuote {
buffer.WriteByte(char)
continue
}
if word := buffer.String(); word != "" {
words = append(words, word)
buffer.Reset()
}
case '"':
if !inQuote {
// An opening quote character.
inQuote = true
} else {
// The closing quote.
inQuote = false
if word := buffer.String(); word != "" {
words = append(words, word)
buffer.Reset()
}
}
default:
buffer.WriteByte(char)
}
}
if remainder := buffer.String(); remainder != "" {
words = append(words, remainder)
}
return Command{
Raw: input,
Command: words[0],
Args: words[1:],
ArgsLiteral: strings.TrimSpace(input[len(words[0]):]),
}
}
// Draw the shell.
func (s *Shell) Draw(d *Doodle, ev *event.State) error {
// Compute the line height we can draw.
lineHeight := balance.ShellFontSize + int(balance.ShellPadding)
// If the console is open, draw the console.
if s.Open {
if ev.Escape {
s.Close()
return nil
} else if keybind.Enter(ev) {
s.Execute(s.Text)
// Auto-close the console unless in REPL mode.
if !s.Repl {
s.Close()
}
return nil
} else if (ev.Up || ev.Down) && len(s.History) > 0 {
// Paging through history.
if !s.historyPaging {
s.historyPaging = true
s.historyIndex = len(s.History)
}
// Consume the inputs and make convenient variables.
isUp := ev.Up
ev.Down = false
ev.Up = false
// Scroll through the input history.
if isUp {
s.historyIndex--
if s.historyIndex < 0 {
s.historyIndex = 0
}
} else {
s.historyIndex++
if s.historyIndex >= len(s.History) {
s.historyIndex = len(s.History) - 1
}
}
s.Text = s.History[s.historyIndex]
}
// Cursor flip?
if shmem.Tick > s.cursorFlip {
s.cursorFlip = shmem.Tick + s.cursorRate
if s.cursor == ' ' {
s.cursor = '_'
} else {
s.cursor = ' '
}
}
// Read a character from the keyboard.
for _, key := range ev.KeysDown(true) {
// Backspace?
if key == `\b` {
if len(s.Text) > 0 {
s.Text = s.Text[:len(s.Text)-1]
}
} else {
s.Text += key
}
// HACK: I wanted to do:
// ev.SetKeyDown(key, false)
// But, ev.KeysDown(shifted=true) returns letter keys
// like 'M' when the key we wanted to unset was 'm',
// or we got '$' when we want to unset '5'... so all
// shifted chars got duplicated 3+ times on key press!
// So, just reset ALL key press states to work around it:
ev.ResetKeyDown()
}
// How tall is the box?
boxHeight := (lineHeight * (balance.ShellHistoryLineCount + 1)) + balance.ShellPadding
// Draw the background color.
d.Engine.DrawBox(
balance.ShellBackgroundColor,
render.Rect{
X: 0,
Y: d.height - boxHeight,
W: d.width,
H: boxHeight,
},
)
// Draw the recent commands.
outputY := d.height - (lineHeight * 2)
for i := 0; i < balance.ShellHistoryLineCount; i++ {
if len(s.Output) > i {
line := s.Output[len(s.Output)-1-i]
d.Engine.DrawText(
render.Text{
FontFilename: balance.ShellFontFilename,
Text: line,
Size: balance.ShellFontSize,
Color: balance.ShellForegroundColor,
},
render.Point{
X: balance.ShellPadding,
Y: outputY,
},
)
}
outputY -= lineHeight
}
// Draw the command prompt.
d.Engine.DrawText(
render.Text{
FontFilename: balance.ShellFontFilename,
Text: s.Prompt + s.Text + string(s.cursor),
Size: balance.ShellFontSize,
Color: balance.ShellPromptColor,
},
render.Point{
X: balance.ShellPadding,
Y: d.height - balance.ShellFontSize - balance.ShellPadding,
},
)
} else if len(s.Flashes) > 0 {
// Otherwise, just draw flashed messages.
valid := false // Did we actually draw any?
outputY := d.height - (lineHeight * 2) - 16
for i := len(s.Flashes); i > 0; i-- {
flash := s.Flashes[i-1]
if shmem.Tick >= flash.Expires {
continue
}
d.Engine.DrawText(
render.Text{
Text: flash.Text,
Size: balance.ShellFontSize,
Color: render.SkyBlue,
Stroke: render.Grey,
Shadow: render.Black,
},
render.Point{
X: balance.ShellPadding + toolbarWidth,
Y: outputY,
},
)
outputY -= lineHeight
valid = true
}
// If we've exhausted all flashes, free up the memory.
if !valid {
s.Flashes = []Flash{}
}
}
return nil
}