Files

496 lines
18 KiB
Go

package editor
import (
stdctx "context"
"fmt"
"slices"
"strconv"
"strings"
"aego/art"
"aego/core/mathx"
"aego/engine/i18n"
"aego/platform/input"
"aego/ui/core"
"aego/ui/widgets"
)
func artFrameIndex(frames []art.Frame, selected art.ID) int {
for index, frame := range frames {
if frame.ID == selected {
return index
}
}
return -1
}
// Navigating frames changes presentation only, never pixels or history. A
// stroke in progress still prevents selection through Session.Select.
func (p *artPanel) stepArtFrame(offset int) {
s := p.model.Session
frames := s.Document.Info().Frames
index := artFrameIndex(frames, s.Frame)
if index < 0 {
return
}
target := min(max(0, index+offset), len(frames)-1)
if target != index {
if err := s.Select("", frames[target].ID, ""); err != nil {
p.report(err)
return
}
}
p.view.playing, p.view.refresh = false, true
}
func timelineIndices(info art.Info, slot art.CellSlot) (int, int) {
li, fi := -1, -1
for i, l := range info.Layers {
if l.ID == slot.Layer {
li = i
break
}
}
for i, f := range info.Frames {
if f.ID == slot.Frame {
fi = i
break
}
}
return li, fi
}
func (p *artPanel) selectTimelineCell(slot art.CellSlot, extend, toggle bool) {
s, v := p.model.Session, p.view
info := s.Document.Info()
li, fi := timelineIndices(info, slot)
if li < 0 || fi < 0 || info.Layers[li].Kind == art.Group {
return
}
if err := s.Select(slot.Layer, slot.Frame, slot.Channel); err != nil {
p.report(err)
return
}
v.playing, v.refresh = false, true
if extend {
al, af := timelineIndices(info, v.timelineAnchor)
if al >= 0 && af >= 0 && v.timelineAnchor.Channel == slot.Channel {
v.timelineCells = nil
for l := min(al, li); l <= max(al, li); l++ {
if info.Layers[l].Kind == art.Group {
continue
}
for f := min(af, fi); f <= max(af, fi); f++ {
v.timelineCells = append(v.timelineCells, art.CellSlot{Layer: info.Layers[l].ID, Frame: info.Frames[f].ID, Channel: slot.Channel})
}
}
return
}
}
if toggle {
if index := slices.Index(v.timelineCells, slot); index >= 0 {
v.timelineCells = slices.Delete(v.timelineCells, index, index+1)
} else {
v.timelineCells = append(v.timelineCells, slot)
}
} else {
v.timelineCells = []art.CellSlot{slot}
}
v.timelineAnchor = slot
}
func (p *artPanel) timelineSelection() []art.CellSlot {
info := p.model.Session.Document.Info()
slots := make([]art.CellSlot, 0, len(p.view.timelineCells))
for _, slot := range p.view.timelineCells {
li, fi := timelineIndices(info, slot)
if li >= 0 && fi >= 0 && info.Layers[li].Kind != art.Group && slot.Channel == p.model.Session.Channel {
slots = append(slots, slot)
}
}
if len(slots) == 0 {
s := p.model.Session
slot := art.CellSlot{Layer: s.Layer, Frame: s.Frame, Channel: s.Channel}
li, fi := timelineIndices(info, slot)
if li >= 0 && fi >= 0 && info.Layers[li].Kind != art.Group {
slots = append(slots, slot)
}
}
return slots
}
func (p *artPanel) askCelTransfer(copyCells, linked bool) {
slots := p.timelineSelection()
if len(slots) == 0 {
return
}
title := i18n.Tr("editor.art.move_cels", "Move selected cels…")
if copyCells {
title = i18n.Tr("editor.art.copy_cels", "Copy selected cels…")
}
if linked {
title = i18n.Tr("editor.art.link_cels", "Link selected cels…")
}
p.askArtValues(title,
[]string{i18n.Tr("editor.art.frame_offset", "Frame offset"), i18n.Tr("editor.art.layer_offset", "Layer offset"), i18n.Tr("editor.art.overwrite_cels", "Replace occupied destination: yes / no")},
[]string{"1", "0", "no"}, func(values []string) error {
fo, err := strconv.Atoi(strings.TrimSpace(values[0]))
if err != nil {
return err
}
lo, err := strconv.Atoi(strings.TrimSpace(values[1]))
if err != nil {
return err
}
overwrite := strings.TrimSpace(values[2])
if overwrite != "yes" && overwrite != "no" {
return fmt.Errorf("art: replacement must be yes or no")
}
_, err = p.model.Execute(stdctx.Background(), art.Operation{Kind: "cel-range", Cells: slots, FrameOffset: fo, LayerOffset: lo, Copy: copyCells, Linked: linked, Overwrite: overwrite == "yes"})
if err == nil {
info := p.model.Session.Document.Info()
p.view.timelineCells = nil
for _, slot := range slots {
l, f := timelineIndices(info, slot)
p.view.timelineCells = append(p.view.timelineCells, art.CellSlot{Layer: info.Layers[l+lo].ID, Frame: info.Frames[f+fo].ID, Channel: slot.Channel})
}
if len(p.view.timelineCells) > 0 {
first := p.view.timelineCells[0]
p.view.timelineAnchor = first
err = p.model.Session.Select(first.Layer, first.Frame, first.Channel)
}
p.view.refresh = true
}
return err
})
}
func (p *artPanel) timelineGrid(ui *core.Context, area mathx.Rect, editable bool) {
p.timelineGridWithWheel(ui, area, editable, true)
}
func (p *artPanel) timelineGridWithWheel(ui *core.Context, area mathx.Rect, editable, wheel bool) {
if area.W < 60 || area.H < ui.Theme.RowHeight {
return
}
s, v := p.model.Session, p.view
info := s.Document.Info()
rowH := ui.Theme.RowHeight + ui.Theme.Spacing
labelW := min(float32(150), max(float32(86), area.W*.24))
// Always leave a usable cel column, even at the dock's minimum width.
labelW = min(labelW, max(float32(24), area.W-ui.Theme.ScrollbarW-32))
columns := min(32, max(1, int((area.W-labelW-ui.Theme.ScrollbarW)/48)))
visibleRows := max(1, min(len(info.Layers), int((area.H-rowH)/rowH)+2))
columns = min(columns, max(1, 192/visibleRows))
pages := (len(info.Frames) + columns - 1) / columns
if index := artFrameIndex(info.Frames, s.Frame); index >= 0 {
v.timelinePage = index / columns
}
v.timelineColumns = columns
v.timelinePage = min(v.timelinePage, max(0, pages-1))
frameStart := v.timelinePage * columns
count := min(columns, len(info.Frames)-frameStart)
cellW := (area.W - labelW - ui.Theme.ScrollbarW) / float32(columns)
ui.Rect(area, ui.Theme.PanelAlt)
header := mathx.Rect{X: area.X, Y: area.Y, W: labelW - 4, H: ui.Theme.RowHeight}
p.actionMenu(ui, header, "cel-actions", i18n.Tr("editor.art.cel_actions", "Cels…"), func(item func(string, string, bool, func())) {
item("move", i18n.Tr("editor.art.move_cels", "Move selected cels…"), editable, func() { p.askCelTransfer(false, false) })
item("copy", i18n.Tr("editor.art.copy_cels", "Copy selected cels…"), editable, func() { p.askCelTransfer(true, false) })
item("link", i18n.Tr("editor.art.link_cels", "Link selected cels…"), editable, func() { p.askCelTransfer(true, true) })
})
widgets.Tooltip(ui, ui.ID("cel-actions"), i18n.Tr("editor.art.cel_selection_hint", "Click a cel to select it. Shift selects a rectangle; Ctrl/Cmd toggles individual cels. Move, copy or link the selection from this menu."))
for j := 0; j < count; j++ {
f := info.Frames[frameStart+j]
r := mathx.Rect{X: area.X + labelW + float32(j)*cellW, Y: area.Y, W: cellW - 2, H: ui.Theme.RowHeight}
id := "frame-" + string(f.ID)
if artTimelineSelectionButton(ui, id, r, fmt.Sprintf("%d · %d", frameStart+j+1, f.DurationMS), s.Frame == f.ID) {
v.playing = false
p.report(s.Select("", f.ID, ""))
v.refresh = true
}
widgets.Tooltip(ui, ui.ID(id), fmt.Sprintf("%d · %d ms", frameStart+j+1, f.DurationMS))
if s.Frame == f.ID {
ui.Outline(r, ui.Theme.Accent)
}
}
grid := mathx.Rect{X: area.X, Y: area.Y + rowH, W: area.W, H: max(0, area.H-rowH)}
if wheel && ui.MouseOver(ui.ID("timeline-scroll"), grid) {
v.timelineScroll -= ui.ScrollY(rowH * 3)
}
content := float32(len(info.Layers)) * rowH
if v.timelineLayer != s.Layer {
for i, layer := range info.Layers {
if layer.ID == s.Layer {
low, high := float32(i)*rowH, float32(i+1)*rowH
if low < v.timelineScroll {
v.timelineScroll = low
}
if high > v.timelineScroll+grid.H {
v.timelineScroll = high - grid.H
}
break
}
}
v.timelineLayer = s.Layer
}
v.timelineScroll = mathx.Clamp(v.timelineScroll, 0, max(0, content-grid.H))
track := mathx.Rect{X: area.X + area.W - ui.Theme.ScrollbarW, Y: grid.Y, W: ui.Theme.ScrollbarW, H: grid.H}
widgets.ScrollBar(ui, ui.ID("timeline-scrollbar"), track, &v.timelineScroll, content, grid.H, &v.timelineBar)
ui.PushClip(grid)
defer ui.PopClip()
cels := make(map[art.CellSlot]art.Cel, len(info.Cels))
links := make(map[art.ID]int)
for _, cel := range info.Cels {
if cel.Channel == s.Channel {
cels[art.CellSlot{Layer: cel.Layer, Frame: cel.Frame, Channel: cel.Channel}] = cel
links[cel.Image]++
}
}
selected := make(map[art.CellSlot]bool)
for _, slot := range p.timelineSelection() {
selected[slot] = true
}
for i, layer := range info.Layers {
y := grid.Y + float32(i)*rowH - v.timelineScroll
if y+rowH < grid.Y || y >= grid.Y+grid.H {
continue
}
r := mathx.Rect{X: area.X, Y: y, W: labelW - 4, H: ui.Theme.RowHeight}
if artTimelineSelectionButton(ui, "timeline-layer-"+string(layer.ID), r, layer.Name, layer.ID == s.Layer) {
p.report(s.Select(layer.ID, "", ""))
v.refresh = true
}
widgets.Tooltip(ui, ui.ID("timeline-layer-"+string(layer.ID)), layer.Name)
if layer.ID == s.Layer {
ui.Outline(r, ui.Theme.Accent)
}
for j := 0; j < count; j++ {
frame := info.Frames[frameStart+j]
slot := art.CellSlot{Layer: layer.ID, Frame: frame.ID, Channel: s.Channel}
r := mathx.Rect{X: area.X + labelW + float32(j)*cellW, Y: y, W: cellW - 2, H: ui.Theme.RowHeight}
id := ui.ID("cel-" + string(layer.ID) + "-" + string(frame.ID))
background := ui.Theme.Widget
if selected[slot] {
background = ui.Theme.SelectionDim
}
ui.Box(r, ui.Theme.Radius.Field, background, ui.Theme.Border)
if layer.Kind == art.Group {
continue
}
if cel, ok := cels[slot]; ok {
size := max(0, min(r.H-4, r.W-20))
thumb := mathx.Rect{X: r.X + 2, Y: r.Y + 2, W: size, H: size}
if size > 0 {
p.thumbnail(ui, thumb, layer.ID, frame.ID)
}
tip := i18n.Tr("editor.art.independent_cel", "Independent cel")
mark := mathx.Rect{X: r.X + r.W - 11, Y: r.Y + r.H/2 - 2, W: 4, H: 4}
if links[cel.Image] > 1 {
tip = i18n.Tr("editor.art.linked_cel", "Linked cel: shared pixels")
ui.Outline(mathx.Rect{X: mark.X - 3, Y: mark.Y - 2, W: 7, H: 5}, ui.Theme.TextDim)
ui.Outline(mathx.Rect{X: mark.X + 2, Y: mark.Y + 1, W: 7, H: 5}, ui.Theme.TextDim)
} else {
ui.Rect(mark, ui.Theme.TextDim)
}
widgets.Tooltip(ui, id, tip)
}
if ui.Clicked(id, r) {
p.selectTimelineCell(slot, ui.Mods().Has(input.ModShift), ui.Primary())
}
if selected[slot] {
ui.Outline(r, ui.Theme.Accent)
}
if layer.ID == s.Layer && frame.ID == s.Frame {
ui.Outline(mathx.Rect{X: r.X + 2, Y: r.Y + 2, W: r.W - 4, H: r.H - 4}, ui.Theme.Text)
}
}
}
}
// Retain the same widget IDs and hit rectangles for the inline and docked
// timelines. A selected frame/layer remains visible in either editor theme.
func artTimelineSelectionButton(ui *core.Context, name string, r mathx.Rect, label string, selected bool) bool {
id := ui.ID(name)
clicked := ui.Clicked(id, r)
background := ui.WidgetColor(id)
if selected {
background = ui.Theme.Selection
}
ui.Box(r, ui.Theme.Radius.Button, background, ui.Theme.Border)
text := widgets.Elide(ui, label, max(0, r.W-8))
ui.PushClip(r)
ui.TextIn(r, text, ui.Theme.Text, core.AlignCenter)
ui.PopClip()
if text != label {
widgets.Tooltip(ui, id, label)
}
return clicked
}
func artTimelineTransport(ui *core.Context, name string, r mathx.Rect, icon, label, tip string, active *bool) bool {
id := ui.ID(name)
clicked := ui.Clicked(id, r)
if clicked && active != nil {
*active = !*active
}
background := ui.WidgetColor(id)
if active != nil && *active {
background = ui.Theme.Selection
}
ui.Box(r, ui.Theme.Radius.Button, background, ui.Theme.Border)
ui.PushClip(r)
if label != "" && r.W >= 76 {
iconRect := mathx.Rect{X: r.X + 4, Y: r.Y, W: r.H, H: r.H}
widgets.IconIn(ui, iconRect, icon, ui.Theme.Text)
textRect := mathx.Rect{X: iconRect.X + iconRect.W, Y: r.Y, W: max(0, r.W-iconRect.W-8), H: r.H}
ui.TextIn(textRect, widgets.Elide(ui, label, max(0, textRect.W-4)), ui.Theme.Text, core.AlignCenter)
} else {
widgets.IconIn(ui, r, icon, ui.Theme.Text)
}
ui.PopClip()
widgets.Tooltip(ui, id, tip)
return clicked
}
func (p *artPanel) timeline(ui *core.Context, r mathx.Rect, editable bool) {
ui.Region(r)
defer ui.PopCursor()
ui.PushClip(r)
defer ui.PopClip()
cur, v, s := ui.Cursor(), p.view, p.model.Session
info := s.Info()
// Keep every existing control reachable when a dock is made shorter than
// the toolbar, frame strip, details and a two-row cel grid combined.
narrow := cur.W < 140
minimum := 2*ui.Theme.Padding + 6*(ui.Theme.RowHeight+ui.Theme.Spacing) + 2*ui.Theme.Spacing
if narrow {
minimum += ui.Theme.RowHeight + ui.Theme.Spacing
}
contentHeight := max(r.H, minimum)
overflow := contentHeight > r.H
if overflow {
if ui.MouseOver(ui.ID("timeline-panel-scroll"), r) {
v.timelinePanelScroll -= ui.ScrollY(ui.Theme.RowHeight * 3)
}
v.timelinePanelScroll = mathx.Clamp(v.timelinePanelScroll, 0, contentHeight-r.H)
cur.W = max(0, cur.W-ui.Theme.ScrollbarW-ui.Theme.Spacing)
cur.Y -= v.timelinePanelScroll
} else {
v.timelinePanelScroll = 0
}
fullWidth := cur.W
toolbar, frames, controls, details := cur.Next(0), cur.Next(0), cur.Next(0), cur.Next(0)
var narrowActions mathx.Rect
if narrow {
narrowActions = cur.Next(0)
}
// The timeline can span the screen beside Assets. Keep frequent actions
// compact instead of stretching four buttons over the entire dock.
toolbar.W = min(toolbar.W, 400)
controls.W = min(controls.W, 480)
details.W = min(details.W, 480)
// Commit a pending text edit before frame controls can change selection.
// Otherwise clicking Add or another frame writes the old frame's value to
// the newly selected frame on the release event.
for _, frame := range info.Document.Frames {
if frame.ID == s.Frame {
if v.durationFrame != frame.ID || v.durationMS != frame.DurationMS {
v.durationFrame, v.durationMS = frame.ID, frame.DurationMS
v.duration = fmt.Sprint(frame.DurationMS)
}
break
}
}
durationField, tagField, tagActions := artCell(details, 0, 3), artCell(details, 1, 3), artCell(details, 2, 3)
if narrow {
durationField, tagField, tagActions = artCell(details, 0, 2), artCell(details, 1, 2), narrowActions
}
if _, commit := widgets.TextFieldRect(ui, ui.ID("duration"), durationField, &v.duration, false); commit && editable {
var ms int
if _, err := fmt.Sscan(v.duration, &ms); err != nil {
p.report(err)
} else {
p.execute(art.Operation{Kind: "duration", Frame: s.Frame, DurationMS: ms})
}
info = s.Info()
}
widgets.Tooltip(ui, ui.ID("duration"), i18n.Tr("editor.art.frame_duration_hint", "Selected frame duration in milliseconds"))
row := toolbar
playIcon, playLabel := "play.start", i18n.Tr("editor.art.play", "Play")
playTip := playLabel
if v.playing {
playIcon, playTip = "play.pause", i18n.Tr("editor.docsview.pause", "Pause")
}
if artTimelineTransport(ui, "play", artCell(row, 0, 4), playIcon, playLabel, playTip, &v.playing) {
v.playStart = ui.Time
v.refresh = true
}
onion := i18n.Tr("editor.art.onion", "Onion skin")
if artToggle(ui, "onion", artCell(row, 1, 4), onion, &v.onion) {
v.refresh = true
}
widgets.Tooltip(ui, ui.ID("onion"), onion)
if artTimelineTransport(ui, "frame-prev", artCell(row, 2, 4), "play.step_back", "", i18n.Tr("editor.art.previous_frame", "Previous frame"), nil) {
p.stepArtFrame(-1)
}
if artTimelineTransport(ui, "frame-next", artCell(row, 3, 4), "play.step", "", i18n.Tr("editor.art.next_frame", "Next frame"), nil) {
p.stepArtFrame(1)
}
if fullWidth-toolbar.W >= 100 {
status := mathx.Rect{X: toolbar.X + toolbar.W + 6, Y: toolbar.Y, W: max(0, fullWidth-toolbar.W-6), H: toolbar.H}
index := artFrameIndex(info.Document.Frames, s.Frame)
if index >= 0 {
text := fmt.Sprintf("%d / %d · %d ms", index+1, len(info.Document.Frames), info.Document.Frames[index].DurationMS)
ui.PushClip(status)
ui.TextIn(status, widgets.Elide(ui, text, status.W), ui.Theme.TextDim, core.AlignLeft)
ui.PopClip()
}
}
row = frames
perPage := max(1, int(row.W/68))
pages := (len(info.Document.Frames) + perPage - 1) / perPage
if index := artFrameIndex(info.Document.Frames, s.Frame); index >= 0 {
v.framePage = index / perPage
}
v.framePage = min(v.framePage, max(0, pages-1))
for j := 0; j < perPage; j++ {
index := v.framePage*perPage + j
if index >= len(info.Document.Frames) {
break
}
frame := info.Document.Frames[index]
cell := artCell(row, j, perPage)
label := fmt.Sprintf("%d · %d", index+1, frame.DurationMS)
if artTimelineSelectionButton(ui, string(frame.ID), cell, label, s.Frame == frame.ID) {
v.playing = false
p.report(s.Select("", frame.ID, ""))
v.refresh = true
}
widgets.Tooltip(ui, ui.ID(string(frame.ID)), fmt.Sprintf("%d · %d ms", index+1, frame.DurationMS))
if frame.ID == s.Frame {
ui.Outline(cell, ui.Theme.Accent)
}
}
row = controls
if artButton(ui, "frame-add", artCell(row, 0, 4), i18n.Tr("editor.art.add_frame", "+ Frame")) && editable {
p.execute(art.Operation{Kind: "add-frame", DurationMS: 100})
}
if artButton(ui, "frame-copy", artCell(row, 1, 4), i18n.Tr("editor.art.duplicate", "Duplicate")) && editable {
p.execute(art.Operation{Kind: "duplicate-frame"})
}
if artButton(ui, "frame-link", artCell(row, 2, 4), i18n.Tr("editor.art.link", "Link")) && editable {
p.execute(art.Operation{Kind: "duplicate-frame", Linked: true})
}
if artButton(ui, "cel-unlink", artCell(row, 3, 4), i18n.Tr("editor.art.unlink", "Unlink")) && editable {
p.execute(art.Operation{Kind: "unlink"})
}
widgets.TextFieldRect(ui, ui.ID("tag-name"), tagField, &v.tagName, false)
p.timelineActions(ui, tagActions, editable)
grid := mathx.Rect{X: cur.X, Y: cur.Y, W: cur.W, H: max(0, r.Y+contentHeight-v.timelinePanelScroll-cur.Y-ui.Theme.Padding)}
p.timelineGridWithWheel(ui, grid, editable, !overflow)
if overflow {
track := mathx.Rect{X: r.X + r.W - ui.Theme.ScrollbarW, Y: r.Y, W: ui.Theme.ScrollbarW, H: r.H}
widgets.ScrollBar(ui, ui.ID("timeline-panel-scrollbar"), track, &v.timelinePanelScroll, contentHeight, r.H, &v.timelinePanelBar)
}
}