using System; using System.Collections; using System.Collections.Generic; using System.Linq; using Unity.EditorCoroutines.Editor; using UnityEditor; using UnityEditor.SceneManagement; using UnityEditorInternal; using UnityEngine; using UnityEngine.SceneManagement; using UnityEngine.UIElements; using UnityEngine.Video; namespace Unity.Tutorials.Core.Editor { internal class TutorialView : View { const string k_NextButtonBorderElementName = "NextButtonBase"; internal const string k_Name = "Tutorial"; internal override string Name => k_Name; TutorialModel Model => Application?.Model?.Tutorial; TutorialPage CurrentPage => Model?.CurrentTutorial?.CurrentPage; string PageTitle => CurrentPage.Title; VisualElement m_Root; VisualElement tutorialPageContainer; VisualElement tutorialParagraphsContainer; VisualElement footer; ScrollView tutorialScrollview; Button btnNext; EditorCoroutine m_NextButtonBlinkRoutine; Dictionary paragraphsRepresentationsPrefabs = new Dictionary(); Dictionary instructionParagraphs = new Dictionary(); VideoPlaybackManager VideoPlaybackManager { get; } = new VideoPlaybackManager(); private List playButtons = new List(); private List playOverlays = new List(); public TutorialView() : base() { } public override void SubscribeEvents() { base.SubscribeEvents(); GUIViewProxy.PositionChanged += OnGUIViewPositionChanged; EditorApplication.projectChanged += RefreshMasking; EditorApplication.hierarchyChanged += RefreshMaskingOnHierarchyChange; EditorApplication.playModeStateChanged += PlayModeChanged; EditorSceneManager.sceneOpened += SceneOpened; } public override void UnubscribeEvents() { base.UnubscribeEvents(); GUIViewProxy.PositionChanged -= OnGUIViewPositionChanged; EditorApplication.projectChanged -= RefreshMasking; EditorApplication.hierarchyChanged -= RefreshMaskingOnHierarchyChange; EditorApplication.playModeStateChanged -= PlayModeChanged; EditorSceneManager.sceneOpened -= SceneOpened; Application.StopAndNullifyEditorCoroutine(ref m_NextButtonBlinkRoutine); VideoPlaybackManager.OnDisable(); ApplyMaskingSettings(false); } internal void Initialize(VisualElement root) { m_Root = root; tutorialPageContainer = m_Root.Q("TutorialPageContainer"); footer = m_Root.Q("TutorialActions"); tutorialScrollview = (ScrollView)tutorialPageContainer.Q("TutorialContainer").ElementAt(0); tutorialParagraphsContainer = tutorialScrollview.Q("unity-content-container"); if (paragraphsRepresentationsPrefabs.Count == 0) { foreach (ParagraphType paragraphType in Enum.GetValues(typeof(ParagraphType))) { paragraphsRepresentationsPrefabs.Add(paragraphType, UIElementsUtils.LoadUXML($"Paragraphs/{paragraphType}")); } } VideoPlaybackManager.OnEnable(); VideoPlaybackManager.ClearCache(); playButtons.Clear(); playOverlays.Clear(); Refresh(); SubscribeEvents(); } private void RefreshMaskingOnHierarchyChange() { if (!CurrentPage.ShouldRefreshMaskingOnHierarchyChange) { return; } RefreshMasking(); } internal void RefreshMasking() { if (!Application.Model.IsOpen) { return; } if (Model.CurrentTutorial == null) { return; } MaskingManager.Unmask(); ApplyMaskingSettings(true); QueueMaskUpdate(); } internal void PlayModeChanged(PlayModeStateChange stateChange) { // Exiting play mode don't reset anything, but some thing get unloaded as the UI will have things // disappearing and play buttons won't be refreshed. So we force those refresh if (stateChange == PlayModeStateChange.EnteredEditMode && m_Root != null) { RefreshPlayer(); } } // opening a new scene will mess up the player like a playmode change so we need to refresh it internal void SceneOpened(Scene scene, OpenSceneMode mode) { RefreshPlayer(); } internal void RefreshPlayer() { EditorCoroutineUtility.StartCoroutineOwnerless(RefreshPlayerCoroutine()); } IEnumerator RefreshPlayerCoroutine() { m_Root.style.display = DisplayStyle.None; yield return null; m_Root.style.display = DisplayStyle.Flex; foreach (var playButton in playButtons) { playButton.RemoveFromClassList("video-pause-button"); playButton.AddToClassList("video-play-button"); } foreach (var playOverlay in playOverlays) { playOverlay.visible = true; } } void OnGUIViewPositionChanged(UnityEngine.Object sender) { if (Model.CurrentTutorial == null || Model.IsLoadingLayout || sender.GetType() == GUIViewProxy.TooltipViewType) { return; } ApplyMaskingSettings(true); } internal void Refresh() { UIElementsUtils.SetupLabel("lblTutorialName", Model.CurrentTutorial.TutorialTitle, m_Root, false); UIElementsUtils.SetupLabel("lblStepCount", $"{Model.CurrentTutorial.CurrentPageIndex + 1} / {Model.CurrentTutorial.PagesCollection.Count}", m_Root, false); m_Root.Q("btnQuit").RegisterCallback(_ => ExitTutorial()); UIElementsUtils.SetupButton("btnPrevious", OnPreviousButtonClicked, true, footer, LocalizationKeys.k_TutorialButtonPrevious, localize: true); string nextButtonText = Model.CurrentTutorial.CurrentPageIndex + 1 == Model.CurrentTutorial.PagesCollection.Count ? CurrentPage.DoneButton : CurrentPage.NextButton; btnNext = UIElementsUtils.SetupButton("btnNext", OnNextButtonClicked, Model.CanMoveToNextPage, footer, nextButtonText, localize: true); ShowCurrentTutorialContent(); ApplyMaskingSettings(true); } void QueueMaskUpdate() { EditorApplication.update -= ApplyQueuedMask; EditorApplication.update += ApplyQueuedMask; } void ApplyQueuedMask() { if (!Application || Application.IsParentNull()) { return; } EditorApplication.update -= ApplyQueuedMask; ApplyMaskingSettings(true); } internal void ApplyMaskingSettings(bool applyMask) { if (!applyMask || !Model.MaskingEnabled || Model.IsLoadingLayout || !Model.CurrentTutorial) { MaskingManager.Unmask(); return; } MaskingSettings maskingSettings = Model.CurrentTutorial.CurrentPage.CurrentMaskingSettings; try { if (maskingSettings == null || !maskingSettings.Enabled) { MaskingManager.Unmask(); } else { bool foundAncestorProperty; var unmaskedViews = UnmaskedView.GetViewsAndRects(maskingSettings.UnmaskedViews, out foundAncestorProperty); if (foundAncestorProperty) { // Keep updating mask when target property is not unfolded QueueMaskUpdate(); } /* // When going back, don't reapply the page's masking settings if the page has completion criteria: // criterion logic introduces complexity and potential changes in the UI, but if we don't have such, // it's fairly safe to re-unmask/highlight UI elements. if (currentTutorial.CurrentPageIndex <= m_FarthestPageCompleted && currentTutorial.CurrentPage.HasCriteria()) { unmaskedViews = new UnmaskedView.MaskData(); }*/ UnmaskedView.MaskData highlightedViews; if (unmaskedViews.Count > 0) //Unmasked views should be highlighted { highlightedViews = (UnmaskedView.MaskData)unmaskedViews.Clone(); } else if (Model.CanMoveToNextPage) // otherwise, if the current page is completed, highlight this window { highlightedViews = new UnmaskedView.MaskData(); highlightedViews.AddParentFullyUnmasked(Application); } else // otherwise, highlight manually specified control rects if there are any { var unmaskedControls = new List(); var unmaskedViewsWithControlsSpecified = maskingSettings.UnmaskedViews.Where(v => v.GetUnmaskedControls(unmaskedControls) > 0).ToArray(); // if there are no manually specified control rects, highlight all unmasked views highlightedViews = UnmaskedView.GetViewsAndRects( unmaskedViewsWithControlsSpecified.Length == 0 ? maskingSettings.UnmaskedViews : unmaskedViewsWithControlsSpecified ); } // ensure tutorial window's HostView and tooltips are not masked unmaskedViews.AddParentFullyUnmasked(Application); unmaskedViews.AddTooltipViews(); // also ensure the Media Popout window (used to enlarge video and image) is unmasked unmaskedViews.AddPopoutWindow(); // tooltip views should not be highlighted highlightedViews.RemoveTooltipViews(); // media popout window should not be highlighted highlightedViews.RemovePopoutWindow(); /* note: For some reason, when highlighting is applied because HierarchyChanged is triggered because a new object is created * and the editor is not attached to a debugger, the object won't be highlighted even if its name matches any of the masking settings. If the editor is attached to a debugger, the highlighting will work as expected. */ MaskingManager.Mask( unmaskedViews, Model.Styles == null ? Color.magenta * new Color(1f, 1f, 1f, 0.8f) : Model.Styles.MaskingColor, highlightedViews, Model.Styles == null ? Color.cyan * new Color(1f, 1f, 1f, 0.8f) : Model.Styles.HighlightColor, Model.Styles == null ? new Color(1, 1, 1, 0.5f) : Model.Styles.BlockedInteractionColor, Model.Styles == null ? 3f : Model.Styles.HighlightThickness ); } } catch (ArgumentException e) { if (TutorialModel.s_AuthoringModeEnabled) { Debug.LogException(e, Model.CurrentTutorial.CurrentPage); } else { Console.WriteLine(StackTraceUtility.ExtractStringFromException(e)); } MaskingManager.Unmask(); } } void ExitTutorial() { Application.Broadcast(new TutorialQuitEvent()); } void ScrollToTop() { tutorialScrollview.scrollOffset = Vector2.zero; } void SetupParagraphUI(VisualElement paragraphUI, TutorialParagraph paragraph, string pageName) { const string instructionContainerElementName = "InstructionContainer"; const string startLinkedTutorialElementName = "btnStartLinkedTutorial"; const string tutorialMediaContainerElementName = "TutorialMediaContainer"; const string codeSampleScrollViewElementName = "CodeSampleScrollView"; const string codeSampleLabelElementName = "CodeSample"; paragraphUI.name = paragraph.Type.ToString(); switch (paragraph.Type) { case ParagraphType.Narrative: var label = new Label(paragraph.Text); //ensure we got word wrap label.style.whiteSpace = WhiteSpace.Normal; paragraphUI.Q("TutorialStepBox1").Add(label); if (paragraph.CodeSample.IsNotNullOrEmpty()) { UIElementsUtils.Show(codeSampleScrollViewElementName, paragraphUI); var codeSample = paragraphUI.Q