503 lines
18 KiB
Go
503 lines
18 KiB
Go
package editor
|
|
|
|
import (
|
|
"bytes"
|
|
stdctx "context"
|
|
"errors"
|
|
"fmt"
|
|
"image"
|
|
"io"
|
|
"os"
|
|
"path"
|
|
"strings"
|
|
|
|
"aego/art"
|
|
"aego/asset"
|
|
"aego/asset/importers/artsource"
|
|
"aego/asset/pipeline"
|
|
"aego/core/mathx"
|
|
"aego/editor/core/artedit"
|
|
"aego/editor/core/context"
|
|
"aego/editor/core/document"
|
|
"aego/editor/core/workspace"
|
|
"aego/editor/ext"
|
|
"aego/engine/i18n"
|
|
"aego/ui/widgets"
|
|
)
|
|
|
|
const artHandlerID = "render2d.art"
|
|
const artPNGHandlerID = "render2d.art.png"
|
|
|
|
const (
|
|
artRectSelect = 7 + iota
|
|
artWand
|
|
artEyedropper
|
|
artEllipseSelect
|
|
artLasso
|
|
artCurve
|
|
artPolygon
|
|
)
|
|
|
|
// The panel owns only presentation. ArtBinding.Model is canonical across tabs,
|
|
// Inspector, recovery and confirmed agent operations; pixels never live in View.
|
|
type artPanel struct {
|
|
ctx *context.Editor
|
|
reg *ext.Registry
|
|
doc *document.AssetDocument
|
|
model *artedit.Document
|
|
view *artView
|
|
err error
|
|
empty bool
|
|
blankBefore []byte
|
|
docks *artDocks
|
|
}
|
|
|
|
type artView struct {
|
|
viewport imageViewport
|
|
display imageDisplay
|
|
pixels *imagePixels
|
|
revision, device uint64
|
|
shownFrame art.ID
|
|
shownIsolation art.ID
|
|
shownChannel art.Channel
|
|
shownOnion bool
|
|
refresh bool
|
|
tool, pane, surface, framePage, layerPage, palettePage, brushSource int
|
|
size, dither, eraserStrength float64
|
|
eraserMode int
|
|
color, background mathx.Color
|
|
recentColors []art.Color
|
|
pickerHue, pickerSaturation float32
|
|
pickerLast art.Color
|
|
pickerReady bool
|
|
pickOnce bool
|
|
pickReturnTool int
|
|
palette int
|
|
backgroundPalette int
|
|
perfect, round, filled, mirrorX, mirrorY, grid, onion, playing bool
|
|
playStart float64
|
|
tag art.ID
|
|
tagPage int
|
|
tagName string
|
|
duration string
|
|
durationFrame art.ID
|
|
durationMS int
|
|
selecting bool
|
|
panDragging bool
|
|
selectionStart image.Point
|
|
canvasStatus string
|
|
selectionTool int
|
|
selectionRevision uint64
|
|
selectionLayer, selectionFrame art.ID
|
|
selectionChannel art.Channel
|
|
selectionPoints []image.Point
|
|
outlineSelection *art.Selection
|
|
outline []artSelectionEdge
|
|
outlineSimplified bool
|
|
propertyScroll, propertyContent float32
|
|
propertyBar widgets.ScrollState
|
|
partScroll map[string]*artPartScroll
|
|
toolSample artToolSample
|
|
toolScroll, toolContent float32
|
|
toolBar widgets.ScrollState
|
|
timelineScroll float32
|
|
timelinePanelScroll float32
|
|
timelineLayer art.ID
|
|
timelineColumns, timelinePage int
|
|
timelineBar widgets.ScrollState
|
|
timelinePanelBar widgets.ScrollState
|
|
timelineCells []art.CellSlot
|
|
timelineAnchor art.CellSlot
|
|
thumbnailClock uint64
|
|
thumbnails map[artThumbnailKey]*artThumbnail
|
|
construction []image.Point
|
|
constructionTool int
|
|
canvasBackground int
|
|
tiled bool
|
|
}
|
|
|
|
func artHooks(r *ext.Registry) error {
|
|
ed := r.Editor()
|
|
if ed == nil || ed.Docs == nil {
|
|
return nil
|
|
}
|
|
if err := registerArtCreate(r); err != nil {
|
|
return err
|
|
}
|
|
if err := registerAsepriteImport(r); err != nil {
|
|
return err
|
|
}
|
|
docks, err := newArtDocks(r)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
views := map[document.ID]*artView{}
|
|
previewStamps := map[document.ID]artPreviewStamp{}
|
|
drafts := map[document.ID]*pngArtDraft{}
|
|
var blank *blankArtDraft
|
|
var open func(*document.AssetDocument) error
|
|
open = r.DocumentPanelFactory(func(d *document.AssetDocument) workspace.Panel {
|
|
model, _ := d.Binding.Model.(*artedit.Document)
|
|
var err error
|
|
if model == nil {
|
|
model, err = artedit.Bind(ed.Docs, d)
|
|
}
|
|
v := views[d.ID]
|
|
if v == nil {
|
|
v = &artView{size: 1, eraserStrength: 100, perfect: true, grid: true, color: mathx.White, refresh: true}
|
|
views[d.ID] = v
|
|
}
|
|
panel := &artPanel{ctx: ed, reg: r, doc: d, model: model, view: v, err: err, docks: docks}
|
|
if model != nil {
|
|
d.Binding.Actions[document.ActionSaveAs] = func() error { panel.askSaveAs(); return nil }
|
|
}
|
|
if draft := drafts[d.ID]; draft != nil && model != nil && model.Draft {
|
|
d.Binding.BeforeSave = draft.verify
|
|
d.Binding.SaveUnchanged = true
|
|
d.Binding.Save = func() error {
|
|
return draft.askSave(panel, func(next *document.AssetDocument) error {
|
|
views[next.ID] = v
|
|
delete(views, d.ID)
|
|
delete(drafts, d.ID)
|
|
r.ClosePanel(panel.ID())
|
|
return open(next)
|
|
})
|
|
}
|
|
}
|
|
if draft := blank; draft != nil && draft.doc == d && model == draft.model && model.Draft {
|
|
d.Binding.BeforeSave = draft.verify
|
|
d.Binding.SaveUnchanged = true
|
|
d.Binding.Save = func() error {
|
|
return draft.askSave(panel, func(next *document.AssetDocument) error {
|
|
views[next.ID] = v
|
|
delete(views, d.ID)
|
|
blank = nil
|
|
r.ClosePanel(panel.ID())
|
|
if err := ed.Docs.CloseAsset(d.Asset, false); err != nil {
|
|
ed.Notify(context.LevelWarn, err.Error())
|
|
}
|
|
return open(next)
|
|
})
|
|
}
|
|
// A first Save As must adopt the new asset, not leave an unrelated
|
|
// copy while the user's original drawing remains untitled.
|
|
d.Binding.Actions[document.ActionSaveAs] = func() error {
|
|
err := d.Binding.Save()
|
|
if errors.Is(err, document.ErrSavePending) {
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
}
|
|
if err != nil && ed.Pipeline != nil && d.Capabilities.Has(document.EditSource) {
|
|
if data, readErr := ed.Pipeline.SourceBytes(d.Path); readErr == nil && isBlankArtSource(data) {
|
|
panel.empty = true
|
|
panel.blankBefore = append([]byte{}, data...)
|
|
}
|
|
}
|
|
docks.active = panel
|
|
current := r.CurrentWorkspace()
|
|
if !current.User && (current.ID == workspace.Design || current.ID == workspace.Default) {
|
|
r.SelectWorkspace(workspace.Art)
|
|
}
|
|
return panel
|
|
})
|
|
openSelected := func(d *document.AssetDocument) error {
|
|
// Refuse a frame switch during a gesture before changing presentation.
|
|
if model, ok := d.Binding.Model.(*artedit.Document); ok {
|
|
if err := selectArtSubasset(model, d.Subasset); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if err := open(d); err != nil {
|
|
return err
|
|
}
|
|
if model, ok := d.Binding.Model.(*artedit.Document); ok {
|
|
return selectArtSubasset(model, d.Subasset)
|
|
}
|
|
return nil
|
|
}
|
|
if err := r.DocumentHandler(document.Handler{
|
|
ID: artHandlerID, Name: i18n.T("editor.art.document", "Pixel Art / Frame Animation"), Icon: "file.art",
|
|
Kinds: []asset.ArtifactKind{asset.ArtTexture, asset.ArtSpriteRegion, asset.ArtAnim}, Extensions: []string{".aeart"},
|
|
SourceExtensionsOnly: true,
|
|
Capabilities: document.View | document.EditSource | document.EditMetadata | document.Preview | document.RevealSource,
|
|
Open: openSelected,
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
if err := r.DocumentHandler(document.Handler{
|
|
ID: artPNGHandlerID, Name: i18n.T("editor.art.document", "Pixel Art / Frame Animation"), Icon: "file.art",
|
|
Kinds: []asset.ArtifactKind{asset.ArtTexture, asset.ArtSpriteRegion}, Extensions: []string{".png"}, Roles: []string{string(asset.RoleColor)},
|
|
SourceExtensionsOnly: true,
|
|
Capabilities: document.View | document.EditSource | document.EditMetadata | document.Preview | document.RevealSource,
|
|
Open: func(d *document.AssetDocument) error {
|
|
draft := drafts[d.ID]
|
|
if draft == nil {
|
|
var link *artsource.TextureLink
|
|
var err error
|
|
draft, link, err = openPNGArtDraft(ed, d)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if link != nil {
|
|
return openLinkedArtFrame(ed, link, d.Selected)
|
|
}
|
|
drafts[d.ID] = draft
|
|
}
|
|
if err := draft.selectFrame(d.Selected); err != nil {
|
|
return err
|
|
}
|
|
// Remove only the old presentation, never its shared metadata document.
|
|
r.ClosePanel(imagePanelID(d))
|
|
return open(d)
|
|
},
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
wasArt := false
|
|
r.Tick(func() {
|
|
pollArtSourcePreviews(ed, previewStamps)
|
|
live := map[document.ID]bool{}
|
|
for _, d := range ed.Docs.AssetDocuments() {
|
|
live[d.ID] = true
|
|
}
|
|
for id, v := range views {
|
|
if !live[id] {
|
|
v.close(ed)
|
|
delete(views, id)
|
|
}
|
|
}
|
|
for id := range drafts {
|
|
if !live[id] {
|
|
delete(drafts, id)
|
|
}
|
|
}
|
|
if blank != nil && ed.Docs.AssetDocument(blank.doc.Asset) != blank.doc {
|
|
blank = nil
|
|
}
|
|
preset := r.CurrentWorkspace()
|
|
inArt := preset.ID == workspace.Art || preset.User && preset.Base == workspace.Art
|
|
entered := inArt && !wasArt
|
|
wasArt = inArt
|
|
if !entered || ed.Project == nil || ed.Pipeline == nil {
|
|
return
|
|
}
|
|
// Reuse an existing drawing, especially a dirty one, rather than hiding
|
|
// it behind a newly created blank on workspace switches.
|
|
if active := docks.active; active != nil && active.model != nil && ed.Docs.AssetDocument(active.doc.Asset) == active.doc {
|
|
if err := open(active.doc); err != nil {
|
|
ed.Notify(context.LevelError, err.Error())
|
|
}
|
|
return
|
|
}
|
|
for _, doc := range ed.Docs.AssetDocuments() {
|
|
if _, ok := doc.Binding.Model.(*artedit.Document); ok {
|
|
if err := open(doc); err != nil {
|
|
ed.Notify(context.LevelError, err.Error())
|
|
}
|
|
return
|
|
}
|
|
}
|
|
var err error
|
|
blank, err = newWorkspaceBlank(ed)
|
|
if err == nil {
|
|
err = open(blank.doc)
|
|
}
|
|
if err != nil {
|
|
ed.Notify(context.LevelError, err.Error())
|
|
}
|
|
})
|
|
return nil
|
|
}
|
|
|
|
func selectArtSubasset(model *artedit.Document, subasset string) error {
|
|
if !strings.HasPrefix(subasset, "frame_") {
|
|
return nil
|
|
}
|
|
for _, frame := range model.Session.Document.Info().Frames {
|
|
for _, channel := range []art.Channel{art.Albedo, art.Normal, art.Height, art.Emission} {
|
|
if artsource.FrameName(frame.ID, channel) == subasset {
|
|
if model.Session.Frame == frame.ID && model.Session.Channel == channel {
|
|
return nil
|
|
}
|
|
return model.Session.Select("", frame.ID, channel)
|
|
}
|
|
}
|
|
}
|
|
return fmt.Errorf("%s", i18n.Tr("editor.art.frame_unavailable", "The selected art frame is no longer available."))
|
|
}
|
|
|
|
func (p *artPanel) ID() string { return artHandlerID + "/" + p.doc.Asset.String() }
|
|
func (p *artPanel) Title() string { return path.Base(p.doc.Path) }
|
|
func (p *artPanel) Dirty() bool { return p.doc.Dirty() }
|
|
func (p *artPanel) Icon() string { return "file.art" }
|
|
func (p *artPanel) Scope() string { return artHandlerID }
|
|
func (p *artPanel) Document() *document.AssetDocument { return p.doc }
|
|
func (p *artPanel) report(err error) {
|
|
if errors.Is(err, document.ErrSavePending) {
|
|
return
|
|
}
|
|
p.err = err
|
|
if err != nil {
|
|
p.ctx.Notify(context.LevelError, err.Error())
|
|
}
|
|
}
|
|
func (p *artPanel) close(force bool) {
|
|
if err := p.ctx.Docs.CloseAsset(p.doc.Asset, force); err != nil {
|
|
p.report(err)
|
|
return
|
|
}
|
|
p.view.close(p.ctx)
|
|
p.reg.ClosePanel(p.ID())
|
|
}
|
|
func (p *artPanel) RequestClose() {
|
|
if p.model != nil {
|
|
p.model.Session.CancelGesture()
|
|
}
|
|
if !p.doc.Dirty() {
|
|
p.close(false)
|
|
return
|
|
}
|
|
p.ctx.Ask(context.Question{
|
|
Title: i18n.Tr("editor.art.unsaved", "Unsaved drawing"),
|
|
Message: i18n.Tr("editor.art.close_message", "Save changes to {path}?", i18n.Str("path", p.doc.Path)),
|
|
Buttons: []string{i18n.Tr("editor.art.save", "Save"), i18n.Tr("editor.art.discard", "Discard"), i18n.Tr("editor.art.cancel", "Cancel")},
|
|
Then: func(choice int) {
|
|
switch choice {
|
|
case 0:
|
|
if err := p.ctx.SaveAsset(p.doc); err != nil {
|
|
p.report(err)
|
|
return
|
|
}
|
|
if !p.doc.Dirty() {
|
|
p.close(false)
|
|
}
|
|
case 1:
|
|
p.close(true)
|
|
}
|
|
},
|
|
})
|
|
}
|
|
|
|
func (p *artPanel) execute(op art.Operation) {
|
|
if p.model == nil {
|
|
return
|
|
}
|
|
_, err := p.model.Execute(stdctx.Background(), op)
|
|
p.report(err)
|
|
p.view.refresh = true
|
|
}
|
|
func (p *artPanel) askInitializeEmpty() {
|
|
ctx, pl, docs, doc := p.ctx, p.ctx.Pipeline, p.ctx.Docs, p.doc
|
|
before := append([]byte{}, p.blankBefore...)
|
|
message := i18n.Tr("editor.art.initialize_empty_confirm", "Create a drawing in this blank file?")
|
|
if len(before) > 0 {
|
|
message = i18n.Tr("editor.art.initialize_blank_backup", "The original text will be backed up in .aego.")
|
|
}
|
|
ctx.Ask(context.Question{
|
|
Title: i18n.Tr("editor.art.initialize_empty", "Initialize empty drawing…"),
|
|
Message: message,
|
|
Fields: []string{i18n.Tr("editor.art.width", "Width"), i18n.Tr("editor.art.height_pixels", "Height"), i18n.Tr("editor.art.color_mode", "Color mode: rgba / grayscale / indexed")},
|
|
Values: []string{"32", "32", "rgba"},
|
|
Buttons: []string{i18n.Tr("editor.art.create", "Create"), i18n.Tr("editor.art.cancel", "Cancel")},
|
|
Submit: func(values []string) {
|
|
if len(values) != 3 {
|
|
return
|
|
}
|
|
if ctx.Pipeline != pl || ctx.Docs != docs || docs.AssetDocument(doc.Asset) != doc || p.model != nil || !p.empty {
|
|
p.report(fmt.Errorf("art: project or document changed before initialization"))
|
|
return
|
|
}
|
|
if rel, ok := pl.PathOf(doc.Asset); !ok || rel != doc.Path {
|
|
p.report(fmt.Errorf("art: source changed before initialization"))
|
|
return
|
|
}
|
|
current, err := pl.SourceBytes(doc.Path)
|
|
if err != nil {
|
|
p.report(err)
|
|
return
|
|
}
|
|
if current == nil || !bytes.Equal(current, before) {
|
|
p.report(fmt.Errorf("art: source changed before initialization"))
|
|
return
|
|
}
|
|
data, err := blankArtSource(values[0], values[1], values[2])
|
|
if err != nil {
|
|
p.report(err)
|
|
return
|
|
}
|
|
backup, err := backupBlankArt(ctx, doc, before)
|
|
if err != nil {
|
|
p.report(err)
|
|
return
|
|
}
|
|
// Before is non-nil even for a zero-byte file. The transaction
|
|
// rejects a file changed while the dialog or backup was prepared.
|
|
batch, err := pl.ApplySources([]pipeline.SourceEdit{{Path: doc.Path, Before: before, After: data}}, false)
|
|
if err != nil {
|
|
p.report(err)
|
|
return
|
|
}
|
|
ctx.ApplyBatch(batch)
|
|
p.empty = false
|
|
p.model, p.err = artedit.Bind(docs, doc)
|
|
p.view.refresh = true
|
|
if p.err != nil {
|
|
p.report(p.err)
|
|
} else if backup != "" {
|
|
ctx.Notify(context.LevelInfo, i18n.Tr("editor.art.blank_backup_saved", "Original text saved at {path}", i18n.Str("path", backup)))
|
|
}
|
|
},
|
|
})
|
|
}
|
|
|
|
func isBlankArtSource(data []byte) bool {
|
|
return data != nil && len(data) <= 4096 && len(bytes.Trim(data, " \t\r\n")) == 0
|
|
}
|
|
|
|
func backupBlankArt(ctx *context.Editor, doc *document.AssetDocument, data []byte) (string, error) {
|
|
if len(data) == 0 {
|
|
return "", nil
|
|
}
|
|
if ctx.Project == nil {
|
|
return "", fmt.Errorf("art: cannot back up the original without a project")
|
|
}
|
|
root, err := os.OpenRoot(ctx.Project.Root)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer root.Close()
|
|
info, err := root.Lstat(".aego")
|
|
if err != nil || !info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
|
|
return "", fmt.Errorf("art: cannot safely access the project's .aego directory")
|
|
}
|
|
base := ".aego/art-blank-" + doc.Asset.String()
|
|
for i := 0; i < 1000; i++ {
|
|
name := base + ".original"
|
|
if i > 0 {
|
|
name = fmt.Sprintf("%s-%d.original", base, i+1)
|
|
}
|
|
f, err := root.OpenFile(name, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
|
|
if errors.Is(err, os.ErrExist) {
|
|
continue
|
|
}
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
n, writeErr := f.Write(data)
|
|
if writeErr == nil && n != len(data) {
|
|
writeErr = io.ErrShortWrite
|
|
}
|
|
if writeErr == nil {
|
|
writeErr = f.Sync()
|
|
}
|
|
writeErr = errors.Join(writeErr, f.Close())
|
|
if writeErr != nil {
|
|
return "", writeErr
|
|
}
|
|
return name, nil
|
|
}
|
|
return "", fmt.Errorf("art: no available backup name")
|
|
}
|