using System.Text; using XFESystem.Console; using XFESystem.Core; namespace XFESystem.Desktop; /// The desktop publishes this on its UI thread. The native adapter /// queues execution and exclusively owns Output until it reports completion. public sealed record TerminalProgramRequest(int WindowId, string Path, TerminalTextWriter Output); public sealed partial class DesktopSession { public string TerminalVersion { get; init; } = "0.3.0-dev.4"; public Action? TerminalProgramRequested { get; init; } private const int TerminalCellWidth = 8, TerminalRowHeight = 20; private static DesktopBounds TerminalViewport(DesktopWindow window) => new(window.Bounds.X + 2, window.Bounds.Y + 35, Math.Max(1, window.Bounds.Width - 4), Math.Max(1, window.Bounds.Height - 64)); private void InitializeTerminal(DesktopWindow window) { int columns = Math.Clamp((window.Bounds.Width - 28) / TerminalCellWidth, 24, 240); window.Shell = new(_storage, TerminalVersion, columns, 128); window.Terminal = window.Shell.ReadOutput(); window.Content = ""; window.Location = window.Shell.CurrentDirectory; window.Status = ""; RestartTerminalCaret(window); } private DesktopWindow GetTerminalWindow(int? windowId = null) { var window = windowId is { } id ? _windows.Find(value => value.Id == id && value.Application == DesktopApplication.Terminal) : ActiveWindow?.Application == DesktopApplication.Terminal ? ActiveWindow : _windows.FindLast(value => value.Application == DesktopApplication.Terminal); if (window is null && windowId is not null) throw new InvalidOperationException("The terminal window is closed."); window ??= Open(DesktopApplication.Terminal); if (window.Shell is null) InitializeTerminal(window); return window; } /// UI-thread completion only. The worker must finish writing before /// its queued completion is drained. No worker touches desktop/window state. public void RefreshTerminalOutput(int windowId) { var window = _windows.Find(value => value.Id == windowId && value.Application == DesktopApplication.Terminal); if (window?.Shell is null) return; // A closed terminal never reopens on worker completion. window.TerminalProgramRunning = false; window.Terminal = window.Shell.ReadOutput(); window.Location = window.Shell.CurrentDirectory; window.TerminalScrollRows = 0; window.Status = ""; RestartTerminalCaret(window); InvalidateScene(window.Bounds); Trace?.Invoke("XFE_TERMINAL_OUTPUT_READY|Window|" + window.Id + "\n"); } /// Short UI-owned output. Long/application output uses the request /// writer on its worker, followed by RefreshTerminalOutput on the UI thread. public void WriteTerminalOutput(string text, int? windowId = null) { var window = GetTerminalWindow(windowId); if (window.TerminalProgramRunning) throw new InvalidOperationException("The running program owns terminal output."); window.Shell!.ProgramOutput.Write(text); window.Shell.ProgramOutput.Complete(); RefreshTerminalOutput(window.Id); } /// Literal single-line paste. It does not execute pasted commands. public bool PasteTerminalInput(string text) { if (IsStartMenuOpen || ActiveWindow is not { Application: DesktopApplication.Terminal, Shell: { } shell } window || window.TerminalProgramRunning) return false; bool accepted = shell.InsertText(text); if (accepted) { window.TerminalScrollRows = 0; RestartTerminalCaret(window); InvalidateScene(TerminalViewport(window)); } return accepted; } private void TerminalKey(DesktopWindow window, DesktopKey key, char character) { if (window.TerminalProgramRunning) { if (key == DesktopKey.Cancel) { window.Status = "Program is running; this runtime cannot interrupt it yet."; InvalidateScene(window.Bounds); } return; } var shell = window.Shell!; TerminalShellKey? mapped = key switch { DesktopKey.Character => TerminalShellKey.Character, DesktopKey.Enter => TerminalShellKey.Enter, DesktopKey.Backspace => TerminalShellKey.Backspace, DesktopKey.Delete => TerminalShellKey.Delete, DesktopKey.Left => TerminalShellKey.Left, DesktopKey.Right => TerminalShellKey.Right, DesktopKey.Up => TerminalShellKey.Up, DesktopKey.Down => TerminalShellKey.Down, DesktopKey.Home => TerminalShellKey.Home, DesktopKey.End => TerminalShellKey.End, DesktopKey.Insert => TerminalShellKey.Insert, DesktopKey.Cancel or DesktopKey.Escape => TerminalShellKey.Cancel, _ => null }; if (mapped is null) return; var result = shell.HandleKey(mapped.Value, key == DesktopKey.Character ? character.ToString() : null); window.Location = shell.CurrentDirectory; window.TerminalScrollRows = 0; RestartTerminalCaret(window); if (key is DesktopKey.Enter or DesktopKey.Cancel or DesktopKey.Escape) window.Terminal = shell.ReadOutput(); if (result is not null) { Trace?.Invoke("XFE_TERMINAL_COMMAND|Window|" + window.Id + "|Exit|" + result.ExitCode + "\n"); if (result.CloseRequested) { Close(window.Id); return; } if (result.OpenPath is { } path) { bool directory = path == SystemPaths.Root || _storage.List(SystemPaths.Parent(path)).Any(entry => entry.IsDirectory && entry.Path.Equals(path, StringComparison.OrdinalIgnoreCase)); if (directory) Open(DesktopApplication.Files, path); else OpenFile(path); } if (result.ManagedProgramPath is { } program) { if (TerminalProgramRequested is not null) { // Capture before publication, then render only this immutable // snapshot until the UI drains the worker's completion. window.TerminalProgramRunning = true; window.Status = "Running " + SystemPaths.Name(program) + "..."; try { TerminalProgramRequested(new(window.Id, program, shell.ProgramOutput)); } catch (Exception error) when (error is IOException or InvalidOperationException or ArgumentException) { window.TerminalProgramRunning = false; shell.ProgramOutput.WriteLine("Program could not start: " + error.Message); RefreshTerminalOutput(window.Id); } } else if (_managedApplication is not null) _managedApplication(program); else { shell.ProgramOutput.WriteLine("Managed application execution is unavailable."); window.Terminal = shell.ReadOutput(); } } } InvalidateScene(TerminalViewport(window)); } private void RestartTerminalCaret(DesktopWindow window) { window.TerminalCaretStarted = _monotonicMilliseconds?.Invoke() ?? _feedbackNow; window.TerminalCaretVisible = true; } private void AdvanceTerminalCarets(long now) { if (ActiveWindow is not { Application: DesktopApplication.Terminal } window || window.TerminalProgramRunning) return; bool visible = Math.Max(0, now - window.TerminalCaretStarted) / 500 % 2 == 0; if (visible != window.TerminalCaretVisible) { window.TerminalCaretVisible = visible; InvalidateScene(TerminalViewport(window)); } } private static int TerminalColumns(DesktopBounds body) => Math.Max(1, (body.Width - 24) / TerminalCellWidth); private static int TerminalRows(DesktopBounds body) => Math.Max(1, (body.Height - 16) / TerminalRowHeight); private static List<(int Row, int Start, int Count)> TerminalRowsForDisplay(TerminalSnapshot frame, int columns) { var rows = new List<(int Row, int Start, int Count)>(); bool promptNeedsNewRow = false; for (int row = 0; row <= Math.Min(frame.CursorRow, frame.Rows - 1); row++) { int used = row == frame.CursorRow ? frame.CursorColumn : 0; for (int col = frame.Columns - 1; col >= 0; col--) { var cell = frame.GetCell(col, row); if (!string.IsNullOrWhiteSpace(cell.Text) || cell.Style.DisplayBackground != TerminalColor.ThemeBackground) { used = Math.Max(used, col + Math.Max(1, cell.DisplayWidth)); break; } } int chunks = Math.Max(1, (used + columns - 1) / columns); for (int chunk = 0; chunk < chunks; chunk++) rows.Add((row, chunk * columns, Math.Min(columns, frame.Columns - chunk * columns))); if (row == frame.CursorRow) promptNeedsNewRow = used > 0; } // Console.Write and carriage-return progress output need not end in a // newline. Preserve their row rather than clearing it for the prompt. if (promptNeedsNewRow) rows.Add((-1, 0, 0)); return rows; } private void DrawTerminal(DesktopWindow window) { var body = TerminalViewport(window); var shell = window.Shell!; var frame = window.Terminal!; // Never snapshot a worker-owned program during rendering. int columns = Math.Min(frame.Columns, TerminalColumns(body)), visibleRows = TerminalRows(body); var rows = TerminalRowsForDisplay(frame, columns); int maximumScroll = Math.Max(0, rows.Count - visibleRows); window.TerminalScrollRows = Math.Clamp(window.TerminalScrollRows, 0, maximumScroll); int first = Math.Max(0, rows.Count - visibleRows - window.TerminalScrollRows); _canvas.Fill(TerminalBackground, body.X, body.Y, body.Width, body.Height); for (int index = first; index < Math.Min(rows.Count, first + visibleRows); index++) { var line = rows[index]; int y = body.Y + 8 + (index - first) * TerminalRowHeight; for (int col = 0; col < line.Count; col++) { var cell = frame.GetCell(line.Start + col, line.Row); if (cell.IsContinuation) continue; int x = body.X + 12 + col * TerminalCellWidth; if (cell.Style.DisplayBackground != TerminalColor.ThemeBackground) _canvas.Fill(Rgb(cell.Style.DisplayBackground), x, y, TerminalCellWidth * Math.Max(1, cell.DisplayWidth), TerminalRowHeight); if (cell.Text.Length != 0 && cell.Text != " ") _canvas.Text(cell.Text, Rgb(cell.Style.DisplayForeground), x, y); if (cell.Style.Underline) _canvas.Line(Rgb(cell.Style.DisplayForeground), x, y + 17, x + TerminalCellWidth * Math.Max(1, cell.DisplayWidth) - 1, y + 17); } } if (!window.TerminalProgramRunning && window.TerminalScrollRows == 0) { int row = rows.Count - 1 - first; DrawTerminalInput(window, body.X + 12, body.Y + 8 + row * TerminalRowHeight, body.Width - 24); } } private static uint Rgb(TerminalColor color) => (uint)(color.Red << 16 | color.Green << 8 | color.Blue); private (string Prompt, int Start, string Visible) TerminalInputLayout(DesktopWindow window, int width) { var shell = window.Shell!; string prompt = shell.Prompt; int promptBudget = Math.Max(24, width / 2); while (prompt.Length > 1 && _canvas.TextWidth(prompt) > promptBudget) prompt = prompt[1..]; if (prompt.Length != shell.Prompt.Length) prompt = "…" + prompt; int available = Math.Max(8, width - _canvas.TextWidth(prompt) - 3); int start = Math.Clamp(window.TerminalInputStart, 0, shell.Caret); if (start > 0 && start < shell.Input.Length && char.IsLowSurrogate(shell.Input[start])) start--; while (start < shell.Caret && _canvas.TextWidth(shell.Input[start..shell.Caret]) > available) start += char.IsHighSurrogate(shell.Input[start]) && start + 1 < shell.Input.Length ? 2 : 1; window.TerminalInputStart = start; int end = start; while (end < shell.Input.Length) { int next = end + (char.IsHighSurrogate(shell.Input[end]) && end + 1 < shell.Input.Length ? 2 : 1); if (_canvas.TextWidth(shell.Input[start..next]) > available) break; end = next; } return (prompt, start, shell.Input[start..end]); } private void DrawTerminalInput(DesktopWindow window, int x, int y, int width) { var shell = window.Shell!; var line = TerminalInputLayout(window, width); _canvas.Fill(TerminalBackground, x, y, width, TerminalRowHeight); _canvas.Text(line.Prompt, TerminalForeground, x, y); int inputX = x + _canvas.TextWidth(line.Prompt); _canvas.Text(line.Visible, TerminalForeground, inputX, y); if (window == ActiveWindow && window.TerminalCaretVisible) { int caretX = inputX + _canvas.TextWidth(shell.Input[line.Start..shell.Caret]); if (shell.InsertMode) _canvas.Fill(TerminalForeground, caretX, y + 1, 2, 16); else _canvas.Fill(TerminalForeground, caretX, y + 16, TerminalCellWidth, 2); } } private void TerminalClick(DesktopWindow window, int x, int y) { if (window.TerminalProgramRunning || window.TerminalScrollRows != 0) return; var body = TerminalViewport(window); var frame = window.Terminal!; var shell = window.Shell!; int columns = Math.Min(frame.Columns, TerminalColumns(body)); var rows = TerminalRowsForDisplay(frame, columns); int inputY = body.Y + 8 + Math.Min(rows.Count - 1, TerminalRows(body) - 1) * TerminalRowHeight; if (y < inputY || y >= inputY + TerminalRowHeight) return; var line = TerminalInputLayout(window, body.Width - 24); int pixels = Math.Max(0, x - body.X - 12 - _canvas.TextWidth(line.Prompt)), index = line.Start; while (index < shell.Input.Length) { int next = index + (char.IsHighSurrogate(shell.Input[index]) && index + 1 < shell.Input.Length ? 2 : 1); int advance = _canvas.TextWidth(shell.Input[index..next]); if (pixels < (advance + 1) / 2) break; pixels -= advance; index = next; } shell.MoveCaret(index); RestartTerminalCaret(window); InvalidateScene(body); } private void ScrollTerminal(DesktopWindow window, int wheel) { var body = TerminalViewport(window); var frame = window.Terminal!; int maximum = Math.Max(0, TerminalRowsForDisplay(frame, Math.Min(frame.Columns, TerminalColumns(body))).Count - TerminalRows(body)); window.TerminalScrollRows = Math.Clamp(window.TerminalScrollRows + wheel * 3, 0, maximum); InvalidateScene(body); } private static void AppendTerminalSnapshot(TerminalShell shell, TerminalSnapshot snapshot) { var writer = shell.ProgramOutput; TerminalStyle? previous = null; for (int row = 0; row < snapshot.Rows; row++) { int last = snapshot.Columns - 1; while (last >= 0 && string.IsNullOrWhiteSpace(snapshot.GetCell(last, row).Text) && snapshot.GetCell(last, row).Style == TerminalStyle.Default) last--; if (last < 0 && row > snapshot.CursorRow) break; var text = new StringBuilder(); for (int col = 0; col <= last; col++) { var cell = snapshot.GetCell(col, row); if (cell.IsContinuation) continue; if (previous != cell.Style) { var style = cell.Style; var foreground = style.Foreground; var background = style.Background; text.Append("\u001b[0;38;2;").Append(foreground.Red).Append(';').Append(foreground.Green).Append(';').Append(foreground.Blue) .Append(";48;2;").Append(background.Red).Append(';').Append(background.Green).Append(';').Append(background.Blue); if (style.Bold) text.Append(";1"); if (style.Dim) text.Append(";2"); if (style.Italic) text.Append(";3"); if (style.Underline) text.Append(";4"); if (style.Inverse) text.Append(";7"); if (style.Hidden) text.Append(";8"); if (style.Strike) text.Append(";9"); text.Append('m'); previous = style; } text.Append(cell.Text); } writer.WriteLine(text.ToString()); } writer.Write("\u001b[0m"); writer.Complete(); } }