This commit is contained in:
Jan Racek
2026-03-24 13:00:54 +01:00
parent 53e993f58e
commit 4aa4e49424
145 changed files with 10853 additions and 2965 deletions

View 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();
}
}
}