Files
aego/editor/shell/modals.go
sanspie 5dd9cc91bb editor: refine agent, scene controls and curves
Improve mentions and the default Design workspace, clarify scene lighting controls, and polish editor workflows for 0.12.5-dev.
2026-11-05 20:14:53 +03:00

313 lines
9.4 KiB
Go

package shell
import (
"fmt"
"runtime"
"strconv"
"strings"
"aego/core/mathx"
"aego/editor/core/context"
"aego/editor/core/document"
"aego/engine"
"aego/engine/i18n"
"aego/platform/input"
"aego/ui/core"
"aego/ui/widgets"
)
type modalKind uint8
const (
modalRecovery modalKind = iota
modalConflict
modalSaveAs
modalQuit
modalClose
modalLock
modalAsk
modalAbout
)
type modal struct {
kind modalKind
doc document.ID
path string
removed bool
rec document.Recovery
lock document.LockInfo
input string
ask context.Question
vals []string
focus bool
}
func (a *App) push(m modal) {
if m.kind != modalAsk {
for _, o := range a.modals {
if o.kind == m.kind && o.doc == m.doc && o.rec.File == m.rec.File {
return
}
}
}
a.modals = append(a.modals, m)
}
func (a *App) pop() {
if len(a.modals) > 0 {
a.modals = a.modals[1:]
}
if len(a.modals) == 0 {
a.ui.CloseModal()
}
}
func mineSuggestion(p string) string {
return strings.TrimSuffix(p, document.SceneExt) + "-mine" + document.SceneExt
}
func (a *App) drawModals() {
if len(a.modals) == 0 {
return
}
ui := a.ui
docs := a.ctx.Docs
id := core.IDOf("editor.modal")
ui.OpenModal(id)
md := a.modals[0]
switch md.kind {
case modalRecovery:
msg := i18n.Tr("editor.shell.an_autosave_of_path_from", "An autosave of {path} from {v} was found.", i18n.Str("path", md.rec.Path), i18n.Str("v", md.rec.Time.Format("2006-01-02 15:04")))
switch widgets.Confirm(ui, id, i18n.Tr("editor.shell.recover_unsaved_changes", "Recover unsaved changes"), msg, i18n.Tr("editor.shell.restore", "Restore"), i18n.Tr("editor.shell.discard", "Discard"), i18n.Tr("editor.shell.later", "Later")) {
case 0:
if _, err := docs.RestoreRecovery(md.rec); err != nil {
a.ctx.Notify(context.LevelError, err.Error())
}
a.pop()
case 1:
docs.DiscardRecovery(md.rec)
a.pop()
case 2:
a.pop()
}
case modalConflict:
msg := i18n.Tr("editor.shell.path_was_changed_on_disk", "{path} was changed on disk while it has unsaved edits.", i18n.Str("path", md.path))
first := i18n.Tr("editor.shell.reload", "Reload")
if md.removed {
msg = i18n.Tr("editor.shell.path_was_deleted_on_disk", "{path} was deleted on disk.", i18n.Str("path", md.path))
first = i18n.Tr("editor.shell.close", "Close")
}
switch widgets.Confirm(ui, id, i18n.Tr("editor.shell.scene_changed_outside_the_editor", "Scene changed outside the editor"), msg, first, i18n.Tr("editor.shell.keep_mine", "Keep mine"), i18n.Tr("editor.shell.save_as", "Save As…")) {
case 0:
if md.removed {
a.ctx.Selection.DropDoc(md.doc)
docs.Close(md.doc, true)
} else if err := docs.Reload(md.doc); err != nil {
a.ctx.Notify(context.LevelError, err.Error())
} else {
a.ctx.PruneSelectionOf(md.doc)
}
a.pop()
case 1:
docs.KeepMine(md.doc)
a.pop()
case 2:
a.modals[0] = modal{kind: modalSaveAs, doc: md.doc, input: mineSuggestion(md.path)}
}
case modalSaveAs:
a.drawSaveAs(id)
case modalQuit:
msg := i18n.Tr("editor.shell.dirtycount_scene_s_have_unsaved", "{dirtycount} scene(s) have unsaved changes.", i18n.Str("dirtycount", strconv.Itoa(docs.DirtyCount())))
switch widgets.Confirm(ui, id, i18n.Tr("editor.shell.quit_aego_editor", "Quit Aego Editor"), msg, i18n.Tr("editor.shell.save_all", "Save All"), i18n.Tr("editor.shell.discard", "Discard"), i18n.Tr("editor.shell.cancel", "Cancel")) {
case 0:
if err := docs.SaveAll(); err != nil {
a.ctx.Notify(context.LevelError, err.Error())
a.pop()
return
}
a.pop()
a.quitting = true
a.eng.Quit()
case 1:
a.discard = true
a.pop()
a.quitting = true
a.eng.Quit()
case 2:
a.pop()
}
case modalClose:
switch widgets.Confirm(ui, id, i18n.Tr("editor.shell.close_scene", "Close scene"), i18n.Tr("editor.shell.path_has_unsaved_changes", "{path} has unsaved changes.", i18n.Str("path", md.path)), i18n.Tr("editor.shell.save", "Save"), i18n.Tr("editor.shell.discard", "Discard"), i18n.Tr("editor.shell.cancel", "Cancel")) {
case 0:
if err := docs.Save(md.doc); err != nil {
a.ctx.Notify(context.LevelError, err.Error())
} else {
a.ctx.Selection.DropDoc(md.doc)
docs.Close(md.doc, false)
}
a.pop()
case 1:
a.ctx.Selection.DropDoc(md.doc)
docs.Close(md.doc, true)
a.pop()
case 2:
a.pop()
}
case modalLock:
msg := fmt.Sprintf("editor.lock belongs to pid %d on %s (%s). Another editor may be running, or the previous one crashed.",
md.lock.PID, md.lock.Host, md.lock.Time.Format("2006-01-02 15:04"))
if widgets.Confirm(ui, id, i18n.Tr("editor.shell.project_may_be_open_elsewhere", "Project may be open elsewhere"), msg, i18n.Tr("editor.shell.continue", "Continue")) == 0 {
a.pop()
}
case modalAsk:
if len(md.ask.Fields) > 0 {
a.drawPrompt(id)
return
}
if c := widgets.Confirm(ui, id, md.ask.Title, md.ask.Message, md.ask.Buttons...); c >= 0 {
a.pop()
if md.ask.Then != nil {
md.ask.Then(c)
}
}
case modalAbout:
a.drawAbout(id)
}
}
func (a *App) drawAbout(id core.ID) {
ui, th := a.ui, &a.ui.Theme
size := ui.Size()
w := max(float32(1), min(float32(480), size.X-40))
h := min(size.Y, 2*th.Padding+7*(th.RowHeight+th.Spacing))
r := mathx.Rect{X: (size.X - w) / 2, Y: max(0, (size.Y-h)/3), W: w, H: h}
if !ui.BeginModal(id, r) {
return
}
widgets.Label(ui, i18n.Tr("editor.shell.about_aego", "About Aego"))
widgets.LabelDim(ui, i18n.Tr("editor.shell.about_version", "Aego engine {version}", i18n.Str("version", engine.Version))+" · "+runtime.Version()+" · "+runtime.GOOS+"/"+runtime.GOARCH)
ui.Cursor().Next(0)
widgets.LabelDim(ui, i18n.Tr("editor.shell.created_by", "Created by: sanspie"))
link := ui.Cursor().Next(0)
link.W = min(link.W, ui.TextSize("about.akarpov.ru").X)
linkID := ui.ID("about.developer")
openDeveloper := ui.Clicked(linkID, link)
color := th.Accent
if ui.Hot() == linkID {
ui.SetCursorShape(core.CursorPointer)
ui.Rect(mathx.Rect{X: link.X, Y: link.Y + link.H - 2, W: link.W, H: 1}, color)
}
ui.Text("about.akarpov.ru", mathx.Vec2{X: link.X, Y: ui.TextY(link)}, color)
widgets.LabelDim(ui, ":3")
row := ui.Cursor().Next(0)
closeLabel := i18n.Tr("editor.shell.close", "Close")
websiteLabel := i18n.Tr("editor.shell.open_website", "Open website")
closeW := ui.TextSize(closeLabel).X + 28
websiteW := ui.TextSize(websiteLabel).X + 28
closeRect := mathx.Rect{X: row.X + row.W - closeW, Y: row.Y, W: closeW, H: row.H}
websiteRect := mathx.Rect{X: closeRect.X - websiteW - 6, Y: row.Y, W: websiteW, H: row.H}
openRepo := widgets.ButtonRect(ui, ui.ID("about.repository"), websiteRect, websiteLabel)
close := widgets.ButtonRect(ui, ui.ID("about.close"), closeRect, closeLabel) || ui.KeyPressed(input.KeyEscape)
ui.EndModal()
if openDeveloper {
a.openSupportURL(developerURL)
}
if openRepo {
a.openSupportURL(repositoryURL)
}
if close {
a.pop()
}
}
func (a *App) drawPrompt(id core.ID) {
ui := a.ui
th := &ui.Theme
md := &a.modals[0]
q := md.ask
for len(md.vals) < len(q.Fields) {
md.vals = append(md.vals, "")
}
rows := 2 + 2*len(q.Fields)
if q.Message != "" {
rows++
}
sz := ui.Size()
w := min(float32(460), sz.X-40)
r := mathx.Rect{X: (sz.X - w) / 2, Y: sz.Y / 3, W: w, H: float32(rows)*(th.RowHeight+th.Spacing) + th.Padding*4}
if !ui.BeginModal(id, r) {
return
}
widgets.Label(ui, q.Title)
if q.Message != "" {
widgets.LabelDim(ui, q.Message)
}
base := ui.ID("prompt")
submit, escape := false, false
for i, f := range q.Fields {
widgets.LabelDim(ui, f)
fid := core.IDSeq(base, i)
if md.focus && i == 0 {
ui.SetFocus(fid)
ui.EditBuffer(fid, md.vals[i]).SelectAll()
md.focus = false
}
if ui.Focus() == fid {
submit = submit || ui.KeyPressed(input.KeyEnter) || ui.KeyPressed(input.KeyKPEnter)
escape = escape || ui.KeyPressed(input.KeyEscape)
}
widgets.TextFieldRect(ui, fid, ui.Cursor().Next(0), &md.vals[i], true)
}
row := ui.Cursor().Next(0)
okLabel, cancelLabel := i18n.Tr("editor.common.ok", "OK"), i18n.Tr("editor.common.cancel", "Cancel")
if len(q.Buttons) > 0 {
okLabel = q.Buttons[0]
}
if len(q.Buttons) > 1 {
cancelLabel = q.Buttons[1]
}
ok := widgets.ButtonRect(ui, ui.ID("prompt.ok"), mathx.Rect{X: row.X + row.W - 184, Y: row.Y, W: 88, H: row.H}, okLabel)
cancel := widgets.ButtonRect(ui, ui.ID("prompt.cancel"), mathx.Rect{X: row.X + row.W - 90, Y: row.Y, W: 88, H: row.H}, cancelLabel)
ui.EndModal()
switch {
case ok || submit:
vals := md.vals
a.pop()
if q.Submit != nil {
q.Submit(vals)
}
case cancel || escape:
a.pop()
}
}
func (a *App) drawSaveAs(id core.ID) {
ui := a.ui
th := &ui.Theme
sz := ui.Size()
w := min(float32(460), sz.X-40)
r := mathx.Rect{X: (sz.X - w) / 2, Y: sz.Y / 3, W: w, H: th.RowHeight*4 + th.Padding*4}
if !ui.BeginModal(id, r) {
return
}
widgets.Label(ui, i18n.Tr("editor.shell.save_scene_as", "Save scene as"))
in := a.modals[0].input
widgets.TextField(ui, ui.ID("saveas.path"), &in)
a.modals[0].input = in
row := ui.Cursor().Next(0)
ok := widgets.ButtonRect(ui, ui.ID("saveas.ok"), mathx.Rect{X: row.X + row.W - 184, Y: row.Y, W: 88, H: row.H}, i18n.Tr("editor.shell.save", "Save"))
cancel := widgets.ButtonRect(ui, ui.ID("saveas.cancel"), mathx.Rect{X: row.X + row.W - 90, Y: row.Y, W: 88, H: row.H}, i18n.Tr("editor.shell.cancel", "Cancel"))
ui.EndModal()
doc := a.modals[0].doc
switch {
case ok:
if err := a.ctx.Docs.SaveAs(doc, in); err != nil {
a.ctx.Notify(context.LevelError, err.Error())
return
}
a.pop()
case cancel:
a.pop()
}
}