A game about forced loneliness, made by TACStudios
1// UITK TreeView is not supported in earlier versions
2// Therefore the UITK version of the InputActionAsset Editor is not available on earlier Editor versions either.
3#if UNITY_EDITOR && UNITY_INPUT_SYSTEM_PROJECT_WIDE_ACTIONS
4using System;
5using System.Linq;
6using UnityEditor;
7using UnityEditor.Callbacks;
8using UnityEditor.PackageManager.UI;
9using UnityEditor.ShortcutManagement;
10using UnityEngine.UIElements;
11using UnityEditor.UIElements;
12
13namespace UnityEngine.InputSystem.Editor
14{
15 // TODO: Remove when UIToolkit editor is complete and set as the default editor
16 [InitializeOnLoad]
17 internal static class EnableUITKEditor
18 {
19 static EnableUITKEditor()
20 {
21 }
22 }
23
24 internal class InputActionsEditorWindow : EditorWindow, IInputActionAssetEditor
25 {
26 // Register editor type via static constructor to enable asset monitoring
27 static InputActionsEditorWindow()
28 {
29 InputActionAssetEditor.RegisterType<InputActionsEditorWindow>();
30 }
31
32 static readonly Vector2 k_MinWindowSize = new Vector2(650, 450);
33 // For UI testing purpose
34 internal InputActionAsset currentAssetInEditor => m_AssetObjectForEditing;
35 [SerializeField] private InputActionAsset m_AssetObjectForEditing;
36 [SerializeField] private InputActionsEditorState m_State;
37 [SerializeField] private string m_AssetGUID;
38
39 private string m_AssetJson;
40 private bool m_IsDirty;
41
42 private StateContainer m_StateContainer;
43 private InputActionsEditorView m_View;
44
45 private InputActionsEditorSessionAnalytic m_Analytics;
46
47 private InputActionsEditorSessionAnalytic analytics =>
48 m_Analytics ??= new InputActionsEditorSessionAnalytic(
49 InputActionsEditorSessionAnalytic.Data.Kind.EditorWindow);
50
51 [OnOpenAsset]
52 public static bool OpenAsset(int instanceId, int line)
53 {
54 if (InputSystem.settings.IsFeatureEnabled(InputFeatureNames.kUseIMGUIEditorForAssets))
55 return false;
56 if (!InputActionImporter.IsInputActionAssetPath(AssetDatabase.GetAssetPath(instanceId)))
57 return false;
58
59 // Grab InputActionAsset.
60 // NOTE: We defer checking out an asset until we save it. This allows a user to open an .inputactions asset and look at it
61 // without forcing a checkout.
62 var obj = EditorUtility.InstanceIDToObject(instanceId);
63 var asset = obj as InputActionAsset;
64
65 string actionMapToSelect = null;
66 string actionToSelect = null;
67
68 // Means we're dealing with an InputActionReference, e.g. when expanding the an .input action asset
69 // on the Asset window and selecting an Action.
70 if (asset == null)
71 {
72 var actionReference = obj as InputActionReference;
73 if (actionReference != null && actionReference.asset != null)
74 {
75 asset = actionReference.asset;
76 actionMapToSelect = actionReference.action.actionMap?.name;
77 actionToSelect = actionReference.action?.name;
78 }
79 else
80 {
81 return false;
82 }
83 }
84
85 OpenWindow(asset, actionMapToSelect, actionToSelect);
86 return true;
87 }
88
89 private static InputActionsEditorWindow OpenWindow(InputActionAsset asset, string actionMapToSelect = null, string actionToSelect = null)
90 {
91 ////REVIEW: It'd be great if the window got docked by default but the public EditorWindow API doesn't allow that
92 //// to be done for windows that aren't singletons (GetWindow<T>() will only create one window and it's the
93 //// only way to get programmatic docking with the current API).
94 // See if we have an existing editor window that has the asset open.
95 var existingWindow = InputActionAssetEditor.FindOpenEditor<InputActionsEditorWindow>(AssetDatabase.GetAssetPath(asset));
96 if (existingWindow != null)
97 {
98 existingWindow.Focus();
99 return existingWindow;
100 }
101
102 var window = GetWindow<InputActionsEditorWindow>();
103 window.m_IsDirty = false;
104 window.minSize = k_MinWindowSize;
105 window.SetAsset(asset, actionToSelect, actionMapToSelect);
106 window.Show();
107
108 return window;
109 }
110
111 /// <summary>
112 /// Open the specified <paramref name="asset"/> in an editor window. Used when someone hits the "Edit Asset" button in the
113 /// importer inspector.
114 /// </summary>
115 /// <param name="asset">The InputActionAsset to open.</param>
116 /// <returns>The editor window.</returns>
117 public static InputActionsEditorWindow OpenEditor(InputActionAsset asset)
118 {
119 return OpenWindow(asset, null, null);
120 }
121
122 private static GUIContent GetEditorTitle(InputActionAsset asset, bool isDirty)
123 {
124 var text = asset.name + " (Input Actions Editor)";
125 if (isDirty)
126 text = "(*) " + text;
127 return new GUIContent(text);
128 }
129
130 private void SetAsset(InputActionAsset asset, string actionToSelect = null, string actionMapToSelect = null)
131 {
132 var existingWorkingCopy = m_AssetObjectForEditing;
133
134 try
135 {
136 // Obtain and persist GUID for the associated asset
137 Debug.Assert(AssetDatabase.TryGetGUIDAndLocalFileIdentifier(asset, out m_AssetGUID, out long _),
138 $"Failed to get asset {asset.name} GUID");
139
140 // Attempt to update editor and internals based on associated asset
141 if (!TryUpdateFromAsset())
142 return;
143
144 // Select the action that was selected on the Asset window.
145 if (actionMapToSelect != null && actionToSelect != null)
146 {
147 m_State = m_State.SelectActionMap(actionMapToSelect);
148 m_State = m_State.SelectAction(actionToSelect);
149 }
150
151 BuildUI();
152 }
153 catch (Exception e)
154 {
155 Debug.LogException(e);
156 }
157 finally
158 {
159 if (existingWorkingCopy != null)
160 DestroyImmediate(existingWorkingCopy);
161 }
162 }
163
164 private void CreateGUI() // Only domain reload
165 {
166 // When opening the window for the first time there will be no state or asset yet.
167 // In that case, we don't do anything as SetAsset() will be called later and at that point the UI can be created.
168 // Here we only recreate the UI e.g. after a domain reload.
169 if (string.IsNullOrEmpty(m_AssetGUID))
170 return;
171
172 // After domain reloads the state will be in a invalid state as some of the fields
173 // cannot be serialized and will become null.
174 // Therefore we recreate the state here using the fields which were saved.
175 if (m_State.serializedObject == null)
176 {
177 InputActionAsset workingCopy = null;
178 try
179 {
180 var assetPath = AssetDatabase.GUIDToAssetPath(m_AssetGUID);
181 var asset = AssetDatabase.LoadAssetAtPath<InputActionAsset>(assetPath);
182
183 if (asset == null)
184 throw new Exception($"Failed to load asset \"{assetPath}\". The file may have been deleted or moved.");
185
186 m_AssetJson = InputActionsEditorWindowUtils.ToJsonWithoutName(asset);
187
188 if (m_AssetObjectForEditing == null)
189 {
190 workingCopy = InputActionAssetManager.CreateWorkingCopy(asset);
191 if (m_State.m_Analytics == null)
192 m_State.m_Analytics = analytics;
193 m_State = new InputActionsEditorState(m_State, new SerializedObject(workingCopy));
194 m_AssetObjectForEditing = workingCopy;
195 }
196 else
197 m_State = new InputActionsEditorState(m_State, new SerializedObject(m_AssetObjectForEditing));
198 m_IsDirty = HasContentChanged();
199 }
200 catch (Exception e)
201 {
202 Debug.LogException(e);
203 if (workingCopy != null)
204 DestroyImmediate(workingCopy);
205 Close();
206 return;
207 }
208 }
209
210 BuildUI();
211 }
212
213 private void CleanupStateContainer()
214 {
215 if (m_StateContainer != null)
216 {
217 m_StateContainer.StateChanged -= OnStateChanged;
218 m_StateContainer = null;
219 }
220 }
221
222 private void BuildUI()
223 {
224 CleanupStateContainer();
225
226 if (m_State.m_Analytics == null)
227 m_State.m_Analytics = m_Analytics;
228
229 m_StateContainer = new StateContainer(m_State, m_AssetGUID);
230 m_StateContainer.StateChanged += OnStateChanged;
231
232 rootVisualElement.Clear();
233 if (!rootVisualElement.styleSheets.Contains(InputActionsEditorWindowUtils.theme))
234 rootVisualElement.styleSheets.Add(InputActionsEditorWindowUtils.theme);
235 m_View = new InputActionsEditorView(rootVisualElement, m_StateContainer, false, () => Save(isAutoSave: false));
236
237 m_StateContainer.Initialize(rootVisualElement.Q("action-editor"));
238 }
239
240 private void OnStateChanged(InputActionsEditorState newState)
241 {
242 DirtyInputActionsEditorWindow(newState);
243 m_State = newState;
244
245 #if UNITY_INPUT_SYSTEM_INPUT_ACTIONS_EDITOR_AUTO_SAVE_ON_FOCUS_LOST
246 // No action taken apart from setting dirty flag, auto-save triggered as part of having a dirty asset
247 // and editor loosing focus instead.
248 #else
249 if (InputEditorUserSettings.autoSaveInputActionAssets)
250 Save(isAutoSave: false);
251 #endif
252 }
253
254 private void UpdateWindowTitle()
255 {
256 titleContent = GetEditorTitle(GetEditedAsset(), m_IsDirty);
257 }
258
259 private InputActionAsset GetEditedAsset()
260 {
261 return m_State.serializedObject.targetObject as InputActionAsset;
262 }
263
264 private void Save(bool isAutoSave)
265 {
266 var path = AssetDatabase.GUIDToAssetPath(m_AssetGUID);
267 #if UNITY_INPUT_SYSTEM_PROJECT_WIDE_ACTIONS
268 var projectWideActions = InputSystem.actions;
269 if (projectWideActions != null && path == AssetDatabase.GetAssetPath(projectWideActions))
270 ProjectWideActionsAsset.Verify(GetEditedAsset());
271 #endif
272 if (InputActionAssetManager.SaveAsset(path, GetEditedAsset().ToJson()))
273 TryUpdateFromAsset();
274
275 if (isAutoSave)
276 analytics.RegisterAutoSave();
277 else
278 analytics.RegisterExplicitSave();
279 }
280
281 private bool HasContentChanged()
282 {
283 var editedAsset = GetEditedAsset();
284 var editedAssetJson = InputActionsEditorWindowUtils.ToJsonWithoutName(editedAsset);
285 return editedAssetJson != m_AssetJson;
286 }
287
288 private void DirtyInputActionsEditorWindow(InputActionsEditorState newState)
289 {
290 #if UNITY_INPUT_SYSTEM_INPUT_ACTIONS_EDITOR_AUTO_SAVE_ON_FOCUS_LOST
291 // Window is dirty is equivalent to if asset has changed
292 var isWindowDirty = HasContentChanged();
293 #else
294 // Window is dirty is never true since every change is auto-saved
295 var isWindowDirty = !InputEditorUserSettings.autoSaveInputActionAssets && HasContentChanged();
296 #endif
297
298 if (m_IsDirty == isWindowDirty)
299 return;
300
301 m_IsDirty = isWindowDirty;
302 UpdateWindowTitle();
303 }
304
305 private void OnEnable()
306 {
307 analytics.Begin();
308 }
309
310 private void OnDisable()
311 {
312 analytics.End();
313 }
314
315 private void OnFocus()
316 {
317 analytics.RegisterEditorFocusIn();
318 }
319
320 private void OnLostFocus()
321 {
322 // Auto-save triggers on focus-lost instead of on every change
323 #if UNITY_INPUT_SYSTEM_INPUT_ACTIONS_EDITOR_AUTO_SAVE_ON_FOCUS_LOST
324 if (InputEditorUserSettings.autoSaveInputActionAssets && m_IsDirty)
325 Save(isAutoSave: true);
326 #endif
327
328 analytics.RegisterEditorFocusOut();
329 }
330
331 private void HandleOnDestroy()
332 {
333 // Do we have unsaved changes that we need to ask the user to save or discard?
334 if (!m_IsDirty)
335 return;
336
337 // Get target asset path from GUID, if this fails file no longer exists and we need to abort.
338 var assetPath = AssetDatabase.GUIDToAssetPath(m_AssetGUID);
339 if (string.IsNullOrEmpty(assetPath))
340 return;
341
342 // Prompt user with a dialog
343 var result = Dialog.InputActionAsset.ShowSaveChanges(assetPath);
344 switch (result)
345 {
346 case Dialog.Result.Save:
347 Save(isAutoSave: false);
348 break;
349 case Dialog.Result.Cancel:
350 // Cancel editor quit. (open new editor window with the edited asset)
351 ReshowEditorWindowWithUnsavedChanges();
352 break;
353 case Dialog.Result.Discard:
354 // Don't save, quit - reload the old asset from the json to prevent the asset from being dirtied
355 break;
356 default:
357 throw new ArgumentOutOfRangeException(nameof(result));
358 }
359 }
360
361 private void OnDestroy()
362 {
363 HandleOnDestroy();
364
365 // Clean-up
366 CleanupStateContainer();
367 if (m_AssetObjectForEditing != null)
368 DestroyImmediate(m_AssetObjectForEditing);
369
370 m_View?.DestroyView();
371 }
372
373 private void ReshowEditorWindowWithUnsavedChanges()
374 {
375 var window = CreateWindow<InputActionsEditorWindow>();
376
377 // Move/transfer ownership of m_AssetObjectForEditing to new window
378 window.m_AssetObjectForEditing = m_AssetObjectForEditing;
379 m_AssetObjectForEditing = null;
380
381 // Move/transfer ownership of m_State to new window (struct)
382 window.m_State = m_State;
383 m_State = new InputActionsEditorState();
384
385 // Just copy trivial arguments
386 window.m_AssetGUID = m_AssetGUID;
387 window.m_AssetJson = m_AssetJson;
388 window.m_IsDirty = m_IsDirty;
389
390 // Note that view and state container will get destroyed with this window instance
391 // and recreated for this window below
392 window.BuildUI();
393 window.Show();
394
395 // Make sure window title is up to date
396 window.UpdateWindowTitle();
397 }
398
399 private bool TryUpdateFromAsset()
400 {
401 Debug.Assert(!string.IsNullOrEmpty(m_AssetGUID), "Asset GUID is empty");
402 var assetPath = AssetDatabase.GUIDToAssetPath(m_AssetGUID);
403 if (assetPath == null)
404 {
405 Debug.LogWarning(
406 $"Failed to open InputActionAsset with GUID {m_AssetGUID}. The asset might have been deleted.");
407 return false;
408 }
409
410 InputActionAsset workingCopy = null;
411 try
412 {
413 var asset = AssetDatabase.LoadAssetAtPath<InputActionAsset>(assetPath);
414 workingCopy = InputActionAssetManager.CreateWorkingCopy(asset);
415 m_AssetJson = InputActionsEditorWindowUtils.ToJsonWithoutName(asset);
416 m_State = new InputActionsEditorState(m_State, new SerializedObject(workingCopy));
417 m_IsDirty = false;
418 }
419 catch (Exception e)
420 {
421 if (workingCopy != null)
422 DestroyImmediate(workingCopy);
423 Debug.LogException(e);
424 Close();
425 return false;
426 }
427
428 m_AssetObjectForEditing = workingCopy;
429 UpdateWindowTitle();
430
431 return true;
432 }
433
434 #region IInputActionEditorWindow
435
436 public string assetGUID => m_AssetGUID;
437 public bool isDirty => m_IsDirty;
438
439 public void OnAssetMoved()
440 {
441 // When an asset is moved, we only need to update window title since content is unchanged
442 UpdateWindowTitle();
443 }
444
445 public void OnAssetDeleted()
446 {
447 // When associated asset is deleted on disk, just close the editor, but also mark the editor
448 // as not being dirty to avoid prompting the user to save changes.
449 m_IsDirty = false;
450 Close();
451 }
452
453 public void OnAssetImported()
454 {
455 // If the editor has pending changes done by the user and the contents changes on disc, there
456 // is not much we can do about it but to ignore loading the changes. If the editors asset is
457 // unmodified, we can refresh the editor with the latest content from disc.
458 if (m_IsDirty)
459 return;
460
461 // If our asset has disappeared from disk, just close the window.
462 var assetPath = AssetDatabase.GUIDToAssetPath(assetGUID);
463 if (string.IsNullOrEmpty(assetPath))
464 {
465 m_IsDirty = false; // Avoid checks
466 Close();
467 return;
468 }
469
470 SetAsset(AssetDatabase.LoadAssetAtPath<InputActionAsset>(assetPath));
471 }
472
473 #endregion
474
475 #region Shortcuts
476 [Shortcut("Input Action Editor/Save", typeof(InputActionsEditorWindow), KeyCode.S, ShortcutModifiers.Action)]
477 private static void SaveShortcut(ShortcutArguments arguments)
478 {
479 var window = (InputActionsEditorWindow)arguments.context;
480 window.Save(isAutoSave: false);
481 }
482
483 [Shortcut("Input Action Editor/Add Action Map", typeof(InputActionsEditorWindow), KeyCode.M, ShortcutModifiers.Alt)]
484 private static void AddActionMapShortcut(ShortcutArguments arguments)
485 {
486 var window = (InputActionsEditorWindow)arguments.context;
487 window.m_StateContainer.Dispatch(Commands.AddActionMap());
488 }
489
490 [Shortcut("Input Action Editor/Add Action", typeof(InputActionsEditorWindow), KeyCode.A, ShortcutModifiers.Alt)]
491 private static void AddActionShortcut(ShortcutArguments arguments)
492 {
493 var window = (InputActionsEditorWindow)arguments.context;
494 window.m_StateContainer.Dispatch(Commands.AddAction());
495 }
496
497 [Shortcut("Input Action Editor/Add Binding", typeof(InputActionsEditorWindow), KeyCode.B, ShortcutModifiers.Alt)]
498 private static void AddBindingShortcut(ShortcutArguments arguments)
499 {
500 var window = (InputActionsEditorWindow)arguments.context;
501 window.m_StateContainer.Dispatch(Commands.AddBinding());
502 }
503
504 #endregion
505 }
506}
507
508#endif