psst
This commit is contained in:
418
Editor/Core/PSXConsoleWindow.cs
Normal file
418
Editor/Core/PSXConsoleWindow.cs
Normal file
@@ -0,0 +1,418 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
|
||||
namespace SplashEdit.EditorCode
|
||||
{
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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<LogLine> _lines = new List<LogLine>();
|
||||
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<PSXConsoleWindow>();
|
||||
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
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
/// <summary>
|
||||
/// Adds a line to the console from any source (serial host, emulator fallback, etc.).
|
||||
/// Thread-safe. Works whether the window is open or not.
|
||||
/// </summary>
|
||||
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.
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Opens the console window and begins capturing output from the given process.
|
||||
/// The process must have RedirectStandardOutput and RedirectStandardError enabled.
|
||||
/// </summary>
|
||||
public static PSXConsoleWindow Attach(Process process)
|
||||
{
|
||||
// Stop reading from any previous process (but keep existing lines)
|
||||
_reading = false;
|
||||
|
||||
_process = process;
|
||||
|
||||
var window = GetWindow<PSXConsoleWindow>();
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stops reading and detaches from the current process.
|
||||
/// </summary>
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user