using System.Text;
using XFESystem.Console;
using XFESystem.Core;
namespace XFESystem.Desktop;
/// <summary>The desktop publishes this on its UI thread. The native adapter
/// queues execution and exclusively owns Output until it reports completion.</summary>
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<TerminalProgramRequest>? 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;
}
/// <summary>UI-thread completion only. The worker must finish writing before
/// its queued completion is drained. No worker touches desktop/window state.</summary>
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");
}
/// <summary>Short UI-owned output. Long/application output uses the request
/// writer on its worker, followed by RefreshTerminalOutput on the UI thread.</summary>
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);
}
/// <summary>Literal single-line paste. It does not execute pasted commands.</summary>
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();
}
}
using System.Text;
using XFESystem.Console;
using XFESystem.Core;
namespace XFESystem.Desktop;
/// <summary>The desktop publishes this on its UI thread. The native adapter
/// queues execution and exclusively owns Output until it reports completion.</summary>
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<TerminalProgramRequest>? 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;
}
/// <summary>UI-thread completion only. The worker must finish writing before
/// its queued completion is drained. No worker touches desktop/window state.</summary>
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");
}
/// <summary>Short UI-owned output. Long/application output uses the request
/// writer on its worker, followed by RefreshTerminalOutput on the UI thread.</summary>
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);
}
/// <summary>Literal single-line paste. It does not execute pasted commands.</summary>
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();
}
}