431 lines
11 KiB
Go
431 lines
11 KiB
Go
package launcher
|
|
|
|
import (
|
|
"errors"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"aego/core/mathx"
|
|
"aego/editor/core/preview"
|
|
"aego/editor/core/state"
|
|
"aego/project"
|
|
"aego/render/texture"
|
|
"aego/ui/core"
|
|
"aego/ui/widgets"
|
|
)
|
|
|
|
type Tab uint8
|
|
|
|
const (
|
|
TabRecent Tab = iota
|
|
TabNew
|
|
TabExamples
|
|
)
|
|
|
|
var tabNames = [...]string{"Recent", "New Project", "Examples"}
|
|
|
|
const (
|
|
cardW float32 = 200
|
|
cardH float32 = 176
|
|
gap float32 = 10
|
|
)
|
|
|
|
type Template struct {
|
|
ID string
|
|
Name string
|
|
Description string
|
|
Icon string
|
|
Files []string
|
|
Preview func() ([]byte, error)
|
|
}
|
|
|
|
type Choice struct {
|
|
ID string
|
|
Title string
|
|
}
|
|
|
|
type Options struct {
|
|
Templates []Template
|
|
Examples []string
|
|
ExamplePreview func(name string) ([]byte, error)
|
|
Workspaces []Choice
|
|
Create func(dir, template, module, example string) (string, error)
|
|
PickFolder func(start string, done func(string))
|
|
Docs func()
|
|
RecentPath string
|
|
Home string
|
|
Image func(key string, load func() ([]byte, error)) (texture.Region, bool)
|
|
}
|
|
|
|
type Launcher struct {
|
|
opt Options
|
|
recent []Entry
|
|
tab Tab
|
|
name string
|
|
dir string
|
|
module string
|
|
open string
|
|
tmpl int
|
|
example int
|
|
ws int
|
|
err string
|
|
result string
|
|
scroll float32
|
|
content float32
|
|
}
|
|
|
|
func New(opt Options) *Launcher {
|
|
l := &Launcher{opt: opt, name: "new-game", dir: opt.Home, tmpl: 1}
|
|
if l.tmpl >= len(opt.Templates) {
|
|
l.tmpl = 0
|
|
}
|
|
l.reload()
|
|
if len(l.recent) == 0 {
|
|
l.tab = TabNew
|
|
}
|
|
return l
|
|
}
|
|
|
|
func PreviewPath(root string) string {
|
|
return filepath.Join((&project.Project{Root: root}).CacheDir(), preview.CoverFile)
|
|
}
|
|
|
|
func (l *Launcher) reload() {
|
|
if l.opt.RecentPath == "" {
|
|
return
|
|
}
|
|
list, err := LoadRecent(l.opt.RecentPath)
|
|
if err != nil {
|
|
l.err = "Recent projects are unreadable: " + err.Error()
|
|
}
|
|
l.recent = list
|
|
}
|
|
|
|
func (l *Launcher) Result() (string, bool) {
|
|
return l.result, l.result != ""
|
|
}
|
|
|
|
func (l *Launcher) Recent() []Entry {
|
|
return l.recent
|
|
}
|
|
|
|
func (l *Launcher) Tab() Tab {
|
|
return l.tab
|
|
}
|
|
|
|
func (l *Launcher) SetTab(t Tab) {
|
|
l.tab, l.scroll = t, 0
|
|
}
|
|
|
|
func (l *Launcher) SetWorkspace(id string) {
|
|
for i, c := range l.opt.Workspaces {
|
|
if c.ID == id {
|
|
l.ws = i
|
|
}
|
|
}
|
|
}
|
|
|
|
func (l *Launcher) templateID() string {
|
|
if l.tmpl < 0 || l.tmpl >= len(l.opt.Templates) {
|
|
return ""
|
|
}
|
|
return l.opt.Templates[l.tmpl].ID
|
|
}
|
|
|
|
func (l *Launcher) workspace() string {
|
|
if l.ws <= 0 || l.ws >= len(l.opt.Workspaces) {
|
|
return ""
|
|
}
|
|
return l.opt.Workspaces[l.ws].ID
|
|
}
|
|
|
|
func (l *Launcher) Create(dir, template, module, example string) (string, error) {
|
|
if l.opt.Create == nil {
|
|
return "", errors.New("project creation is not available")
|
|
}
|
|
root, err := l.opt.Create(dir, template, module, example)
|
|
if err != nil {
|
|
l.err = err.Error()
|
|
return "", err
|
|
}
|
|
if id := l.workspace(); id != "" {
|
|
st := state.Default()
|
|
st.Workspace = id
|
|
if err := state.Save(root, st); err != nil {
|
|
l.err = "The project was created, but its workspace was not saved: " + err.Error()
|
|
}
|
|
}
|
|
l.result = root
|
|
return root, nil
|
|
}
|
|
|
|
func (l *Launcher) Open(dir string) error {
|
|
abs, err := filepath.Abs(strings.TrimSpace(dir))
|
|
if err == nil {
|
|
_, err = os.Stat(filepath.Join(abs, project.FileName))
|
|
}
|
|
if err != nil {
|
|
l.err = "No " + project.FileName + " in " + dir
|
|
return errors.New(l.err)
|
|
}
|
|
l.err, l.result = "", abs
|
|
return nil
|
|
}
|
|
|
|
func (l *Launcher) image(key string, load func() ([]byte, error)) (texture.Region, bool) {
|
|
if l.opt.Image == nil || load == nil {
|
|
return texture.Region{}, false
|
|
}
|
|
return l.opt.Image(key, load)
|
|
}
|
|
|
|
func (l *Launcher) Draw(ui *core.Context, r mathx.Rect) {
|
|
th := &ui.Theme
|
|
ui.Rect(r, th.Background)
|
|
if ui.MouseOver(ui.ID("launcher.scroll"), r) {
|
|
if w := ui.ScrollY(th.RowHeight * 3); w != 0 {
|
|
l.scroll -= w
|
|
}
|
|
}
|
|
l.scroll = mathx.Clamp(l.scroll, 0, max(0, l.content-r.H+48))
|
|
w := min(r.W-48, 960)
|
|
cur := ui.Cursor()
|
|
cur.X, cur.W = r.X+(r.W-w)/2, w
|
|
cur.Y = r.Y + 24 - l.scroll
|
|
top := cur.Y
|
|
widgets.Label(ui, "Aego")
|
|
widgets.LabelDim(ui, "Open a recent project, start a new one or learn from an example.")
|
|
l.tabs(ui)
|
|
if l.err != "" {
|
|
row := cur.Next(0)
|
|
ui.PushClip(row)
|
|
ui.Text(l.err, mathx.Vec2{X: row.X, Y: ui.TextY(row)}, th.Error)
|
|
ui.PopClip()
|
|
}
|
|
switch l.tab {
|
|
case TabRecent:
|
|
l.recentTab(ui)
|
|
case TabNew:
|
|
l.newTab(ui)
|
|
case TabExamples:
|
|
l.examplesTab(ui)
|
|
}
|
|
if l.opt.Docs != nil {
|
|
cur.Y += 10
|
|
if widgets.Button(ui, "Documentation") {
|
|
l.opt.Docs()
|
|
}
|
|
}
|
|
l.content = cur.Y - top
|
|
}
|
|
|
|
func (l *Launcher) tabs(ui *core.Context) {
|
|
row := ui.Cursor().Next(0)
|
|
w := min(float32(150), row.W/float32(len(tabNames)))
|
|
for i, name := range tabNames {
|
|
on := l.tab == Tab(i)
|
|
if widgets.ToggleButton(ui, core.IDSeq(ui.ID("tab"), i), mathx.Rect{X: row.X + float32(i)*(w+4), Y: row.Y, W: w, H: row.H}, name, &on) {
|
|
l.SetTab(Tab(i))
|
|
}
|
|
}
|
|
ui.Cursor().Y += 6
|
|
}
|
|
|
|
func (l *Launcher) heading(ui *core.Context, s string) {
|
|
ui.Cursor().Y += 10
|
|
widgets.Label(ui, s)
|
|
widgets.Separator(ui)
|
|
}
|
|
|
|
func (l *Launcher) grid(ui *core.Context, n int, fn func(i int, r mathx.Rect)) {
|
|
cur := ui.Cursor()
|
|
cols := max(1, int((cur.W+gap)/(cardW+gap)))
|
|
cw := (cur.W - gap*float32(cols-1)) / float32(cols)
|
|
for i := 0; i < n; i += cols {
|
|
row := cur.Next(cardH)
|
|
for c := 0; c < cols && i+c < n; c++ {
|
|
fn(i+c, mathx.Rect{X: row.X + float32(c)*(cw+gap), Y: row.Y, W: cw, H: row.H})
|
|
}
|
|
cur.Y += gap - cur.Space
|
|
}
|
|
}
|
|
|
|
func fit(r mathx.Rect, w, h int) mathx.Rect {
|
|
if w <= 0 || h <= 0 {
|
|
return r
|
|
}
|
|
s := min(r.W/float32(w), r.H/float32(h))
|
|
fw, fh := float32(w)*s, float32(h)*s
|
|
return mathx.Rect{X: r.X + (r.W-fw)/2, Y: r.Y + (r.H-fh)/2, W: fw, H: fh}
|
|
}
|
|
|
|
type cardSpec struct {
|
|
title string
|
|
sub string
|
|
icon string
|
|
key string
|
|
load func() ([]byte, error)
|
|
selected bool
|
|
dim bool
|
|
}
|
|
|
|
func (l *Launcher) card(ui *core.Context, id core.ID, r mathx.Rect, c cardSpec) bool {
|
|
th := &ui.Theme
|
|
clicked := ui.Clicked(id, r)
|
|
bg, border := th.CardBg, th.Border
|
|
switch {
|
|
case c.selected:
|
|
bg, border = th.SelectionDim, th.Accent
|
|
case ui.Hot() == id:
|
|
bg = th.WidgetHot
|
|
}
|
|
ui.Box(r, th.Radius.Card, bg, border)
|
|
lh := ui.LineHeight()
|
|
pr := mathx.Rect{X: r.X + 6, Y: r.Y + 6, W: r.W - 12, H: r.H - 16 - 2*lh}
|
|
ui.Rect(pr, th.PanelAlt)
|
|
tint := mathx.Color{R: 1, G: 1, B: 1, A: 1}
|
|
if c.dim {
|
|
tint.A = 0.4
|
|
}
|
|
if reg, ok := l.image(c.key, c.load); ok {
|
|
ui.List().Texture(reg, fit(pr, int(reg.W), int(reg.H)), tint)
|
|
} else if c.icon != "" {
|
|
s := min(pr.H*0.5, 40)
|
|
widgets.IconAt(ui, mathx.Rect{X: pr.X + (pr.W-s)/2, Y: pr.Y + (pr.H-s)/2, W: s, H: s}, c.icon, th.TextDim)
|
|
}
|
|
text, dim := th.Text, th.TextDim
|
|
if c.dim {
|
|
text, dim = th.TextDisabled, th.TextDisabled
|
|
}
|
|
ui.PushClip(r)
|
|
ui.Text(c.title, mathx.Vec2{X: r.X + 8, Y: pr.Y + pr.H + 4}, text)
|
|
ui.Text(widgets.Elide(ui, c.sub, r.W-16), mathx.Vec2{X: r.X + 8, Y: pr.Y + pr.H + 4 + lh}, dim)
|
|
ui.PopClip()
|
|
return clicked
|
|
}
|
|
|
|
func (l *Launcher) recentTab(ui *core.Context) {
|
|
if len(l.recent) == 0 {
|
|
widgets.LabelDim(ui, "No recent projects yet")
|
|
}
|
|
forget := -1
|
|
l.grid(ui, len(l.recent), func(i int, r mathx.Rect) {
|
|
e := l.recent[i]
|
|
name := e.Name
|
|
if name == "" {
|
|
name = filepath.Base(e.Path)
|
|
}
|
|
sub := e.Path
|
|
if !e.Time.IsZero() {
|
|
sub = e.Time.Format("2006-01-02 15:04") + " · " + e.Path
|
|
}
|
|
if e.Missing {
|
|
sub = "missing, click to remove"
|
|
}
|
|
root := e.Path
|
|
spec := cardSpec{title: name, sub: sub, icon: "file.folder", key: "recent:" + root, dim: e.Missing,
|
|
load: func() ([]byte, error) { return os.ReadFile(PreviewPath(root)) }}
|
|
if !l.card(ui, ui.ID("recent/"+e.Path), r, spec) {
|
|
return
|
|
}
|
|
if e.Missing {
|
|
forget = i
|
|
return
|
|
}
|
|
l.Open(e.Path)
|
|
})
|
|
if forget >= 0 && l.opt.RecentPath != "" {
|
|
if err := Forget(l.opt.RecentPath, l.recent[forget].Path); err != nil {
|
|
l.err = err.Error()
|
|
}
|
|
l.reload()
|
|
}
|
|
l.heading(ui, "Open a folder")
|
|
row := ui.Cursor().Next(0)
|
|
bw := float32(90)
|
|
widgets.TextFieldRect(ui, ui.ID("open.dir"), mathx.Rect{X: row.X, Y: row.Y, W: row.W - bw - 4, H: row.H}, &l.open, true)
|
|
if widgets.ButtonRect(ui, ui.ID("open.go"), mathx.Rect{X: row.X + row.W - bw, Y: row.Y, W: bw, H: row.H}, "Open") {
|
|
l.Open(l.open)
|
|
}
|
|
}
|
|
|
|
func (l *Launcher) newTab(ui *core.Context) {
|
|
th := &ui.Theme
|
|
l.grid(ui, len(l.opt.Templates), func(i int, r mathx.Rect) {
|
|
t := l.opt.Templates[i]
|
|
spec := cardSpec{title: t.Name, sub: t.Description, icon: t.Icon, key: "template:" + t.ID, load: t.Preview, selected: i == l.tmpl}
|
|
if l.card(ui, ui.ID("tmpl/"+t.ID), r, spec) {
|
|
l.tmpl = i
|
|
}
|
|
})
|
|
if l.tmpl >= 0 && l.tmpl < len(l.opt.Templates) {
|
|
t := l.opt.Templates[l.tmpl]
|
|
l.heading(ui, t.Name)
|
|
widgets.LabelWrapped(ui, t.Description, true)
|
|
if len(t.Files) > 0 {
|
|
widgets.LabelWrapped(ui, "Contains: "+strings.Join(t.Files, ", "), true)
|
|
}
|
|
}
|
|
l.heading(ui, "Project")
|
|
widgets.TextFieldRect(ui, ui.ID("new.name"), widgets.Field(ui, "Name"), &l.name, true)
|
|
l.location(ui)
|
|
mid := ui.ID("new.module")
|
|
mr := widgets.Field(ui, "Module")
|
|
widgets.TextFieldRect(ui, mid, mr, &l.module, true)
|
|
if l.module == "" && ui.Focus() != mid {
|
|
ui.Text(project.ModuleName(strings.TrimSpace(l.name)), mathx.Vec2{X: mr.X + 4, Y: ui.TextY(mr)}, th.TextDisabled)
|
|
}
|
|
if len(l.opt.Workspaces) > 0 {
|
|
names := make([]string, len(l.opt.Workspaces))
|
|
for i, c := range l.opt.Workspaces {
|
|
names[i] = c.Title
|
|
}
|
|
widgets.Combo(ui, ui.ID("new.workspace"), widgets.Field(ui, "Workspace"), &l.ws, names, false)
|
|
}
|
|
target := filepath.Join(l.dir, strings.TrimSpace(l.name))
|
|
widgets.LabelDim(ui, target)
|
|
if widgets.Button(ui, "Create") && strings.TrimSpace(l.name) != "" {
|
|
l.Create(target, l.templateID(), strings.TrimSpace(l.module), "")
|
|
}
|
|
}
|
|
|
|
func (l *Launcher) location(ui *core.Context) {
|
|
row := widgets.Field(ui, "Location")
|
|
if l.opt.PickFolder == nil {
|
|
widgets.TextFieldRect(ui, ui.ID("new.dir"), row, &l.dir, true)
|
|
return
|
|
}
|
|
bw := ui.TextSize("Browse…").X + 20
|
|
widgets.TextFieldRect(ui, ui.ID("new.dir"), mathx.Rect{X: row.X, Y: row.Y, W: row.W - bw - 4, H: row.H}, &l.dir, true)
|
|
if widgets.ButtonRect(ui, ui.ID("new.pick"), mathx.Rect{X: row.X + row.W - bw, Y: row.Y, W: bw, H: row.H}, "Browse…") {
|
|
l.opt.PickFolder(l.dir, func(p string) { l.dir = p })
|
|
}
|
|
}
|
|
|
|
func (l *Launcher) examplesTab(ui *core.Context) {
|
|
if len(l.opt.Examples) == 0 {
|
|
widgets.LabelWrapped(ui, "Examples come from the engine source tree; set AEGO_ROOT to its folder.", true)
|
|
return
|
|
}
|
|
l.grid(ui, len(l.opt.Examples), func(i int, r mathx.Rect) {
|
|
name := l.opt.Examples[i]
|
|
var load func() ([]byte, error)
|
|
if l.opt.ExamplePreview != nil {
|
|
load = func() ([]byte, error) { return l.opt.ExamplePreview(name) }
|
|
}
|
|
spec := cardSpec{title: name, sub: "examples/" + name, icon: "file.scene", key: "example:" + name, load: load, selected: i == l.example}
|
|
if l.card(ui, ui.ID("example/"+name), r, spec) {
|
|
l.example = i
|
|
}
|
|
})
|
|
l.heading(ui, "Copy to")
|
|
l.location(ui)
|
|
ex := l.opt.Examples[min(l.example, len(l.opt.Examples)-1)]
|
|
target := filepath.Join(l.dir, ex)
|
|
widgets.LabelDim(ui, target)
|
|
if widgets.Button(ui, "Create from "+ex) {
|
|
l.Create(target, "", strings.TrimSpace(l.module), ex)
|
|
}
|
|
}
|