Files
sanspie fac6fa35a4
ci-platforms / darwin arm64 smoke (push) Skipped
ci-platforms / windows amd64 smoke (push) Skipped
ci-platforms / darwin amd64 smoke (push) Skipped
github-native / gitea-pending (push) Skipped
github-native / darwin amd64 native (push) Skipped
github-native / linux amd64 native (push) Skipped
github-native / windows amd64 native (push) Skipped
github-native / darwin arm64 native (push) Skipped
github-native / gitea-result (push) Skipped
ci / checks (push) Failing after 7m29s
ci / build (amd64, linux, ubuntu-24.04) (push) Failing after 14m13s
ci / release (push) Skipped
GitHub / windows amd64 native GitHub-hosted native CI: failure
GitHub / darwin arm64 native GitHub-hosted native CI: error
GitHub / darwin amd64 native GitHub-hosted native CI: failure
GitHub / linux amd64 native GitHub-hosted native CI: success
fix: stabilize CI and prepare docs release
Keep recovery copies private on Windows, make cross-platform test fixtures deterministic, and preserve the updated Art and documentation contracts.

Refresh workspace screenshot references for the intentional factory layouts and restore the earlier Russian palette reference after before/after comparison. Keep the existing screenshot tolerances.
2026-11-09 16:24:24 +03:00

