559 lines
29 KiB
C++
559 lines
29 KiB
C++
#include "editor/ActionTimelineEditor.h"
|
|
|
|
#include <algorithm>
|
|
#include <utility>
|
|
|
|
namespace eve::editor {
|
|
namespace {
|
|
|
|
constexpr std::string_view kReplaceOperation = "action.timeline.replace";
|
|
|
|
EditorResult<void> editorError(EditorStatus status, std::string rule, std::string message) {
|
|
return EditorResult<void>::error(status, RuleId(std::move(rule)), std::move(message));
|
|
}
|
|
|
|
std::string commonMessage(const Status& status, std::string fallback) {
|
|
if (!status.diagnostics().empty() && !status.diagnostics().front().message().empty())
|
|
return status.diagnostics().front().message();
|
|
return fallback;
|
|
}
|
|
|
|
action::ActionTrack* findTrack(action::ActionTimeline& timeline, const LogicalId& id) {
|
|
auto found = std::find_if(timeline.tracks.begin(), timeline.tracks.end(),
|
|
[&](const action::ActionTrack& track) { return track.id == id; });
|
|
return found == timeline.tracks.end() ? nullptr : &*found;
|
|
}
|
|
|
|
const action::ActionTrack* findTrack(const action::ActionTimeline& timeline, const LogicalId& id) {
|
|
auto found = std::find_if(timeline.tracks.begin(), timeline.tracks.end(),
|
|
[&](const action::ActionTrack& track) { return track.id == id; });
|
|
return found == timeline.tracks.end() ? nullptr : &*found;
|
|
}
|
|
|
|
void sortTrack(action::ActionTrack& track) {
|
|
std::stable_sort(track.notifies.begin(), track.notifies.end(), [](const auto& left, const auto& right) {
|
|
return left.time == right.time ? left.id.format() < right.id.format() : left.time < right.time;
|
|
});
|
|
std::stable_sort(track.states.begin(), track.states.end(), [](const auto& left, const auto& right) {
|
|
return left.start == right.start ? left.id.format() < right.id.format() : left.start < right.start;
|
|
});
|
|
}
|
|
|
|
EditorValue replacementPayload(const std::string& json) {
|
|
EditorValue::Object payload;
|
|
payload["json"] = json;
|
|
return EditorValue(std::move(payload));
|
|
}
|
|
|
|
const std::string* jsonPayload(const EditorValue& value) {
|
|
const auto* object = value.getIf<EditorValue::Object>();
|
|
if (!object) return nullptr;
|
|
const auto found = object->find("json");
|
|
return found == object->end() ? nullptr : found->second.getIf<std::string>();
|
|
}
|
|
|
|
} // namespace
|
|
|
|
ActionTimelineTarget::ActionTimelineTarget(std::string targetId, action::ActionTimeline timeline)
|
|
: targetId_(std::move(targetId)), timeline_(std::move(timeline)) {}
|
|
|
|
EditorResult<void> ActionTimelineTarget::applyDomainOperation(const DomainOperation& operation) {
|
|
if (operation.target.value() != targetId_)
|
|
return editorError(EditorStatus::Rejected, "editor.action.timeline.target-mismatch",
|
|
"Action timeline operation targets another document");
|
|
if (operation.type != kReplaceOperation)
|
|
return editorError(EditorStatus::Unsupported, "editor.action.timeline.operation-unsupported",
|
|
"Action timeline target only accepts canonical replace operations");
|
|
const std::string* json = jsonPayload(operation.payload);
|
|
if (!json)
|
|
return editorError(EditorStatus::Rejected, "editor.action.timeline.payload-invalid",
|
|
"Action timeline replacement requires a JSON string");
|
|
auto value = Value::fromJson(*json);
|
|
if (!value)
|
|
return editorError(EditorStatus::Rejected, "editor.action.timeline.json-invalid",
|
|
commonMessage(value.status(), "Action timeline JSON is invalid"));
|
|
auto decoded = action::ActionTimeline::fromValue(value.value());
|
|
if (!decoded)
|
|
return editorError(EditorStatus::Rejected, "editor.action.timeline.asset-invalid",
|
|
commonMessage(decoded.status(), "Action timeline asset is invalid"));
|
|
timeline_ = std::move(decoded).takeValue();
|
|
++revision_;
|
|
return EditorResult<void>::applied();
|
|
}
|
|
|
|
std::unique_ptr<IDomainOperationTarget> ActionTimelineTarget::cloneDomainState() const {
|
|
auto clone = std::make_unique<ActionTimelineTarget>(targetId_, timeline_);
|
|
clone->revision_ = revision_;
|
|
return clone;
|
|
}
|
|
|
|
EditorResult<void> ActionTimelineTarget::commitDomainState(std::unique_ptr<IDomainOperationTarget> candidate) {
|
|
auto* typed = dynamic_cast<ActionTimelineTarget*>(candidate.get());
|
|
if (!typed || typed->targetId_ != targetId_)
|
|
return editorError(EditorStatus::Conflict, "editor.action.timeline.candidate-mismatch",
|
|
"Action timeline candidate belongs to another target");
|
|
auto valid = typed->timeline_.validate();
|
|
if (!valid)
|
|
return editorError(EditorStatus::Rejected, "editor.action.timeline.candidate-invalid",
|
|
commonMessage(valid.status(), "Action timeline candidate is invalid"));
|
|
timeline_ = std::move(typed->timeline_);
|
|
++revision_;
|
|
return EditorResult<void>::applied();
|
|
}
|
|
|
|
ActionTimelineEditor::ActionTimelineEditor(std::string targetId, action::ActionTimeline timeline)
|
|
: target_(std::move(targetId), std::move(timeline)), authority_(&target_), transactions_(&authority_) {}
|
|
|
|
EditorResult<void> ActionTimelineEditor::rejected(std::string rule, std::string message) {
|
|
return editorError(EditorStatus::Rejected, std::move(rule), std::move(message));
|
|
}
|
|
|
|
EditorResult<void> ActionTimelineEditor::configureWorkspace(EditorWorkspace& workspace) const {
|
|
EditorWorkspace candidate = workspace;
|
|
struct Panel {
|
|
const char* id;
|
|
const char* title;
|
|
const char* region;
|
|
int order;
|
|
};
|
|
constexpr Panel panels[] = {
|
|
{"action.assets", "Actions", "left", 100},
|
|
{"action.preview", "Action Preview", "center", 100},
|
|
{"action.inspector", "Action Inspector", "right", 100},
|
|
{"action.timeline", "Action Timeline", "bottom", 100},
|
|
};
|
|
for (const auto& panel : panels) {
|
|
if (!candidate.registerPanel(panel.id, panel.title, panel.region, panel.order) ||
|
|
!candidate.setPanelCapability(panel.id, "action.timeline") ||
|
|
!candidate.setPanelContext(panel.id, "timeline"))
|
|
return rejected("editor.action.timeline.workspace-conflict",
|
|
"Could not install the action editor workspace composition");
|
|
}
|
|
if (!candidate.activatePanel("action.timeline"))
|
|
return rejected("editor.action.timeline.workspace-activate-failed",
|
|
"Could not activate the action timeline panel");
|
|
workspace = std::move(candidate);
|
|
return EditorResult<void>::applied();
|
|
}
|
|
|
|
EditorResult<void> ActionTimelineEditor::commit(action::ActionTimeline candidate, std::string label,
|
|
std::string mergeKey) {
|
|
auto valid = candidate.validate();
|
|
if (!valid)
|
|
return rejected("editor.action.timeline.candidate-invalid",
|
|
commonMessage(valid.status(), "Action timeline edit is invalid"));
|
|
auto beforeValue = target_.timeline().toValue();
|
|
auto afterValue = candidate.toValue();
|
|
if (!beforeValue || !afterValue)
|
|
return rejected("editor.action.timeline.encode-failed", "Could not encode the action timeline edit");
|
|
auto beforeJson = beforeValue.value().toJson();
|
|
auto afterJson = afterValue.value().toJson();
|
|
if (!beforeJson || !afterJson)
|
|
return rejected("editor.action.timeline.encode-failed", "Could not serialize the action timeline edit");
|
|
if (beforeJson.value() == afterJson.value()) {
|
|
EditorResult<void> result;
|
|
result.status = EditorStatus::NoOp;
|
|
return result;
|
|
}
|
|
|
|
const std::string operationMergeKey = mergeKey;
|
|
TransactionSpec transaction;
|
|
transaction.id = TransactionId("action.timeline.tx." + std::to_string(++transactionSequence_));
|
|
transaction.label = std::move(label);
|
|
transaction.target = TargetId(target_.targetId());
|
|
transaction.baseRevision = target_.revision();
|
|
transaction.mergeKey = std::move(mergeKey);
|
|
auto begun = transactions_.begin(std::move(transaction));
|
|
if (!begun.accepted())
|
|
return editorError(begun.status, "editor.action.timeline.begin-failed", "Could not begin action edit");
|
|
|
|
DomainOperation operation;
|
|
operation.type = std::string(kReplaceOperation);
|
|
operation.inverseType = std::string(kReplaceOperation);
|
|
operation.target = TargetId(target_.targetId());
|
|
operation.payload = replacementPayload(afterJson.value());
|
|
operation.inverse = replacementPayload(beforeJson.value());
|
|
operation.hasInverse = true;
|
|
operation.affectedObjects.push_back({operation.target, target_.timeline().actionId.format(), target_.revision()});
|
|
operation.affectedProperties.push_back("timeline");
|
|
operation.mergeKey = operationMergeKey;
|
|
auto appended = transactions_.append(std::move(operation));
|
|
if (!appended.accepted()) {
|
|
auto discarded = transactions_.discard();
|
|
(void)discarded;
|
|
return appended;
|
|
}
|
|
auto committed = transactions_.commit();
|
|
if (!committed.accepted())
|
|
return editorError(committed.status, "editor.action.timeline.commit-failed", "Action edit was rejected");
|
|
return EditorResult<void>::applied();
|
|
}
|
|
|
|
EditorResult<void> ActionTimelineEditor::addTrack(action::ActionTrack track) {
|
|
action::ActionTimeline candidate = target_.timeline();
|
|
candidate.tracks.push_back(std::move(track));
|
|
return commit(std::move(candidate), "Add action track", "action.timeline.track.add");
|
|
}
|
|
|
|
EditorResult<void> ActionTimelineEditor::addNotify(const LogicalId& trackId, action::ActionNotify notify) {
|
|
action::ActionTimeline candidate = target_.timeline();
|
|
auto* track = findTrack(candidate, trackId);
|
|
if (!track) return rejected("editor.action.timeline.track-not-found", "Action track was not found");
|
|
if (track->locked) return rejected("editor.action.timeline.track-locked", "Action track is locked");
|
|
track->notifies.push_back(std::move(notify));
|
|
sortTrack(*track);
|
|
return commit(std::move(candidate), "Add action notify", "action.timeline.notify.add");
|
|
}
|
|
|
|
EditorResult<void> ActionTimelineEditor::addState(const LogicalId& trackId, action::ActionNotifyState state) {
|
|
action::ActionTimeline candidate = target_.timeline();
|
|
auto* track = findTrack(candidate, trackId);
|
|
if (!track) return rejected("editor.action.timeline.track-not-found", "Action track was not found");
|
|
if (track->locked) return rejected("editor.action.timeline.track-locked", "Action track is locked");
|
|
track->states.push_back(std::move(state));
|
|
sortTrack(*track);
|
|
return commit(std::move(candidate), "Add action notify state", "action.timeline.state.add");
|
|
}
|
|
|
|
EditorResult<void> ActionTimelineEditor::moveItem(const LogicalId& itemId, Duration delta) {
|
|
action::ActionTimeline candidate = target_.timeline();
|
|
for (auto& track : candidate.tracks) {
|
|
for (auto& notify : track.notifies) {
|
|
if (notify.id != itemId) continue;
|
|
if (track.locked) return rejected("editor.action.timeline.track-locked", "Action track is locked");
|
|
auto moved = notify.time.tryAdd(delta);
|
|
if (!moved) return rejected("editor.action.timeline.time-overflow", "Notify time overflowed");
|
|
notify.time = std::move(moved).takeValue();
|
|
sortTrack(track);
|
|
return commit(std::move(candidate), "Move action notify", "action.timeline.item.move");
|
|
}
|
|
for (auto& state : track.states) {
|
|
if (state.id != itemId) continue;
|
|
if (track.locked) return rejected("editor.action.timeline.track-locked", "Action track is locked");
|
|
auto start = state.start.tryAdd(delta);
|
|
auto end = state.end.tryAdd(delta);
|
|
if (!start || !end) return rejected("editor.action.timeline.time-overflow", "Notify-state time overflowed");
|
|
state.start = std::move(start).takeValue();
|
|
state.end = std::move(end).takeValue();
|
|
sortTrack(track);
|
|
return commit(std::move(candidate), "Move action notify state", "action.timeline.item.move");
|
|
}
|
|
}
|
|
return rejected("editor.action.timeline.item-not-found", "Timeline item was not found");
|
|
}
|
|
|
|
EditorResult<void> ActionTimelineEditor::resizeState(const LogicalId& itemId, Duration start, Duration end) {
|
|
if (start < Duration::zero() || end < start || end > target_.timeline().duration)
|
|
return rejected("editor.action.timeline.state-range", "Notify-state range is outside the timeline");
|
|
action::ActionTimeline candidate = target_.timeline();
|
|
for (auto& track : candidate.tracks) {
|
|
auto found = std::find_if(track.states.begin(), track.states.end(),
|
|
[&](const auto& value) { return value.id == itemId; });
|
|
if (found == track.states.end()) continue;
|
|
if (track.locked) return rejected("editor.action.timeline.track-locked", "Action track is locked");
|
|
found->start = start;
|
|
found->end = end;
|
|
sortTrack(track);
|
|
return commit(std::move(candidate), "Resize action notify state", "action.timeline.state.resize");
|
|
}
|
|
return rejected("editor.action.timeline.state-not-found", "Notify state was not found");
|
|
}
|
|
|
|
EditorResult<void> ActionTimelineEditor::updateItem(const LogicalId& itemId, LogicalId type, Value::Object payload) {
|
|
if (!type.isValid()) return rejected("editor.action.timeline.type-invalid", "Timeline item type is invalid");
|
|
action::ActionTimeline candidate = target_.timeline();
|
|
for (auto& track : candidate.tracks) {
|
|
for (auto& notify : track.notifies) {
|
|
if (notify.id != itemId) continue;
|
|
if (track.locked) return rejected("editor.action.timeline.track-locked", "Action track is locked");
|
|
notify.type = std::move(type);
|
|
notify.payload = std::move(payload);
|
|
return commit(std::move(candidate), "Edit action notify", "action.timeline.item.properties");
|
|
}
|
|
for (auto& state : track.states) {
|
|
if (state.id != itemId) continue;
|
|
if (track.locked) return rejected("editor.action.timeline.track-locked", "Action track is locked");
|
|
state.type = std::move(type);
|
|
state.payload = std::move(payload);
|
|
return commit(std::move(candidate), "Edit action notify state", "action.timeline.item.properties");
|
|
}
|
|
}
|
|
return rejected("editor.action.timeline.item-not-found", "Timeline item was not found");
|
|
}
|
|
|
|
EditorResult<void> ActionTimelineEditor::editItem(const LogicalId& itemId, Duration start, Duration end, LogicalId type,
|
|
Value::Object payload) {
|
|
if (!type.isValid()) return rejected("editor.action.timeline.type-invalid", "Timeline item type is invalid");
|
|
if (start < Duration::zero() || end < start || end > target_.timeline().duration)
|
|
return rejected("editor.action.timeline.item-range", "Timeline item range is outside the timeline");
|
|
action::ActionTimeline candidate = target_.timeline();
|
|
for (auto& track : candidate.tracks) {
|
|
for (auto& notify : track.notifies) {
|
|
if (notify.id != itemId) continue;
|
|
if (track.locked) return rejected("editor.action.timeline.track-locked", "Action track is locked");
|
|
if (start != end)
|
|
return rejected("editor.action.timeline.notify-range", "Instant notify start and end must match");
|
|
notify.time = start;
|
|
notify.type = std::move(type);
|
|
notify.payload = std::move(payload);
|
|
sortTrack(track);
|
|
return commit(std::move(candidate), "Edit action notify", "action.timeline.item.edit");
|
|
}
|
|
for (auto& state : track.states) {
|
|
if (state.id != itemId) continue;
|
|
if (track.locked) return rejected("editor.action.timeline.track-locked", "Action track is locked");
|
|
state.start = start;
|
|
state.end = end;
|
|
state.type = std::move(type);
|
|
state.payload = std::move(payload);
|
|
sortTrack(track);
|
|
return commit(std::move(candidate), "Edit action notify state", "action.timeline.item.edit");
|
|
}
|
|
}
|
|
return rejected("editor.action.timeline.item-not-found", "Timeline item was not found");
|
|
}
|
|
|
|
EditorResult<void> ActionTimelineEditor::removeItem(const LogicalId& itemId) {
|
|
action::ActionTimeline candidate = target_.timeline();
|
|
for (auto& track : candidate.tracks) {
|
|
const auto notify = std::find_if(track.notifies.begin(), track.notifies.end(),
|
|
[&](const auto& value) { return value.id == itemId; });
|
|
if (notify != track.notifies.end()) {
|
|
if (track.locked) return rejected("editor.action.timeline.track-locked", "Action track is locked");
|
|
track.notifies.erase(notify);
|
|
selection_.erase(itemId.format());
|
|
return commit(std::move(candidate), "Delete action notify", "action.timeline.item.delete");
|
|
}
|
|
const auto state = std::find_if(track.states.begin(), track.states.end(),
|
|
[&](const auto& value) { return value.id == itemId; });
|
|
if (state != track.states.end()) {
|
|
if (track.locked) return rejected("editor.action.timeline.track-locked", "Action track is locked");
|
|
track.states.erase(state);
|
|
selection_.erase(itemId.format());
|
|
return commit(std::move(candidate), "Delete action notify state", "action.timeline.item.delete");
|
|
}
|
|
}
|
|
return rejected("editor.action.timeline.item-not-found", "Timeline item was not found");
|
|
}
|
|
|
|
EditorResult<void> ActionTimelineEditor::setTrackMuted(const LogicalId& trackId, bool muted) {
|
|
action::ActionTimeline candidate = target_.timeline();
|
|
auto* track = findTrack(candidate, trackId);
|
|
if (!track) return rejected("editor.action.timeline.track-not-found", "Action track was not found");
|
|
track->muted = muted;
|
|
return commit(std::move(candidate), muted ? "Mute action track" : "Unmute action track",
|
|
"action.timeline.track.mute");
|
|
}
|
|
|
|
EditorResult<std::size_t> ActionTimelineEditor::boxSelect(Duration start, Duration end) {
|
|
if (start < Duration::zero() || end < start || end > target_.timeline().duration)
|
|
return EditorResult<std::size_t>::error(EditorStatus::Rejected,
|
|
RuleId("editor.action.timeline.selection-range"),
|
|
"Selection range is outside the timeline");
|
|
selection_.clear();
|
|
for (const auto& track : target_.timeline().tracks) {
|
|
for (const auto& notify : track.notifies)
|
|
if (notify.time >= start && notify.time <= end) selection_.insert(notify.id.format());
|
|
for (const auto& state : track.states)
|
|
if (state.end >= start && state.start <= end) selection_.insert(state.id.format());
|
|
}
|
|
return EditorResult<std::size_t>::applied(selection_.size());
|
|
}
|
|
|
|
EditorResult<void> ActionTimelineEditor::selectItem(const LogicalId& itemId, bool additive) {
|
|
bool found = false;
|
|
for (const auto& track : target_.timeline().tracks) {
|
|
found = found || std::any_of(track.notifies.begin(), track.notifies.end(),
|
|
[&](const auto& item) { return item.id == itemId; });
|
|
found = found || std::any_of(track.states.begin(), track.states.end(),
|
|
[&](const auto& item) { return item.id == itemId; });
|
|
}
|
|
if (!found) return rejected("editor.action.timeline.item-not-found", "Timeline item was not found");
|
|
if (!additive) selection_.clear();
|
|
selection_.insert(itemId.format());
|
|
return EditorResult<void>::applied();
|
|
}
|
|
|
|
std::vector<LogicalId> ActionTimelineEditor::selectedItemIds() const {
|
|
std::vector<LogicalId> result;
|
|
result.reserve(selection_.size());
|
|
for (const auto& value : selection_) {
|
|
auto parsed = LogicalId::parse(value);
|
|
if (parsed) result.push_back(std::move(*parsed));
|
|
}
|
|
return result;
|
|
}
|
|
|
|
EditorResult<std::size_t> ActionTimelineEditor::copySelection() {
|
|
clipboard_.clear();
|
|
for (const auto& track : target_.timeline().tracks) {
|
|
for (const auto& notify : track.notifies)
|
|
if (selection_.contains(notify.id.format())) clipboard_.push_back({track.id, false, notify, {}});
|
|
for (const auto& state : track.states)
|
|
if (selection_.contains(state.id.format())) clipboard_.push_back({track.id, true, {}, state});
|
|
}
|
|
return EditorResult<std::size_t>::applied(clipboard_.size());
|
|
}
|
|
|
|
LogicalId ActionTimelineEditor::copiedId(const LogicalId& source) {
|
|
const std::string name = std::string(source.name()) + ".copy." + std::to_string(++copySequence_);
|
|
auto id = LogicalId::fromParts(source.namespaceName(), name);
|
|
return id ? std::move(*id) : LogicalId{};
|
|
}
|
|
|
|
EditorResult<std::size_t> ActionTimelineEditor::paste(Duration offset) {
|
|
if (clipboard_.empty())
|
|
return EditorResult<std::size_t>::error(EditorStatus::Rejected,
|
|
RuleId("editor.action.timeline.clipboard-empty"),
|
|
"Action timeline clipboard is empty");
|
|
action::ActionTimeline candidate = target_.timeline();
|
|
std::set<std::string> pastedSelection;
|
|
for (const auto& item : clipboard_) {
|
|
auto* track = findTrack(candidate, item.trackId);
|
|
if (!track)
|
|
return EditorResult<std::size_t>::error(EditorStatus::Conflict,
|
|
RuleId("editor.action.timeline.track-not-found"),
|
|
"Clipboard track no longer exists");
|
|
if (track->locked)
|
|
return EditorResult<std::size_t>::error(
|
|
EditorStatus::Rejected, RuleId("editor.action.timeline.track-locked"), "Clipboard track is locked");
|
|
if (item.state) {
|
|
auto copy = item.notifyState;
|
|
copy.id = copiedId(copy.id);
|
|
auto start = copy.start.tryAdd(offset);
|
|
auto end = copy.end.tryAdd(offset);
|
|
if (!start || !end)
|
|
return EditorResult<std::size_t>::error(EditorStatus::Rejected,
|
|
RuleId("editor.action.timeline.time-overflow"),
|
|
"Pasted notify-state time overflowed");
|
|
copy.start = std::move(start).takeValue();
|
|
copy.end = std::move(end).takeValue();
|
|
pastedSelection.insert(copy.id.format());
|
|
track->states.push_back(std::move(copy));
|
|
} else {
|
|
auto copy = item.notify;
|
|
copy.id = copiedId(copy.id);
|
|
auto time = copy.time.tryAdd(offset);
|
|
if (!time)
|
|
return EditorResult<std::size_t>::error(EditorStatus::Rejected,
|
|
RuleId("editor.action.timeline.time-overflow"),
|
|
"Pasted notify time overflowed");
|
|
copy.time = std::move(time).takeValue();
|
|
pastedSelection.insert(copy.id.format());
|
|
track->notifies.push_back(std::move(copy));
|
|
}
|
|
sortTrack(*track);
|
|
}
|
|
const std::size_t count = pastedSelection.size();
|
|
auto committed = commit(std::move(candidate), "Paste action timeline items", "action.timeline.item.paste");
|
|
if (!committed.accepted())
|
|
return EditorResult<std::size_t>::error(committed.status, RuleId("editor.action.timeline.paste-failed"),
|
|
"Could not paste action timeline items");
|
|
selection_ = std::move(pastedSelection);
|
|
return EditorResult<std::size_t>::applied(count);
|
|
}
|
|
|
|
EditorResult<void> ActionTimelineEditor::deleteSelection() {
|
|
if (selection_.empty()) return rejected("editor.action.timeline.selection-empty", "No timeline items are selected");
|
|
action::ActionTimeline candidate = target_.timeline();
|
|
for (auto& track : candidate.tracks) {
|
|
const bool touchesLocked =
|
|
track.locked && (std::any_of(track.notifies.begin(), track.notifies.end(),
|
|
[&](const auto& item) { return selection_.contains(item.id.format()); }) ||
|
|
std::any_of(track.states.begin(), track.states.end(),
|
|
[&](const auto& item) { return selection_.contains(item.id.format()); }));
|
|
if (touchesLocked)
|
|
return rejected("editor.action.timeline.track-locked", "Selection contains an item on a locked track");
|
|
std::erase_if(track.notifies, [&](const auto& item) { return selection_.contains(item.id.format()); });
|
|
std::erase_if(track.states, [&](const auto& item) { return selection_.contains(item.id.format()); });
|
|
}
|
|
auto result = commit(std::move(candidate), "Delete action timeline selection", "action.timeline.item.delete");
|
|
if (result.accepted()) selection_.clear();
|
|
return result;
|
|
}
|
|
|
|
EditorResult<TransactionReceipt> ActionTimelineEditor::undo() { return transactions_.undo(); }
|
|
|
|
EditorResult<TransactionReceipt> ActionTimelineEditor::redo() { return transactions_.redo(); }
|
|
|
|
EditorResult<void> ActionTimelineEditor::seek(Duration time) {
|
|
if (time < Duration::zero() || time > target_.timeline().duration)
|
|
return rejected("editor.action.timeline.seek-range", "Preview seek is outside the timeline");
|
|
previewTime_ = time;
|
|
previewStarted_ = false;
|
|
previewEvents_.clear();
|
|
return EditorResult<void>::applied();
|
|
}
|
|
|
|
EditorResult<std::size_t> ActionTimelineEditor::update(Duration delta) {
|
|
auto plan = planPreview(delta);
|
|
if (!plan.accepted())
|
|
return EditorResult<std::size_t>::error(plan.status, RuleId("editor.action.timeline.preview-plan"),
|
|
"Could not prepare preview advance");
|
|
if (plan.status == EditorStatus::NoOp) {
|
|
previewEvents_.clear();
|
|
EditorResult<std::size_t> result;
|
|
result.status = EditorStatus::NoOp;
|
|
result.value = 0;
|
|
return result;
|
|
}
|
|
return applyPreviewPlan(std::move(*plan.value));
|
|
}
|
|
|
|
EditorResult<ActionTimelinePreviewPlan> ActionTimelineEditor::planPreview(Duration delta) const {
|
|
if (!playing_) {
|
|
ActionTimelinePreviewPlan plan;
|
|
plan.timelineRevision = target_.revision();
|
|
plan.previous = previewTime_;
|
|
plan.current = previewTime_;
|
|
EditorResult<ActionTimelinePreviewPlan> result;
|
|
result.status = EditorStatus::NoOp;
|
|
result.value = std::move(plan);
|
|
return result;
|
|
}
|
|
if (delta < Duration::zero())
|
|
return EditorResult<ActionTimelinePreviewPlan>::error(EditorStatus::Rejected,
|
|
RuleId("editor.action.timeline.preview-delta"),
|
|
"Preview delta must be non-negative");
|
|
auto next = previewTime_.tryAdd(delta);
|
|
if (!next)
|
|
return EditorResult<ActionTimelinePreviewPlan>::error(
|
|
EditorStatus::Rejected, RuleId("editor.action.timeline.preview-overflow"), "Preview time overflowed");
|
|
Duration current = std::move(next).takeValue();
|
|
if (current > target_.timeline().duration) current = target_.timeline().duration;
|
|
auto sampled = target_.timeline().sample(previewTime_, current, !previewStarted_);
|
|
if (!sampled)
|
|
return EditorResult<ActionTimelinePreviewPlan>::error(
|
|
EditorStatus::Rejected, RuleId("editor.action.timeline.preview-invalid"),
|
|
commonMessage(sampled.status(), "Could not sample action timeline"));
|
|
ActionTimelinePreviewPlan plan;
|
|
plan.timelineRevision = target_.revision();
|
|
plan.previous = previewTime_;
|
|
plan.current = current;
|
|
plan.includeStart = !previewStarted_;
|
|
plan.reachesEnd = current == target_.timeline().duration;
|
|
plan.events = std::move(sampled).takeValue();
|
|
return EditorResult<ActionTimelinePreviewPlan>::applied(std::move(plan));
|
|
}
|
|
|
|
EditorResult<std::size_t> ActionTimelineEditor::applyPreviewPlan(ActionTimelinePreviewPlan plan) {
|
|
if (!playing_ || plan.timelineRevision != target_.revision() || plan.previous != previewTime_ ||
|
|
plan.includeStart == previewStarted_)
|
|
return EditorResult<std::size_t>::error(EditorStatus::Conflict,
|
|
RuleId("editor.action.timeline.preview-plan-stale"),
|
|
"Prepared preview advance no longer matches transport state");
|
|
auto expected = target_.timeline().sample(plan.previous, plan.current, plan.includeStart);
|
|
if (!expected || expected.value() != plan.events ||
|
|
plan.reachesEnd != (plan.current == target_.timeline().duration))
|
|
return EditorResult<std::size_t>::error(EditorStatus::Rejected,
|
|
RuleId("editor.action.timeline.preview-plan-invalid"),
|
|
"Prepared preview advance does not match the authoritative timeline");
|
|
previewEvents_ = std::move(plan.events);
|
|
previewTime_ = plan.current;
|
|
previewStarted_ = true;
|
|
if (plan.reachesEnd) playing_ = false;
|
|
return EditorResult<std::size_t>::applied(previewEvents_.size());
|
|
}
|
|
|
|
} // namespace eve::editor
|