using System;
using RosettaUI.Builder;
using UnityEngine;
using UnityEngine.UIElements;
namespace RosettaUI.UIToolkit.AnimationCurveEditor
{
///
/// Control point of the animation curve editor.
///
/// ControlPointはAnimationCurveのKeyframeと1対1対応するが、直接Keyframeを編集はしない
/// Keyframeの値を変えると隣り合ったKeyframeの値も変更する必要があることがある(インデックスが変わったり、TangentモードがLinearだとTangentが変わるなど)ので、
/// ControlPoint単体でKeyframeを編集せずにAnimationCurve全体での整合性を取るようControlPointHolderなどに変更を依頼する
///
public class ControlPoint : VisualElement
{
private const string ContainerClassName = "rosettaui-animation-curve-editor__control-point-container";
private const string ControlPointClassName = "rosettaui-animation-curve-editor__control-point";
private const string ActiveControlPointClassName = "rosettaui-animation-curve-editor__control-point--active";
// Weightの固定値は1/3の模様
// https://github.com/Unity-Technologies/UnityCsReference/blob/4b463aa72c78ec7490b7f03176bd012399881768/Editor/Mono/Animation/AnimationWindow/CurveMenuManager.cs#L193
// Ferguson/Coons曲線をベジエ曲線にするときの係数
// https://olj611.hatenablog.com/entry/2023/12/01/150842
private const float WeightDefaultValue = 1 / 3f;
private readonly CurveController _curveController;
private readonly PreviewTransform _previewTransform;
private readonly VisualElement _controlPointCore;
private readonly ControlPointHandle _leftHandle;
private readonly ControlPointHandle _rightHandle;
private readonly WrapModeButton _wrapModeButton;
private readonly Action _showPopupMenu;
private Vector2 _pointerDownPositionToElementOffset;
private Vector2 _keyframePositionOnPointerDown;
/// Needs to call CurveController.OnCurveChanged() after this to reflect the change in the view
public bool IsKeyBroken { get; set; }
/// Needs to call CurveController.OnCurveChanged() after this to reflect the change in the view
public TangentMode InTangentMode { get; set; } = TangentMode.Free;
/// Needs to call CurveController.OnCurveChanged() after this to reflect the change in the view
public TangentMode OutTangentMode { get; set; } = TangentMode.Free;
public bool IsActive
{
get => _controlPointCore.ClassListContains(ActiveControlPointClassName);
set
{
_controlPointCore.EnableInClassList(ActiveControlPointClassName, value);
UpdateHandleView();
}
}
public Keyframe Keyframe => _curveController.GetKeyframe(this);
public Vector2 KeyframePosition => Keyframe.GetPosition();
///
/// UIToolkitのLocalPosition
/// get時、resolveStyleに反映されるのはレイアウト計算後なのでstyle.left/topを直接参照する
///
public Vector2 LocalPosition
{
get => new(style.left.value.value, style.top.value.value);
private set
{
style.left = value.x;
style.top = value.y;
}
}
private ControlPoint Left => _curveController.GetControlPointLeft(this);
private ControlPoint Right => _curveController.GetControlPointRight(this);
public ControlPoint(CurveController curveController, PreviewTransform previewTransform,
Action showPopupMenu)
{
_curveController = curveController;
_previewTransform = previewTransform;
_showPopupMenu = showPopupMenu;
// Control point container
AddToClassList(ContainerClassName);
RegisterCallback(OnPointerDown);
// Handles
_leftHandle = new ControlPointHandle(InOrOut.In,
_previewTransform,
(tangent, weight) => OnTangentAndWeightChanged(InOrOut.In, tangent, weight)
);
Add(_leftHandle);
_rightHandle = new ControlPointHandle(InOrOut.Out,
_previewTransform,
(tangent, weight) => OnTangentAndWeightChanged(InOrOut.Out, tangent, weight)
);
Add(_rightHandle);
// Control point
_controlPointCore = new VisualElement { name = "AnimationCurveEditorControlPoint" };
_controlPointCore.AddToClassList(ControlPointClassName);
Add(_controlPointCore);
// Clamp mode button
_wrapModeButton = new WrapModeButton();
_wrapModeButton.RegisterCallback(_ => OnWrapModeButtonClicked());
Add(_wrapModeButton);
// hide handles
UpdateHandleView();
return;
void OnTangentAndWeightChanged(InOrOut inOrOut, float tangent, float weight)
{
var keyframe = Keyframe;
keyframe.SetTangent(inOrOut, tangent);
if (!IsKeyBroken)
{
var oppositeTangent = float.IsInfinity(tangent) ? -tangent : tangent;
keyframe.SetTangent(inOrOut.Opposite(), oppositeTangent);
}
keyframe.SetWeight(inOrOut, keyframe.IsWeighted(inOrOut) ? weight : WeightDefaultValue);
_curveController.Command.SetControlPointKeyframe(this, keyframe);
}
void OnWrapModeButtonClicked()
{
Action setWrapMode;
WrapMode currentMode;
switch (_wrapModeButton.CurrentPreOrPost)
{
case WrapModeButton.PreOrPost.Pre:
setWrapMode = _curveController.Command.SetPreWrapMode;
currentMode = _curveController.Curve.preWrapMode;
break;
case WrapModeButton.PreOrPost.Post:
setWrapMode = _curveController.Command.SetPostWrapMode;
currentMode = _curveController.Curve.postWrapMode;
break;
default:
throw new ArgumentOutOfRangeException();
}
var position = _wrapModeButton.worldBound.position;
position.y += 10f;
PopupMenuUtility.Show(new[]
{
CreateItem("Loop", WrapMode.Loop),
CreateItem("Ping Pong", WrapMode.PingPong),
CreateItem("Clamp", WrapMode.ClampForever),
},
position,
_wrapModeButton
);
return;
MenuItem CreateItem(string itemName, WrapMode mode)
{
return new MenuItem(itemName, () => setWrapMode(mode)) { isChecked = mode == currentMode };
}
}
}
public void UpdateView()
{
LocalPosition = _previewTransform.GetScreenPosFromCurvePos(KeyframePosition);
UpdateWrapModeButton();
if (IsActive)
{
UpdateHandleView();
}
}
private void UpdateWrapModeButton()
{
var hasLeft = Left != null;
var hasRight = Right != null;
// ReSharper disable once ConvertIfStatementToSwitchStatement
if (!hasLeft && hasRight)
{
_wrapModeButton.Show(WrapModeButton.PreOrPost.Pre);
}
else if (hasLeft && !hasRight)
{
_wrapModeButton.Show(WrapModeButton.PreOrPost.Post);
}
else
{
_wrapModeButton.Hide();
}
}
private void UpdateHandleView()
{
var keyframe = Keyframe;
var leftHandleEnable = IsActive && (Left != null) && (InTangentMode == TangentMode.Free);
var rightHandleEnable = IsActive && (Right != null) && (OutTangentMode == TangentMode.Free);
Update(leftHandleEnable, _leftHandle, keyframe, GetXDistInScreen(Left, this));
Update(rightHandleEnable, _rightHandle, keyframe, GetXDistInScreen(this, Right));
return;
static void Update(bool enable, ControlPointHandle handle, Keyframe keyframe, float xDistToNeighborInScreen)
{
if (!enable)
{
handle.style.visibility = Visibility.Hidden;
return;
}
handle.style.visibility = Visibility.Visible;
handle.UpdateView(keyframe, xDistToNeighborInScreen);
}
float GetXDistInScreen(ControlPoint left, ControlPoint right)
{
if (left == null || right == null) return 0f;
var screesPosLeft = _previewTransform.GetScreenPosFromCurvePos(left.KeyframePosition);
var screesPosRight = _previewTransform.GetScreenPosFromCurvePos(right.KeyframePosition);
return screesPosRight.x - screesPosLeft.x;
}
}
private void OnPointerDown(PointerDownEvent evt)
{
switch (evt.button)
{
// Left click
// - subKey: select/unselect
// - !subKey: select only this. and expect start drag.
case 0:
var subKey = evt.shiftKey || evt.ctrlKey || evt.commandKey;
if (subKey)
{
if (IsActive)
{
_curveController.UnselectControlPoint(this);
}
else
{
_curveController.SelectControlPoint(this, keepOtherSelection: true);
}
evt.StopPropagation();
return;
}
// 未選択のコントロールポイントの場合はそれのみ選択する
// すでに選択済みの場合は複数選択のケースもあるのでそのまま
if (!IsActive)
{
_curveController.SelectControlPoint(this);
}
// ここでStopPropagationしない
// 別のマニュピレーターでDragされるのを期待する
// evt.StopPropagation();
break;
// Right click
// Show popup menu
case 1:
// IsActive(=選択中)の場合はそのまま(複数選択のとき複数選択状態をキープ)
// IsActiveでない場合は他の選択を外して自身のみ選択してからメニュー表示
if (!IsActive)
{
_curveController.SelectControlPoint(this, keepOtherSelection: false);
}
// Show menu
// SelectedControlPointsRectのときでも出して良い気がするが、
// - UnityのAnimationCurveEditorの挙動では出ない
// - DropdownMenuが、画面下部にスペースがなく上部にある場合targetエレメントの上にメニューが表示される
// SelectedControlPointsRectの上部に出てしまいカーソルから離れすぎて不自然なので一旦なし
// ControlPointsPopupMenu.Show(evt.position, SelectedControlPointsEditor, _editPositionPopup, this);
_showPopupMenu(evt.position, this);
evt.StopPropagation();
break;
}
}
}
}