845 lines
34 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package editor
import (
"bytes"
stdctx "context"
"encoding/json"
"fmt"
"image"
"image/png"
"path"
"strconv"
"strings"
"aego/art"
"aego/asset"
"aego/asset/importers/artsource"
"aego/asset/pipeline"
"aego/core/ids"
"aego/core/mathx"
"aego/editor/core/assetops"
"aego/editor/core/context"
"aego/editor/core/document"
"aego/engine/i18n"
"aego/format/aetext"
"aego/ui/core"
"aego/ui/widgets"
)
// These commands are presentation adapters over the shared Art operations.
// A delayed dialog is tied to the same open document, selection and revision.
func (p *artPanel) askArtValues(title string, fields, values []string, apply func([]string) error) {
model, doc, docs, pl := p.model, p.doc, p.ctx.Docs, p.ctx.Pipeline
s := model.Session
owner := s.Document
revision, layer, frame, channel, selection := s.Document.Revision(), s.Layer, s.Frame, s.Channel, s.Selection
p.ctx.Ask(context.Question{Title: title, Fields: fields, Values: values,
Buttons: []string{i18n.Tr("editor.art.apply", "Apply"), i18n.Tr("editor.art.cancel", "Cancel")},
Submit: func(v []string) {
if len(v) != len(fields) {
return
}
if p.ctx.Docs != docs || p.ctx.Pipeline != pl || docs.AssetDocument(doc.Asset) != doc || doc.Binding.Model != model || model.Session != s || s.Document != owner ||
s.Document.Revision() != revision || s.Layer != layer || s.Frame != frame || s.Channel != channel || s.Selection != selection || s.Info().Gesture {
p.report(fmt.Errorf("%s", i18n.Tr("editor.art.action_stale", "The document or selection changed. Open the action again.")))
return
}
p.report(apply(v))
p.view.refresh = true
},
})
}
func (p *artPanel) actionMenu(ui *core.Context, r mathx.Rect, id, title string, draw func(func(string, string, bool, func()))) {
popup := ui.ID(id + "-popup")
if artButton(ui, id, r, title) {
ui.OpenPopup(popup, mathx.Vec2{X: r.X, Y: r.Y + r.H})
}
if !ui.BeginPopup(popup, 260) {
return
}
draw(func(key, label string, enabled bool, run func()) {
ui.PushID(key)
tip := ""
if key == "merge" {
tip = i18n.Tr("editor.art.merge_hint", "Merge adjacent visible, unlocked layers of the same kind using Normal blend. Other combinations are rejected; pixels are never silently approximated.")
}
if widgets.Entry(ui, widgets.Item{Label: label, Tip: tip, Enabled: enabled, Checked: key == "isolate" && p.model.Session.Info().Isolation != ""}) {
ui.ClosePopup(popup)
run()
}
ui.PopID()
})
ui.EndPopup()
}
type artActionGroups uint8
const (
artActionsLayer artActionGroups = 1 << iota
artActionsPixels
artActionsPalette
artActionsDocument
)
// Independent dock panels expose the same commands against the same document;
// the combined legacy panel retains all four menus in their original order.
func (p *artPanel) authoringActionsFor(ui *core.Context, editable bool, groups artActionGroups) {
cur, s := ui.Cursor(), p.model.Session
info := s.Info()
var layer art.Layer
for _, v := range info.Document.Layers {
if v.ID == s.Layer {
layer = v
break
}
}
if groups&artActionsLayer != 0 {
p.actionMenu(ui, cur.Next(0), "layer-actions", i18n.Tr("editor.art.layer_actions", "Layer…"), func(item func(string, string, bool, func())) {
item("properties", i18n.Tr("editor.art.layer_settings", "Layer properties…"), editable, func() { p.askLayerProperties(layer) })
item("group", i18n.Tr("editor.art.new_group", "New group"), editable, func() {
p.execute(art.Operation{Kind: "add-layer", Name: i18n.Tr("editor.art.group", "Group"), LayerKind: art.Group})
})
item("reference", i18n.Tr("editor.art.new_reference", "New reference layer"), editable, func() {
p.execute(art.Operation{Kind: "add-layer", Name: i18n.Tr("editor.art.reference", "Reference"), LayerKind: art.Reference})
})
item("duplicate", i18n.Tr("editor.art.duplicate", "Duplicate"), editable && layer.Kind != art.Group, func() { p.execute(art.Operation{Kind: "duplicate-layer"}) })
item("alpha-lock", i18n.Tr("editor.art.toggle_alpha_lock", "Toggle alpha lock"), editable && layer.Kind != art.Group, func() {
layer.AlphaLock = !layer.AlphaLock
p.execute(art.Operation{Kind: "set-layer", LayerValue: &layer})
})
item("up", i18n.Tr("editor.art.layer_up", "Move layer up"), editable, func() { p.execute(art.Operation{Kind: "move-layer", Offset: -1}) })
item("down", i18n.Tr("editor.art.layer_down", "Move layer down"), editable, func() { p.execute(art.Operation{Kind: "move-layer", Offset: 1}) })
item("delete", i18n.Tr("editor.art.delete_layer", "Delete layer"), editable && len(info.Document.Layers) > 1, func() { p.execute(art.Operation{Kind: "delete-layer"}) })
item("merge", i18n.Tr("editor.art.merge_down", "Merge down"), editable && layer.Kind != art.Group, func() { p.execute(art.Operation{Kind: "merge-down"}) })
item("isolate", i18n.Tr("editor.art.isolate", "Isolate selected layer / Show all"), true, func() {
id := s.Layer
if s.Info().Isolation == id {
id = ""
}
p.report(s.SetIsolation(id))
p.view.refresh = true
})
})
}
if groups&artActionsPixels != 0 {
p.actionMenu(ui, cur.Next(0), "pixel-actions", i18n.Tr("editor.art.pixel_actions", "Pixels…"), func(item func(string, string, bool, func())) {
item("all", i18n.Tr("editor.art.select_all", "Select all"), true, func() { p.report(s.SetSelection("all", nil, 0)); p.view.refresh = true })
item("none", i18n.Tr("editor.art.deselect", "Deselect"), true, func() { p.report(s.SetSelection("none", nil, 0)); p.view.refresh = true })
item("copy", i18n.Tr("editor.art.copy", "Copy pixels"), true, func() { p.report(s.Copy()) })
item("paste", i18n.Tr("editor.art.paste", "Paste pixels"), editable && s.Clipboard != nil, func() {
at := image.Point{}
if s.Selection != nil {
at = s.Selection.Bounds().Min
}
p.report(s.Paste(at))
p.view.refresh = true
})
for _, v := range []struct{ kind, label string }{
{"flip-x", i18n.Tr("editor.art.flip_x", "Flip horizontally")}, {"flip-y", i18n.Tr("editor.art.flip_y", "Flip vertically")},
{"rotate-cw", i18n.Tr("editor.art.rotate_cw", "Rotate 90° clockwise")}, {"rotate-ccw", i18n.Tr("editor.art.rotate_ccw", "Rotate 90° counterclockwise")},
} {
item(v.kind, v.label, editable && s.ActiveCel() != "", func() { p.execute(art.Operation{Kind: v.kind}) })
}
item("move", i18n.Tr("editor.art.move_pixels", "Move pixels…"), editable && s.ActiveCel() != "", func() { p.askTransform(false) })
item("scale", i18n.Tr("editor.art.scale_pixels", "Scale pixels…"), editable && s.ActiveCel() != "", func() { p.askTransform(true) })
item("clear", i18n.Tr("editor.art.clear_pixels", "Clear selected pixels"), editable && s.ActiveCel() != "", func() { p.execute(art.Operation{Kind: "clear"}) })
})
}
if groups&artActionsPalette != 0 {
p.actionMenu(ui, cur.Next(0), "palette-actions", i18n.Tr("editor.art.palette_actions", "Palette…"), func(item func(string, string, bool, func())) {
item("add", i18n.Tr("editor.art.palette_add_foreground", "Add foreground to palette"), editable && len(info.Document.Palette) < 256, p.addForegroundSwatch)
item("swatch", i18n.Tr("editor.art.palette_set_fg", "Set selected swatch to foreground"), editable, func() {
index := min(max(p.view.palette, 0), len(info.Document.Palette)-1)
swatch := info.Document.Palette[index]
swatch.Color = artColorBytes(p.view.color)
p.execute(art.Operation{Kind: "palette-color", Index: index, Swatch: &swatch})
})
item("left", i18n.Tr("editor.art.palette_left", "Move swatch left (preserve pixels)"), editable && p.view.palette > 1, func() { p.moveSwatch(-1) })
item("right", i18n.Tr("editor.art.palette_right", "Move swatch right (preserve pixels)"), editable && p.view.palette > 0 && p.view.palette < len(info.Document.Palette)-1, func() { p.moveSwatch(1) })
item("remap", i18n.Tr("editor.art.palette_remap", "Remap palette index…"), editable && info.Document.Canvas.Mode == art.Indexed, p.askPaletteRemap)
item("extract", i18n.Tr("editor.art.palette_extract", "Extract palette from frame…"), editable, p.askPaletteExtract)
item("ramp", i18n.Tr("editor.art.ramp_shade", "Shade using palette ramp…"), editable && s.ActiveCel() != "", p.askRampShade)
item("import", i18n.Tr("editor.art.palette_import", "Import palette…"), editable, p.askPaletteImport)
for _, v := range []struct {
mode art.Mode
label string
}{
{art.RGBA, i18n.Tr("editor.art.mode_rgba", "Convert to RGBA")}, {art.Grayscale, i18n.Tr("editor.art.mode_grayscale", "Convert to grayscale")}, {art.Indexed, i18n.Tr("editor.art.mode_indexed", "Convert to indexed palette")},
} {
item(string(v.mode), v.label, editable && info.Document.Canvas.Mode != v.mode, func() { p.execute(art.Operation{Kind: "mode", Mode: v.mode}) })
}
})
}
if groups&artActionsDocument != 0 {
p.actionMenu(ui, cur.Next(0), "document-actions", i18n.Tr("editor.art.export_actions", "Save / Export…"), func(item func(string, string, bool, func())) {
item("copy", i18n.Tr("editor.art.save_copy", "Save editable copy…"), true, func() { p.askExport("aeart") })
item("aseprite", i18n.Tr("editor.art.export_aseprite", "Export Aseprite interchange copy…"), true, p.askAsepriteExport)
item("png", i18n.Tr("editor.art.export_png", "Export current frame PNG…"), true, func() { p.askExport("png") })
item("sheet", i18n.Tr("editor.art.export_sheet", "Export sprite sheet and animation…"), true, func() { p.askExport("sheet") })
item("palette", i18n.Tr("editor.art.export_palette", "Export palette…"), true, func() { p.askExport("palette") })
})
}
}
func (p *artPanel) askLayerProperties(layer art.Layer) {
groups := p.model.Session.Document.Info().Layers
parent := ""
for _, group := range groups {
if group.ID == layer.Parent {
parent = group.Name
}
}
p.askArtValues(i18n.Tr("editor.art.layer_settings", "Layer properties…"),
[]string{i18n.Tr("editor.art.layer_name", "Name"), i18n.Tr("editor.art.opacity", "Opacity (0–255)"), i18n.Tr("editor.art.blend_mode", "Blend: normal / multiply / screen / add"), i18n.Tr("editor.art.parent_group", "Parent group name (empty = root)")},
[]string{layer.Name, strconv.Itoa(int(layer.Opacity)), string(layer.Blend), parent}, func(values []string) error {
opacity, err := strconv.ParseUint(strings.TrimSpace(values[1]), 10, 8)
if err != nil {
return err
}
layer.Name, layer.Opacity, layer.Blend, layer.Parent = values[0], uint8(opacity), art.Blend(strings.TrimSpace(values[2])), ""
name := strings.TrimSpace(values[3])
if name != "" {
for _, group := range groups {
if group.Kind == art.Group && group.Name == name {
if layer.Parent != "" {
return fmt.Errorf("art: more than one group is named %q", name)
}
layer.Parent = group.ID
}
}
if layer.Parent == "" {
return fmt.Errorf("art: group %q does not exist", name)
}
}
_, err = p.model.Execute(stdctx.Background(), art.Operation{Kind: "set-layer", LayerValue: &layer})
return err
})
}
func (p *artPanel) askTransform(scale bool) {
s := p.model.Session
r := image.Rect(0, 0, s.Document.Info().Canvas.Width, s.Document.Info().Canvas.Height)
if s.Selection != nil {
r = s.Selection.Bounds()
}
title, fields, values := i18n.Tr("editor.art.move_pixels", "Move pixels…"), []string{"X", "Y"}, []string{"0", "0"}
if scale {
title, fields, values = i18n.Tr("editor.art.scale_pixels", "Scale pixels…"), []string{i18n.Tr("editor.art.width", "Width"), i18n.Tr("editor.art.height_pixels", "Height")}, []string{strconv.Itoa(r.Dx()), strconv.Itoa(r.Dy())}
}
p.askArtValues(title, fields, values, func(v []string) error {
x, err := strconv.Atoi(strings.TrimSpace(v[0]))
if err != nil {
return err
}
y, err := strconv.Atoi(strings.TrimSpace(v[1]))
if err != nil {
return err
}
op := art.Operation{Kind: "move", Points: []image.Point{image.Pt(x, y)}}
if scale {
op = art.Operation{Kind: "scale", Width: x, Height: y}
}
_, err = p.model.Execute(stdctx.Background(), op)
return err
})
}
func (p *artPanel) moveSwatch(offset int) {
n, from := len(p.model.Session.Document.Info().Palette), p.view.palette
to := from + offset
if from < 0 || from >= n || to < 0 || to >= n {
return
}
order := make([]int, n)
for i := range order {
order[i] = i
}
order[from], order[to] = order[to], order[from]
_, err := p.model.Execute(stdctx.Background(), art.Operation{Kind: "palette-reorder", PaletteOrder: order})
p.report(err)
if err == nil {
p.view.palette, p.view.refresh = to, true
if p.view.backgroundPalette == from {
p.view.backgroundPalette = to
} else if p.view.backgroundPalette == to {
p.view.backgroundPalette = from
}
}
}
func (p *artPanel) askPaletteRemap() {
p.askArtValues(i18n.Tr("editor.art.palette_remap", "Remap palette index…"), []string{i18n.Tr("editor.art.palette_from", "Source index"), i18n.Tr("editor.art.palette_to", "Destination index")}, []string{strconv.Itoa(p.view.palette), strconv.Itoa(p.view.palette)}, func(v []string) error {
from, err := strconv.Atoi(strings.TrimSpace(v[0]))
if err != nil {
return err
}
to, err := strconv.Atoi(strings.TrimSpace(v[1]))
if err != nil {
return err
}
n := len(p.model.Session.Document.Info().Palette)
if from < 0 || from >= n || to < 0 || to >= n {
return fmt.Errorf("art: invalid palette index")
}
order := make([]int, n)
for i := range order {
order[i] = i
}
order[from] = to
_, err = p.model.Execute(stdctx.Background(), art.Operation{Kind: "palette-remap", PaletteOrder: order})
return err
})
}
func (p *artPanel) askPaletteExtract() {
p.askArtValues(i18n.Tr("editor.art.palette_extract", "Extract palette from frame…"), []string{i18n.Tr("editor.art.color_count", "Maximum colors (2–256)")}, []string{"32"}, func(v []string) error {
n, err := strconv.Atoi(strings.TrimSpace(v[0]))
if err != nil {
return err
}
_, err = p.model.Execute(stdctx.Background(), art.Operation{Kind: "palette-extract", ColorLimit: n})
return err
})
}
func (p *artPanel) askPaletteImport() {
p.askArtValues(i18n.Tr("editor.art.palette_import", "Import palette…"), []string{i18n.Tr("editor.art.palette_file", "Palette file (.json or .gpl)")}, []string{""}, func(v []string) error {
data, err := pipeline.ReadExternalFile(strings.TrimSpace(v[0]), art.MaxPaletteBytes)
if err != nil {
return err
}
palette, err := art.DecodePalette(data, strings.ToLower(strings.TrimPrefix(path.Ext(v[0]), ".")))
if err != nil {
return err
}
_, err = p.model.Execute(stdctx.Background(), art.Operation{Kind: "palette-load", Palette: palette})
return err
})
}
func (p *artPanel) askRampShade() {
n := len(p.model.Session.Document.Info().Palette)
p.askArtValues(i18n.Tr("editor.art.ramp_shade", "Shade using palette ramp…"),
[]string{i18n.Tr("editor.art.ramp_first", "First palette index"), i18n.Tr("editor.art.ramp_last", "Last palette index"), i18n.Tr("editor.art.ramp_step", "Step (negative = darker)")},
[]string{"1", strconv.Itoa(n - 1), "1"}, func(v []string) error {
values := [3]int{}
for i := range values {
n, err := strconv.Atoi(strings.TrimSpace(v[i]))
if err != nil {
return err
}
values[i] = n
}
_, err := p.model.Execute(stdctx.Background(), art.Operation{Kind: "ramp-shade", RampFirst: values[0], RampLast: values[1], Offset: values[2]})
return err
})
}
func (p *artPanel) askExport(format string) {
ext := ".png"
if format == "aeart" {
ext = ".aeart"
}
if format == "palette" {
ext = ".palette.json"
}
dir, stem := artExportLocation(p.doc, "-export")
target := assetops.UniqueFile(p.ctx, dir, stem, ext)
p.askArtValues(i18n.Tr("editor.art.export_actions", "Save / Export…"), []string{i18n.Tr("editor.art.new_export_path", "New project path (existing files are not overwritten)")}, []string{target}, func(v []string) error {
return p.exportTo(strings.TrimSpace(v[0]), format)
})
}
func artExportLocation(doc *document.AssetDocument, suffix string) (string, string) {
if doc.Transient {
return "assets", "drawing" + suffix
}
return path.Dir(doc.Path), strings.TrimSuffix(path.Base(doc.Path), path.Ext(doc.Path)) + suffix
}
// Interchange deliberately does not replace the current Aego source. Plain
// Aseprite files do not yet carry its stable identities or all surface data.
func (p *artPanel) askAsepriteExport() {
model, doc, docs, pl, project := p.model, p.doc, p.ctx.Docs, p.ctx.Pipeline, p.ctx.Project
if model == nil || docs == nil || pl == nil || model.PendingRecovery {
p.report(fmt.Errorf("%s", i18n.Tr("editor.art.aseprite_export_unavailable", "Resolve pending recovery and open a project drawing before exporting.")))
return
}
if model.Session.Info().Gesture {
p.report(fmt.Errorf("art: finish or cancel the gesture before export"))
return
}
revision := model.PreviewRevision()
snapshot, err := model.Session.Document.Snapshot()
if err != nil {
p.report(err)
return
}
data, notes, err := snapshot.EncodeAseprite()
if err != nil {
p.report(fmt.Errorf("%s: %w", i18n.Tr("editor.art.aseprite_export_unsupported", "Cannot export this drawing without losing data"), err))
return
}
current := func() bool {
return p.ctx.Docs == docs && p.ctx.Pipeline == pl && p.ctx.Project == project && docs.AssetDocument(doc.Asset) == doc &&
doc.Binding.Model == model && !model.PendingRecovery && !model.Session.Info().Gesture && model.PreviewCurrent(revision)
}
if !current() {
p.report(art.ErrConflict)
return
}
message := i18n.Tr("editor.art.aseprite_export_warning", "This is an unindexed interchange copy, not a replacement for your source. Aego identities and asset links are not carried into the copy. Your original and its links remain unchanged. Verify the copy in Aseprite before using it.")
for _, note := range notes {
// The identity warning above is localized and deliberately prominent.
if note.Code != "aego-identities" {
message += "\n\n" + note.Message
}
}
p.ctx.Ask(context.Question{
Title: i18n.Tr("editor.art.export_aseprite", "Export Aseprite interchange copy…"), Message: message,
Buttons: []string{i18n.Tr("editor.art.export_copy", "Export copy"), i18n.Tr("editor.art.cancel", "Cancel")},
Then: func(choice int) {
if choice != 0 {
return
}
if !current() {
p.report(art.ErrConflict)
return
}
dir, stem := artExportLocation(doc, "-export")
target := assetops.UniqueFile(p.ctx, dir, stem, ".aseprite")
p.askArtValues(i18n.Tr("editor.art.export_aseprite", "Export Aseprite interchange copy…"),
[]string{i18n.Tr("editor.art.new_export_path", "New project path (existing files are not overwritten)")}, []string{target}, func(values []string) error {
name := strings.TrimSpace(values[0])
if !strings.EqualFold(path.Ext(name), ".aseprite") {
return fmt.Errorf("%s", i18n.Tr("editor.art.aseprite_export_extension", "The interchange copy must use .aseprite."))
}
if !current() {
return art.ErrConflict
}
for _, openPath := range []string{name, name + pipeline.MetaSuffix} {
if docs.Script(openPath) != nil {
return fmt.Errorf("%s", i18n.Tr("editor.art.aseprite_export_open_target", "Close the open destination or its metadata before exporting."))
}
}
for _, open := range docs.AssetDocuments() {
if open.Path == name {
return fmt.Errorf("%s", i18n.Tr("editor.art.aseprite_export_open_target", "Close the open destination or its metadata before exporting."))
}
}
// No importer or sidecar is installed by this interchange export.
// Both a new file and absence of an orphan sidecar are guarded.
edits := []pipeline.SourceEdit{
{Path: name, After: data, Auxiliary: true},
{Path: name + pipeline.MetaSuffix, ReadOnlyGuard: true},
}
batch, err := pl.ApplySources(edits, false)
if err != nil {
return err
}
p.ctx.ApplyBatch(batch)
p.ctx.Notify(context.LevelInfo, i18n.Tr("editor.art.aseprite_exported", "Created interchange copy: {path}", i18n.Str("path", name)))
return nil
})
},
})
}
func (p *artPanel) askSaveAs() {
ed, doc, model := p.ctx, p.doc, p.model
if ed == nil || ed.Docs == nil || ed.Pipeline == nil || ed.Project == nil || doc == nil || model == nil || model.PendingRecovery || model.Session.Info().Gesture {
p.report(fmt.Errorf("art: resolve recovery and finish the current gesture before saving"))
return
}
docs, pl, project, session := ed.Docs, ed.Pipeline, ed.Project, model.Session
owner := session.Document
layer, frame, channel, selection := session.Layer, session.Frame, session.Channel, session.Selection
revision := model.PreviewRevision()
snapshot, err := owner.Snapshot()
if err != nil {
p.report(err)
return
}
encoded, err := snapshot.Encode()
if err != nil {
p.report(err)
return
}
current := func() error {
if ed.Docs != docs || ed.Pipeline != pl || ed.Project != project || docs.AssetDocument(doc.Asset) != doc || doc.Binding.Model != model || model.Session != session || session.Document != owner || model.PendingRecovery || session.Info().Gesture || session.Layer != layer || session.Frame != frame || session.Channel != channel || session.Selection != selection || !model.PreviewCurrent(revision) {
return art.ErrConflict
}
return model.CheckDraftInputs()
}
if err := current(); err != nil {
p.report(err)
return
}
dir, stem := artExportLocation(doc, "-copy")
ed.Ask(context.Question{
Title: i18n.Tr("editor.shell.save_as", "Save As…"),
Message: i18n.Tr("editor.art.save_blank_notice", "Choose a project source. Replacing an existing drawing requires confirmation."),
Fields: []string{i18n.Tr("editor.art.source_path", "Source path")},
Values: []string{assetops.UniqueFile(ed, dir, stem, art.SourceExtension)},
Buttons: []string{i18n.Tr("editor.art.save", "Save"), i18n.Tr("editor.art.cancel", "Cancel")},
DestructiveLabel: i18n.Tr("editor.art.replace", "Replace"),
DestructiveWhen: func(values []string) bool { return artSaveReplaces(ed, values) },
Submit: func(values []string) {
if len(values) != 1 {
p.report(art.ErrConflict)
return
}
if err := current(); err != nil {
p.report(err)
return
}
name, err := artSavePath(values[0])
if err != nil {
p.report(err)
return
}
if !model.Draft && name == doc.Path {
before, err := pl.SourceBytes(name)
if err != nil {
p.report(err)
return
}
metaBefore, err := pl.SourceBytes(name + pipeline.MetaSuffix)
if err == nil {
err = artCurrentSaveGuard(ed, doc, before, metaBefore)
}
if err != nil {
p.report(err)
return
}
ed.Ask(context.Question{
Title: i18n.Tr("editor.art.replace_title", "Replace existing drawing?"),
Message: i18n.Tr("editor.art.replace_notice", "Replace {path}? Its current drawing will be overwritten. The asset identity is kept, so all users of this shared asset can change.", i18n.Str("path", name)),
Buttons: []string{i18n.Tr("editor.art.replace", "Replace"), i18n.Tr("editor.art.cancel", "Cancel")},
Then: func(choice int) {
if choice != 0 {
return
}
if err := current(); err != nil {
p.report(err)
return
}
if err := artCurrentSaveGuard(ed, doc, before, metaBefore); err != nil {
p.report(err)
return
}
if doc.Binding.BeforeSave != nil {
if err := doc.Binding.BeforeSave(); err != nil {
p.report(err)
return
}
if err := current(); err != nil {
p.report(err)
return
}
if err := artCurrentSaveGuard(ed, doc, before, metaBefore); err != nil {
p.report(err)
return
}
}
// The normal save checks the original BinarySource generation,
// marks this same history saved and emits its usual import event.
p.report(model.Save())
p.view.refresh = true
},
})
return
}
target, err := prepareArtSaveTarget(ed, doc, name, snapshot)
if err != nil {
p.report(err)
return
}
target.confirm(func() {
if err := current(); err != nil {
p.report(err)
return
}
if err := target.verify(); err != nil {
p.report(err)
return
}
var batch *pipeline.Batch
err := owner.Publish(snapshot, func() error {
var err error
batch, err = pl.ApplySources(target.edits(encoded), false)
return err
})
if err == nil {
ed.ApplyBatch(batch)
// The copy or confirmed target is a separate identity; the
// original dirty document and its consumers remain open.
_, err = ed.OpenAssetPath(target.path)
}
p.report(err)
p.view.refresh = true
})
},
})
}
// Export is a new, independently editable bundle. The same pipeline transaction
// publishes PNG, frame metadata and the engine's existing .aeanim atomically.
func (p *artPanel) exportTo(target, format string) error {
if p.model.Session.Info().Gesture {
return fmt.Errorf("art: finish or cancel the gesture before export")
}
revision := p.model.PreviewRevision()
snapshot, err := p.model.Session.Document.Snapshot()
if err != nil {
return err
}
if !p.model.PreviewCurrent(revision) {
return art.ErrConflict
}
var edits []pipeline.SourceEdit
pl := p.ctx.Pipeline
switch format {
case "aeart":
if !strings.EqualFold(path.Ext(target), art.SourceExtension) {
return fmt.Errorf("art: editable copies need .aeart")
}
data, err := snapshot.Encode()
if err != nil {
return err
}
edits, err = newArtCopyEdits(pl, target, data)
if err != nil {
return err
}
case "palette":
if !strings.EqualFold(path.Ext(target), ".json") {
return fmt.Errorf("art: palettes need .json")
}
data, err := art.EncodePalette(snapshot.Info().Palette)
if err != nil {
return err
}
edits = []pipeline.SourceEdit{{Path: target, After: data, Auxiliary: true}}
case "png", "sheet":
if !strings.EqualFold(path.Ext(target), ".png") {
return fmt.Errorf("art: pixel exports need .png")
}
meta, err := pl.NewSourceMeta(target)
if err != nil {
return err
}
meta.Settings["auto_normal"], meta.Settings["auto_emission"] = aetext.Bool(false), aetext.Bool(false)
switch p.model.Session.Channel {
case art.Normal:
meta.Settings["role"], meta.Settings["colorspace"] = aetext.String("normal"), aetext.String("linear")
case art.Height:
meta.Settings["role"], meta.Settings["colorspace"] = aetext.String("height"), aetext.String("linear")
case art.Emission:
meta.Settings["role"] = aetext.String("emission-rgb")
}
var data []byte
if format == "png" {
pixels, err := snapshot.Render(p.model.Session.Frame, art.RenderOptions{Channel: p.model.Session.Channel})
if err != nil {
return err
}
var b bytes.Buffer
if err := png.Encode(&b, pixels); err != nil {
return err
}
data = b.Bytes()
} else {
sheet, err := snapshot.ExportSheet(0, p.model.Session.Channel)
if err != nil {
return err
}
data = sheet.PNG
frames := make(map[art.ID]ids.AssetID, len(sheet.Frames))
info := snapshot.Info()
for _, frame := range sheet.Frames {
name := artsource.FrameName(frame.ID, p.model.Session.Channel)
id := artsource.StableID(meta.ID, name)
frames[frame.ID] = id
sub := asset.SourceFrame{Name: name, ID: id, Rect: [4]int{frame.Rect.Min.X, frame.Rect.Min.Y, frame.Rect.Dx(), frame.Rect.Dy()}}
if pivot, ok := info.FramePivot(frame.ID); ok {
sub.Pivot, sub.HasPivot = [2]float32{float32(pivot.X) / float32(info.Canvas.Width), float32(pivot.Y) / float32(info.Canvas.Height)}, true
}
meta.Subs = append(meta.Subs, sub)
}
clip, err := artsource.ClipText(snapshot, "", frames)
if err != nil {
return err
}
base := strings.TrimSuffix(target, path.Ext(target))
clipMeta, err := pl.NewSourceMeta(base + ".aeanim")
if err != nil {
return err
}
jsonData, err := json.MarshalIndent(sheet, "", " ")
if err != nil {
return err
}
edits = append(edits, pipeline.SourceEdit{Path: base + ".aeanim", After: clip}, pipeline.SourceEdit{Path: base + ".aeanim.meta", After: pipeline.WriteMeta(clipMeta)}, pipeline.SourceEdit{Path: base + ".frames.json", After: jsonData, Auxiliary: true})
}
edits = append(edits, pipeline.SourceEdit{Path: target, After: data}, pipeline.SourceEdit{Path: target + ".meta", After: pipeline.WriteMeta(meta)})
default:
return fmt.Errorf("art: unknown export format")
}
if !p.model.PreviewCurrent(revision) {
return art.ErrConflict
}
batch, err := pl.ApplySources(edits, false)
if err != nil {
return err
}
p.ctx.ApplyBatch(batch)
p.ctx.Notify(context.LevelInfo, i18n.Tr("editor.art.exported", "Exported: {path}", i18n.Str("path", target)))
return nil
}
func (p *artPanel) timelineActions(ui *core.Context, r mathx.Rect, editable bool) {
s, v := p.model.Session, p.view
info := s.Document.Info()
p.actionMenu(ui, r, "timeline-actions", i18n.Tr("editor.art.frames_tags", "Frames / Tags…"), func(item func(string, string, bool, func())) {
item("new-tag", i18n.Tr("editor.art.new_tag", "New animation tag…"), editable, func() { p.askTagProperties(art.Tag{Name: v.tagName, Direction: art.Forward}) })
var selected art.Tag
for _, tag := range info.Tags {
if tag.ID == v.tag {
selected = tag
}
}
item("edit-tag", i18n.Tr("editor.art.edit_tag", "Edit selected tag…"), editable && selected.ID != "", func() { p.askTagProperties(selected) })
item("delete-tag", i18n.Tr("editor.art.delete_tag", "Delete selected tag"), editable && selected.ID != "", func() { p.execute(art.Operation{Kind: "delete-tag", Tag: &selected}); v.tag = "" })
item("all-frames", i18n.Tr("editor.art.all_frames", "Play all frames"), true, func() { v.tag = ""; v.playing = false; v.refresh = true })
pages := (len(info.Tags) + 7) / 8
v.tagPage = min(v.tagPage, max(0, pages-1))
for i := v.tagPage * 8; i < min(len(info.Tags), (v.tagPage+1)*8); i++ {
tag := info.Tags[i]
item(string(tag.ID), tag.Name, true, func() { v.tag, v.tagName, v.playing, v.refresh = tag.ID, tag.Name, false, true })
}
if pages > 1 {
item("tags-previous", i18n.Tr("editor.art.previous_tags", "Previous tags"), v.tagPage > 0, func() { v.tagPage-- })
item("tags-next", i18n.Tr("editor.art.next_tags", "Next tags"), v.tagPage < pages-1, func() { v.tagPage++ })
}
item("frame-back", i18n.Tr("editor.art.frame_back", "Move frame earlier"), editable, func() { p.moveFrame(-1) })
item("frame-forward", i18n.Tr("editor.art.frame_forward", "Move frame later"), editable, func() { p.moveFrame(1) })
item("delete-frame", i18n.Tr("editor.art.delete_frame", "Delete current frame"), editable && len(info.Frames) > 1, func() { p.execute(art.Operation{Kind: "delete-frame"}) })
item("pivot", i18n.Tr("editor.art.edit_pivot", "Frame pivot…"), editable, p.askPivot)
item("copy-animation", i18n.Tr("editor.art.copy_animation", "Copy animation range…"), p.docks != nil, p.askCopyAnimation)
item("paste-animation", i18n.Tr("editor.art.paste_animation", "Paste animation"), editable && p.docks != nil && p.docks.animationClipboard != nil, func() {
p.report(p.model.PasteAnimation(stdctx.Background(), p.docks.animationClipboard))
p.view.refresh = true
})
})
widgets.Tooltip(ui, ui.ID("timeline-actions"), i18n.Tr("editor.art.tag", "Tag frames"))
}
func (p *artPanel) moveFrame(offset int) {
s := p.model.Session
info := s.Document.Info()
order := make([]art.ID, len(info.Frames))
at := -1
for i, f := range info.Frames {
order[i] = f.ID
if f.ID == s.Frame {
at = i
}
}
to := at + offset
if at < 0 || to < 0 || to >= len(order) {
return
}
order[at], order[to] = order[to], order[at]
p.execute(art.Operation{Kind: "frame-order", FrameOrder: order})
}
func (p *artPanel) askTagProperties(tag art.Tag) {
frames := p.model.Session.Document.Info().Frames
from, to := 1, len(frames)
for i, f := range frames {
if f.ID == tag.From {
from = i + 1
}
if f.ID == tag.To {
to = i + 1
}
}
p.askArtValues(i18n.Tr("editor.art.tag_settings", "Animation tag"),
[]string{i18n.Tr("editor.art.layer_name", "Name"), i18n.Tr("editor.art.tag_from", "First frame (1-based)"), i18n.Tr("editor.art.tag_to", "Last frame (1-based)"), i18n.Tr("editor.art.tag_direction", "Direction: forward / reverse / ping-pong")},
[]string{tag.Name, strconv.Itoa(from), strconv.Itoa(to), string(tag.Direction)}, func(values []string) error {
a, err := strconv.Atoi(strings.TrimSpace(values[1]))
if err != nil {
return err
}
b, err := strconv.Atoi(strings.TrimSpace(values[2]))
if err != nil {
return err
}
if a < 1 || b < a || b > len(frames) {
return fmt.Errorf("art: invalid tag frame range")
}
tag.Name, tag.From, tag.To, tag.Direction = values[0], frames[a-1].ID, frames[b-1].ID, art.Direction(strings.TrimSpace(values[3]))
id, err := p.model.Execute(stdctx.Background(), art.Operation{Kind: "tag", Tag: &tag})
if err == nil {
p.view.tag, p.view.tagName, p.view.playing = id, tag.Name, false
}
return err
})
}
func (p *artPanel) askCopyAnimation() {
frames := p.model.Session.Document.Info().Frames
p.askArtValues(i18n.Tr("editor.art.copy_animation", "Copy animation range…"),
[]string{i18n.Tr("editor.art.tag_from", "First frame (1-based)"), i18n.Tr("editor.art.tag_to", "Last frame (1-based)")},
[]string{"1", strconv.Itoa(len(frames))}, func(v []string) error {
first, err := strconv.Atoi(strings.TrimSpace(v[0]))
if err != nil {
return err
}
last, err := strconv.Atoi(strings.TrimSpace(v[1]))
if err != nil {
return err
}
if first < 1 || last < first || last > len(frames) {
return fmt.Errorf("art: invalid animation frame range")
}
ids := make([]art.ID, last-first+1)
for i := range ids {
ids[i] = frames[first-1+i].ID
}
clip, err := p.model.Session.Document.CopyAnimation(ids)
if err == nil {
p.docks.animationClipboard = clip
}
return err
})
}
func (p *artPanel) askPivot() {
info := p.model.Session.Document.Info()
frame := p.model.Session.Frame
slice := art.Slice{Name: "pivot", Frame: frame, Rect: image.Rect(0, 0, info.Canvas.Width, info.Canvas.Height), Pivot: image.Pt(info.Canvas.Width/2, info.Canvas.Height/2)}
if pivot, ok := info.FramePivot(frame); ok {
slice.Pivot = pivot
}
for _, v := range info.Slices {
if v.Name == "pivot" && v.Frame == frame {
slice = v
}
}
p.askArtValues(i18n.Tr("editor.art.edit_pivot", "Frame pivot…"), []string{"X", "Y"}, []string{strconv.Itoa(slice.Pivot.X), strconv.Itoa(slice.Pivot.Y)}, func(v []string) error {
x, err := strconv.Atoi(strings.TrimSpace(v[0]))
if err != nil {
return err
}
y, err := strconv.Atoi(strings.TrimSpace(v[1]))
if err != nil {
return err
}
slice.Pivot = image.Pt(x, y)
_, err = p.model.Execute(stdctx.Background(), art.Operation{Kind: "slice", Slice: &slice})
return err
})
}