using System; using System.Collections.Generic; using System.Diagnostics; using System.Text; using System.Threading; using UnityEditor; using UnityEngine; namespace SplashEdit.EditorCode { /// /// A live console window that displays stdout/stderr from PCSX-Redux and PSX build output. /// Opens automatically when a build starts or the emulator launches. /// public class PSXConsoleWindow : EditorWindow { private const string WINDOW_TITLE = "PSX Console"; private const string MENU_PATH = "PlayStation 1/PSX Console"; private const int MAX_LINES = 2000; private const int TRIM_AMOUNT = 500; // ── Shared state (set by SplashControlPanel) ── private static Process _process; private static readonly List _lines = new List(); private static readonly object _lock = new object(); private static volatile bool _autoScroll = true; private static volatile bool _reading; // ── Instance state ── private Vector2 _scrollPos; private string _filterText = ""; private bool _showStdout = true; private bool _showStderr = true; private bool _wrapLines = true; private GUIStyle _monoStyle; private GUIStyle _monoStyleErr; private GUIStyle _monoStyleSelected; private int _lastLineCount; // ── Selection state (for shift-click range and right-click copy) ── private int _selectionAnchor = -1; // first clicked line index (into _lines) private int _selectionEnd = -1; // last shift-clicked line index (into _lines) private struct LogLine { public string text; public bool isError; public string timestamp; } // ═══════════════════════════════════════════════════════════════ // Menu // ═══════════════════════════════════════════════════════════════ [MenuItem(MENU_PATH, false, 10)] public static void ShowWindow() { var window = GetWindow(); window.titleContent = new GUIContent(WINDOW_TITLE, EditorGUIUtility.IconContent("d_UnityEditor.ConsoleWindow").image); window.minSize = new Vector2(400, 200); window.Show(); } // ═══════════════════════════════════════════════════════════════ // Public API — called by SplashControlPanel // ═══════════════════════════════════════════════════════════════ /// /// Adds a line to the console from any source (serial host, emulator fallback, etc.). /// Thread-safe. Works whether the window is open or not. /// public static void AddLine(string text, bool isError = false) { if (string.IsNullOrEmpty(text)) return; lock (_lock) { _lines.Add(new LogLine { text = text, isError = isError, timestamp = DateTime.Now.ToString("HH:mm:ss.fff") }); if (_lines.Count > MAX_LINES) { _lines.RemoveRange(0, TRIM_AMOUNT); } } // Repaint is handled by OnEditorUpdate polling _lines.Count changes. // Do NOT call EditorApplication.delayCall here - AddLine is called // from background threads (serial host, process readers) and // delayCall is not thread-safe. It kills the calling thread. } /// /// Opens the console window and begins capturing output from the given process. /// The process must have RedirectStandardOutput and RedirectStandardError enabled. /// public static PSXConsoleWindow Attach(Process process) { // Stop reading from any previous process (but keep existing lines) _reading = false; _process = process; var window = GetWindow(); window.titleContent = new GUIContent(WINDOW_TITLE, EditorGUIUtility.IconContent("d_UnityEditor.ConsoleWindow").image); window.minSize = new Vector2(400, 200); window.Show(); // Start async readers _reading = true; StartReader(process.StandardOutput, false); StartReader(process.StandardError, true); return window; } /// /// Stops reading and detaches from the current process. /// public static void Detach() { _reading = false; _process = null; } // ═══════════════════════════════════════════════════════════════ // Async readers // ═══════════════════════════════════════════════════════════════ private static void StartReader(System.IO.StreamReader reader, bool isError) { var thread = new Thread(() => { try { while (_reading && !reader.EndOfStream) { string line = reader.ReadLine(); if (line == null) break; lock (_lock) { _lines.Add(new LogLine { text = line, isError = isError, timestamp = DateTime.Now.ToString("HH:mm:ss.fff") }); // Trim if too many lines if (_lines.Count > MAX_LINES) { _lines.RemoveRange(0, TRIM_AMOUNT); } } } } catch (Exception) { // Stream closed — normal when process exits } }) { IsBackground = true, Name = isError ? "PSXConsole-stderr" : "PSXConsole-stdout" }; thread.Start(); } // ═══════════════════════════════════════════════════════════════ // Window lifecycle // ═══════════════════════════════════════════════════════════════ private void OnEnable() { EditorApplication.update += OnEditorUpdate; } private void OnDisable() { EditorApplication.update -= OnEditorUpdate; } private void OnEditorUpdate() { // Repaint when new lines arrive int count; lock (_lock) { count = _lines.Count; } if (count != _lastLineCount) { _lastLineCount = count; Repaint(); } } // ═══════════════════════════════════════════════════════════════ // GUI // ═══════════════════════════════════════════════════════════════ private void EnsureStyles() { if (_monoStyle == null) { _monoStyle = new GUIStyle(EditorStyles.label) { font = Font.CreateDynamicFontFromOSFont("Consolas", 12), fontSize = 11, richText = false, wordWrap = _wrapLines, normal = { textColor = new Color(0.85f, 0.85f, 0.85f) }, padding = new RectOffset(4, 4, 1, 1), margin = new RectOffset(0, 0, 0, 0) }; } if (_monoStyleErr == null) { _monoStyleErr = new GUIStyle(_monoStyle) { normal = { textColor = new Color(1f, 0.45f, 0.4f) } }; } if (_monoStyleSelected == null) { _monoStyleSelected = new GUIStyle(_monoStyle) { normal = { textColor = new Color(0.95f, 0.95f, 0.95f), background = MakeSolidTexture(new Color(0.25f, 0.40f, 0.65f, 0.6f)) } }; } _monoStyle.wordWrap = _wrapLines; _monoStyleErr.wordWrap = _wrapLines; _monoStyleSelected.wordWrap = _wrapLines; } private static Texture2D MakeSolidTexture(Color color) { var tex = new Texture2D(1, 1); tex.SetPixel(0, 0, color); tex.Apply(); return tex; } private void OnGUI() { EnsureStyles(); DrawToolbar(); DrawConsoleOutput(); } private void DrawToolbar() { EditorGUILayout.BeginHorizontal(EditorStyles.toolbar); // Process status bool alive = _process != null && !_process.HasExited; var statusColor = GUI.contentColor; GUI.contentColor = alive ? Color.green : Color.gray; GUILayout.Label(alive ? "● Live" : "● Stopped", EditorStyles.toolbarButton, GUILayout.Width(60)); GUI.contentColor = statusColor; // Filter GUILayout.Label("Filter:", GUILayout.Width(40)); _filterText = EditorGUILayout.TextField(_filterText, EditorStyles.toolbarSearchField, GUILayout.Width(150)); GUILayout.FlexibleSpace(); // Toggles _showStdout = GUILayout.Toggle(_showStdout, "stdout", EditorStyles.toolbarButton, GUILayout.Width(50)); _showStderr = GUILayout.Toggle(_showStderr, "stderr", EditorStyles.toolbarButton, GUILayout.Width(50)); _wrapLines = GUILayout.Toggle(_wrapLines, "Wrap", EditorStyles.toolbarButton, GUILayout.Width(40)); // Auto-scroll _autoScroll = GUILayout.Toggle(_autoScroll, "Auto↓", EditorStyles.toolbarButton, GUILayout.Width(50)); // Clear if (GUILayout.Button("Clear", EditorStyles.toolbarButton, GUILayout.Width(45))) { lock (_lock) { _lines.Clear(); } } // Copy all if (GUILayout.Button("Copy", EditorStyles.toolbarButton, GUILayout.Width(40))) { CopyToClipboard(); } EditorGUILayout.EndHorizontal(); } private void DrawConsoleOutput() { // Simple scroll view - no BeginArea/EndArea mixing that causes layout errors. _scrollPos = EditorGUILayout.BeginScrollView(_scrollPos, GUILayout.ExpandHeight(true)); // Dark background behind the scroll content Rect scrollBg = EditorGUILayout.BeginVertical(); EditorGUI.DrawRect(scrollBg, new Color(0.13f, 0.13f, 0.15f)); bool hasFilter = !string.IsNullOrEmpty(_filterText); string filterLower = hasFilter ? _filterText.ToLowerInvariant() : null; int selMin = Mathf.Min(_selectionAnchor, _selectionEnd); int selMax = Mathf.Max(_selectionAnchor, _selectionEnd); bool hasSelection = _selectionAnchor >= 0 && _selectionEnd >= 0; lock (_lock) { if (_lines.Count == 0) { GUILayout.Label("Waiting for output...", EditorStyles.centeredGreyMiniLabel); } for (int i = 0; i < _lines.Count; i++) { var line = _lines[i]; if (line.isError && !_showStderr) continue; if (!line.isError && !_showStdout) continue; if (hasFilter && line.text.ToLowerInvariant().IndexOf(filterLower, StringComparison.Ordinal) < 0) continue; bool selected = hasSelection && i >= selMin && i <= selMax; GUIStyle style = selected ? _monoStyleSelected : (line.isError ? _monoStyleErr : _monoStyle); string label = $"[{line.timestamp}] {line.text}"; GUILayout.Label(label, style); // Handle click/right-click on last drawn rect Rect lineRect = GUILayoutUtility.GetLastRect(); Event evt = Event.current; if (evt.type == EventType.MouseDown && lineRect.Contains(evt.mousePosition)) { if (evt.button == 0) { if (evt.shift && _selectionAnchor >= 0) _selectionEnd = i; else { _selectionAnchor = i; _selectionEnd = i; } evt.Use(); Repaint(); } else if (evt.button == 1) { int clickedLine = i; bool lineInSelection = hasSelection && clickedLine >= selMin && clickedLine <= selMax; var menu = new GenericMenu(); if (lineInSelection && selMin != selMax) { menu.AddItem(new GUIContent("Copy selected lines"), false, () => CopyRange(selMin, selMax)); menu.AddSeparator(""); } menu.AddItem(new GUIContent("Copy this line"), false, () => { string text; lock (_lock) { text = clickedLine < _lines.Count ? $"[{_lines[clickedLine].timestamp}] {_lines[clickedLine].text}" : ""; } EditorGUIUtility.systemCopyBuffer = text; }); menu.ShowAsContext(); evt.Use(); } } } } EditorGUILayout.EndVertical(); if (_autoScroll) _scrollPos.y = float.MaxValue; EditorGUILayout.EndScrollView(); } private void CopyRange(int fromIndex, int toIndex) { var sb = new StringBuilder(); lock (_lock) { int lo = Mathf.Min(fromIndex, toIndex); int hi = Mathf.Max(fromIndex, toIndex); for (int i = lo; i <= hi && i < _lines.Count; i++) { string prefix = _lines[i].isError ? "[ERR]" : "[OUT]"; sb.AppendLine($"[{_lines[i].timestamp}] {prefix} {_lines[i].text}"); } } EditorGUIUtility.systemCopyBuffer = sb.ToString(); } private void CopyToClipboard() { var sb = new StringBuilder(); lock (_lock) { foreach (var line in _lines) { string prefix = line.isError ? "[ERR]" : "[OUT]"; sb.AppendLine($"[{line.timestamp}] {prefix} {line.text}"); } } EditorGUIUtility.systemCopyBuffer = sb.ToString(); } } }