Files

426 lines
10 KiB
Go

package search
import (
favs "aego/editor/core/favorites"
"path"
"sort"
"strconv"
"strings"
"aego/asset"
"aego/asset/pipeline"
"aego/core/mathx"
"aego/editor/core/command"
"aego/editor/core/context"
"aego/editor/core/fuzzy"
"aego/editor/core/services"
"aego/platform/input"
"aego/ui/core"
"aego/ui/widgets"
)
const (
CommandOpen = "search.everywhere"
maxHits = 200
maxEmpty = 60
detailCost = 30
favBonus = 40
)
type Tab uint8
const (
TabAll Tab = iota
TabFiles
TabSymbols
TabNodes
TabActions
TabSettings
tabCount
)
var tabNames = [tabCount]string{"All", "Files", "Symbols", "Nodes", "Actions", "Settings"}
type Item struct {
Label string
Detail string
Kind string
Tab Tab
Icon string
Run func()
Fav favs.Item
HasFav bool
score int
pos []int
}
type Overlay struct {
ShowHierarchy func()
id core.ID
open bool
focus bool
follow bool
query string
last string
lastTab Tab
tab Tab
sel int
scroll float32
items []Item
hits []int
dirty bool
d Deps
}
type Deps struct {
Pipeline *pipeline.Pipeline
Symbols func() []context.SearchItem
Docs services.Documents
Selection services.Selection
Commands *command.Registry
Notices services.Notices
Nav services.Navigation
Settings func() []Item
Favorites *favs.Store
}
func New(d Deps) *Overlay {
return &Overlay{id: core.IDOf("search.everywhere"), d: d}
}
func (o *Overlay) Active() bool {
return o.open
}
func (o *Overlay) Open(ui *core.Context, tab Tab) {
o.open, o.focus, o.dirty = true, true, true
o.tab, o.sel, o.scroll = tab, 0, 0
o.collect()
ui.OpenModal(o.id)
}
func (o *Overlay) Close(ui *core.Context) {
if !o.open {
return
}
o.open = false
ui.CloseModal()
ui.CancelFocus()
}
func fileIcon(k asset.ArtifactKind) string {
switch k {
case asset.ArtScene:
return "file.scene"
case asset.ArtScript:
return "file.script"
case asset.ArtPrefab:
return "file.prefab"
case asset.ArtMesh:
return "file.mesh"
}
return "file.texture"
}
func (o *Overlay) collect() {
o.items = o.items[:0]
o.favorites()
if o.d.Pipeline != nil {
for _, a := range o.d.Pipeline.Assets() {
rel := a.Path
o.items = append(o.items, Item{
Label: path.Base(rel), Detail: path.Dir(rel), Kind: "file", Tab: TabFiles, Icon: fileIcon(a.Kind),
Fav: favs.Item{Kind: favs.Asset, Asset: a.ID, Label: path.Base(rel)}, HasFav: true,
Run: func() { o.d.Nav.Open(context.Location{Path: rel, Offset: -1}) },
})
}
}
if o.d.Symbols != nil {
for _, s := range o.d.Symbols() {
loc := s.Loc
o.items = append(o.items, Item{
Label: s.Label, Detail: s.Detail, Kind: s.Kind, Tab: TabSymbols, Icon: "cat.scripts",
Run: func() { o.d.Nav.Open(loc) },
})
}
}
if d := o.d.Docs.Doc(); d != nil {
s := d.Scene()
root, doc, scenePath := s.Root(), d.ID, d.Path
for _, h := range s.PreOrder() {
if h == root || !s.IsAlive(h) {
continue
}
id := s.ID(h)
o.items = append(o.items, Item{
Label: s.Name(h), Detail: s.Path(h), Kind: "node", Tab: TabNodes, Icon: "cat.node",
Fav: favs.Item{Kind: favs.Node, Scene: scenePath, Node: id, Label: s.Name(h)}, HasFav: true,
Run: func() {
o.d.Docs.Documents().SetActive(doc)
o.d.Selection.Select(id)
if o.ShowHierarchy != nil {
o.ShowHierarchy()
}
},
})
}
}
if o.d.Settings != nil {
for _, it := range o.d.Settings() {
it.Tab, it.Kind = TabSettings, "setting"
if it.Icon == "" {
it.Icon = "ui.more"
}
o.items = append(o.items, it)
}
}
for _, d := range o.d.Commands.All() {
if d.ID == CommandOpen {
continue
}
id := d.ID
detail := d.Category
if sc := d.Shortcut.String(); sc != "" {
detail += " " + sc
}
o.items = append(o.items, Item{
Label: d.Name, Detail: detail, Kind: "action", Tab: TabActions, Icon: "tool.select",
Fav: favs.Item{Kind: favs.Command, Command: id, Label: d.Name}, HasFav: true,
Run: func() {
if o.d.Commands.Enabled(id) {
o.d.Commands.Run(id)
} else {
o.d.Notices.Notify(context.LevelInfo, "This action is not available right now")
}
},
})
}
}
func (o *Overlay) rank() {
o.hits = o.hits[:0]
q := strings.ReplaceAll(strings.TrimSpace(o.query), " ", "")
for i := range o.items {
it := &o.items[i]
if o.tab != TabAll && it.Tab != o.tab {
continue
}
if it.Kind == "favorite" && o.tab != TabAll {
continue
}
if q == "" {
it.score, it.pos = 0, it.pos[:0]
o.hits = append(o.hits, i)
continue
}
s, pos, ok := fuzzy.Match(q, it.Label, it.pos)
if !ok && it.Detail != "" {
if ds, _, dok := fuzzy.Match(q, it.Detail, nil); dok {
s, pos, ok = ds-detailCost, pos[:0], true
}
}
if !ok {
continue
}
if it.Kind == "favorite" {
s += favBonus
}
it.score, it.pos = s, pos
o.hits = append(o.hits, i)
}
if q != "" {
sort.SliceStable(o.hits, func(a, b int) bool {
x, y := &o.items[o.hits[a]], &o.items[o.hits[b]]
if x.score != y.score {
return x.score > y.score
}
return len(x.Label) < len(y.Label)
})
}
limit := maxHits
if q == "" {
limit = maxEmpty
}
if len(o.hits) > limit {
o.hits = o.hits[:limit]
}
o.sel, o.scroll = 0, 0
}
func (o *Overlay) Draw(ui *core.Context) {
if !o.open {
return
}
th := &ui.Theme
if o.dirty || o.query != o.last || o.tab != o.lastTab {
o.rank()
o.last, o.lastTab, o.dirty = o.query, o.tab, false
}
sz := ui.Size()
w := min(680, sz.X-40)
h := min(460, sz.Y-60)
r := mathx.Rect{X: (sz.X - w) / 2, Y: max(20, sz.Y*0.12), W: w, H: h}
if ui.MousePressed(input.MouseLeft) && !r.Contains(ui.Mouse()) {
o.Close(ui)
return
}
if !ui.BeginModal(o.id, r) {
o.open = false
return
}
qid := core.IDSeq(o.id, 1)
if o.focus || ui.Focus() == 0 {
ui.SetFocus(qid)
if o.focus {
ui.EditBuffer(qid, o.query).SelectAll()
}
o.focus = false
}
run := -1
if ui.Focus() == qid {
switch {
case ui.KeyPressed(input.KeyEscape):
ui.EndModal()
o.Close(ui)
return
case ui.KeyPressed(input.KeyEnter), ui.KeyPressed(input.KeyKPEnter):
run = o.sel
case ui.Primary() && ui.KeyPressed(input.KeyD):
o.star()
case ui.KeyPressed(input.KeyTab):
if ui.Mods().Has(input.ModShift) {
o.tab = (o.tab + tabCount - 1) % tabCount
} else {
o.tab = (o.tab + 1) % tabCount
}
case ui.KeyRepeated(input.KeyDown):
o.sel = min(o.sel+1, len(o.hits)-1)
o.follow = true
case ui.KeyRepeated(input.KeyUp):
o.sel = max(o.sel-1, 0)
o.follow = true
}
}
tabs := ui.Cursor().Next(0)
x := tabs.X
for t := Tab(0); t < tabCount; t++ {
bw := ui.TextSize(tabNames[t]).X + 18
on := o.tab == t
if widgets.ToggleButton(ui, core.IDSeq(o.id, 10+int(t)), mathx.Rect{X: x, Y: tabs.Y, W: bw, H: tabs.H}, tabNames[t], &on) {
o.tab = t
}
x += bw + 4
}
ui.TextIn(tabs, "Double Shift · Tab switches", th.TextDisabled, core.AlignRight)
widgets.TextFieldRect(ui, qid, ui.Cursor().Next(0), &o.query, true)
cur := ui.Cursor()
rowH := th.RowHeight + 2
list := mathx.Rect{X: cur.X, Y: cur.Y, W: cur.W, H: max(0, r.Y+r.H-cur.Y-th.RowHeight-th.Padding)}
if o.follow {
widgets.EnsureVisible(&o.scroll, o.sel, rowH, list.H)
o.follow = false
}
widgets.VirtualList(ui, core.IDSeq(o.id, 2), list, len(o.hits), rowH, &o.scroll, func(i int, rr mathx.Rect) {
if o.drawItem(ui, i, rr) {
run = i
}
})
if len(o.hits) == 0 {
ui.TextIn(list, "Nothing found", th.TextDisabled, core.AlignCenter)
}
foot := mathx.Rect{X: r.X + th.Padding, Y: r.Y + r.H - th.RowHeight - th.Padding/2, W: r.W - 2*th.Padding, H: th.RowHeight}
ui.TextIn(foot, strconv.Itoa(len(o.hits))+" result(s)", th.TextDim, core.AlignLeft)
ui.TextIn(foot, "Enter open · Ctrl+D favorite · Esc close", th.TextDim, core.AlignRight)
ui.EndModal()
if run >= 0 && run < len(o.hits) {
it := o.items[o.hits[run]]
o.Close(ui)
if it.Run != nil {
it.Run()
}
}
}
func (o *Overlay) drawItem(ui *core.Context, i int, rr mathx.Rect) bool {
th := &ui.Theme
it := &o.items[o.hits[i]]
id := core.IDSeq(o.id, 100+i)
pressed := ui.Pressed(id, rr)
switch {
case i == o.sel:
ui.Rect(rr, th.Selection)
case ui.Hot() == id:
ui.Rect(rr, th.WidgetHot)
}
widgets.IconAt(ui, mathx.Rect{X: rr.X + 4, Y: rr.Y + (rr.H-16)/2, W: 16, H: 16}, it.Icon, th.TextDim)
tx := rr.X + 26
ty := ui.TextY(rr)
kw := ui.TextSize(it.Kind).X + 12
starred := it.Kind != "favorite" && it.HasFav && o.d.Favorites != nil && o.d.Favorites.Has(it.Fav)
if starred {
kw += 20
}
ui.PushClip(mathx.Rect{X: rr.X, Y: rr.Y, W: max(0, rr.W-kw), H: rr.H})
ui.Text(it.Label, mathx.Vec2{X: tx, Y: ty}, th.Text)
for _, p := range it.pos {
if p >= len(it.Label) {
continue
}
px := tx + ui.TextSize(it.Label[:p]).X
ui.Rect(mathx.Rect{X: px, Y: ty + ui.LineHeight() - 1, W: ui.TextSize(it.Label[p : p+1]).X, H: 2}, th.Accent)
}
ui.Text(it.Detail, mathx.Vec2{X: tx + ui.TextSize(it.Label).X + 12, Y: ty}, th.TextDim)
ui.PopClip()
if starred {
widgets.IconAt(ui, mathx.Rect{X: rr.X + rr.W - kw + 2, Y: rr.Y + (rr.H-16)/2, W: 16, H: 16}, "ui.star", th.Accent)
}
ui.TextIn(rr, it.Kind, th.TextDisabled, core.AlignRight)
return pressed
}
func (o *Overlay) env() favs.Env {
return favs.Env{Pipeline: o.d.Pipeline, Docs: o.d.Docs, Selection: o.d.Selection, Commands: o.d.Commands, Nav: o.d.Nav, ShowHierarchy: o.ShowHierarchy}
}
func (o *Overlay) favorites() {
if o.d.Favorites == nil {
return
}
env := o.env()
for _, f := range o.d.Favorites.Items() {
it := f
label, detail, _, ok := favs.Describe(env, it)
if !ok {
continue
}
o.items = append(o.items, Item{
Label: label, Detail: detail, Kind: "favorite", Tab: TabAll, Icon: "ui.star", Fav: it, HasFav: true,
Run: func() {
if err := favs.Open(env, it); err != nil {
o.d.Notices.Notify(context.LevelInfo, err.Error())
}
},
})
}
}
func (o *Overlay) star() {
if o.d.Favorites == nil || o.sel < 0 || o.sel >= len(o.hits) {
return
}
it := &o.items[o.hits[o.sel]]
if !it.HasFav {
o.d.Notices.Notify(context.LevelInfo, "Only files, nodes and actions can be favorites")
return
}
added, err := o.d.Favorites.Toggle(it.Fav)
switch {
case err != nil:
o.d.Notices.Notify(context.LevelError, err.Error())
case added:
o.d.Notices.Notify(context.LevelInfo, "Added "+it.Fav.Label+" to Favorites")
default:
o.d.Notices.Notify(context.LevelInfo, "Removed "+it.Fav.Label+" from Favorites")
}
}