From a0bb798eab800f26cbaea194f64a46b9902a20dc Mon Sep 17 00:00:00 2001 From: keyldev Date: Mon, 5 Oct 2026 22:01:45 +0300 Subject: [PATCH 1/2] feat(ssh): terminal colors and themes - Emulator keeps 256-color and truecolor SGR (both ; and : spellings), plus dim, italic, underline, inverse, hidden, strikethrough and cursor visibility - Six built-in terminal themes resolved at draw time; pixel-snapped background runs - Settings: theme, cursor style, blinking cursor, history length, with a live preview --- .../Controls/TerminalView.cs | 308 +++++++++++++++--- src/OpenIPC.Viewer.App/Services/Localizer.cs | 28 ++ .../Services/UserSettings.cs | 6 + .../ViewModels/SettingsPageViewModel.cs | 70 +++- .../ViewModels/SshTerminalViewModel.cs | 13 +- .../Views/Dialogs/SshTerminalContent.axaml.cs | 7 + .../Views/Pages/SettingsPage.axaml | 48 +++ .../Ssh/Terminal/TerminalCell.cs | 86 ++++- .../Ssh/Terminal/TerminalEmulator.cs | 211 ++++++++---- .../Ssh/Terminal/TerminalTheme.cs | 136 ++++++++ .../Ssh/TerminalEmulatorTests.cs | 4 +- .../Ssh/TerminalRenditionTests.cs | 178 ++++++++++ 12 files changed, 985 insertions(+), 110 deletions(-) create mode 100644 src/OpenIPC.Viewer.Core/Ssh/Terminal/TerminalTheme.cs create mode 100644 tests/OpenIPC.Viewer.Core.Tests/Ssh/TerminalRenditionTests.cs diff --git a/src/OpenIPC.Viewer.App/Controls/TerminalView.cs b/src/OpenIPC.Viewer.App/Controls/TerminalView.cs index 17f881b..b386492 100644 --- a/src/OpenIPC.Viewer.App/Controls/TerminalView.cs +++ b/src/OpenIPC.Viewer.App/Controls/TerminalView.cs @@ -19,19 +19,50 @@ namespace OpenIPC.Viewer.App.Controls; /// public sealed class TerminalView : Control { - // Fixed 16-color ANSI palette (terminals don't follow the app theme). - private static readonly Color[] Palette = + public static readonly StyledProperty ColorThemeProperty = + AvaloniaProperty.Register(nameof(ColorTheme), TerminalTheme.Default, + // A binding that briefly resolves to nothing (a combo box between selections) must not + // leave the renderer without a palette. + coerce: (_, theme) => theme ?? TerminalTheme.Default); + + /// The palette the grid is drawn with (terminals don't follow the app theme). + public TerminalTheme ColorTheme { - Color.Parse("#1e1e1e"), Color.Parse("#cd3131"), Color.Parse("#0dbc79"), Color.Parse("#e5e510"), - Color.Parse("#2472c8"), Color.Parse("#bc3fbc"), Color.Parse("#11a8cd"), Color.Parse("#cccccc"), - Color.Parse("#666666"), Color.Parse("#f14c4c"), Color.Parse("#23d18b"), Color.Parse("#f5f543"), - Color.Parse("#3b8eea"), Color.Parse("#d670d6"), Color.Parse("#29b8db"), Color.Parse("#ffffff"), - }; + get => GetValue(ColorThemeProperty); + set => SetValue(ColorThemeProperty, value); + } + + public static readonly StyledProperty CursorStyleProperty = + AvaloniaProperty.Register(nameof(CursorStyle)); + + public TerminalCursorStyle CursorStyle + { + get => GetValue(CursorStyleProperty); + set => SetValue(CursorStyleProperty, value); + } + + public static readonly StyledProperty CursorBlinkProperty = + AvaloniaProperty.Register(nameof(CursorBlink)); - private static readonly Color DefaultFg = Color.Parse("#d4d4d4"); - private static readonly Color DefaultBg = Color.Parse("#0c0f14"); - private static readonly IBrush DefaultFgBrush = new SolidColorBrush(DefaultFg); - private static readonly IBrush CursorBrush = new SolidColorBrush(Color.Parse("#d4d4d4")); + public bool CursorBlink + { + get => GetValue(CursorBlinkProperty); + set => SetValue(CursorBlinkProperty, value); + } + + public static readonly StyledProperty FitToEmulatorProperty = + AvaloniaProperty.Register(nameof(FitToEmulator)); + + /// + /// Size the control to the emulator's grid instead of filling the space it is given — for a + /// fixed-size sample such as the settings preview. A live terminal leaves this off and + /// resizes the emulator to fit the control instead. + /// + public bool FitToEmulator + { + get => GetValue(FitToEmulatorProperty); + set => SetValue(FitToEmulatorProperty, value); + } public static readonly StyledProperty EmulatorProperty = AvaloniaProperty.Register(nameof(Emulator)); @@ -73,13 +104,29 @@ public double TerminalFontSize // If any of them resolves we get real monospace metrics; DrawRow below is correct either way. private const string MonoFamilies = "Cascadia Mono,Consolas,Menlo,Droid Sans Mono,Roboto Mono,monospace"; - private readonly Typeface _typeface = new(new FontFamily(MonoFamilies)); - private readonly Typeface _boldTypeface = - new(new FontFamily(MonoFamilies), weight: FontWeight.Bold); + private static readonly FontFamily Mono = new(MonoFamilies); + private readonly Typeface _typeface = new(Mono); + private readonly Typeface _boldTypeface = new(Mono, weight: FontWeight.Bold); + private readonly Typeface _italicTypeface = new(Mono, FontStyle.Italic); + private readonly Typeface _boldItalicTypeface = new(Mono, FontStyle.Italic, FontWeight.Bold); + + // Only the attributes that change the glyph itself; colors are part of the key separately and + // lines (underline, strikethrough) are drawn over the glyph. + private const TerminalAttributes GlyphStyle = TerminalAttributes.Bold | TerminalAttributes.Italic; private double _cellWidth; private double _cellHeight; - private readonly Dictionary<(char Char, byte Foreground, bool Bold), FormattedText> _glyphs = new(); + private readonly Dictionary<(char Char, uint Rgb, TerminalAttributes Style), FormattedText> _glyphs = new(); + // One brush per color: 256-color and truecolor output can use many, and allocating a brush per + // cell per repaint is what the cache is for. + private readonly Dictionary _brushes = new(); + + // Blink phase. The cursor is drawn while this is true; typing or new output sets it back so + // the cursor never vanishes at the moment you are looking for it. + private DispatcherTimer? _blinkTimer; + private bool _blinkOn = true; + private static readonly TimeSpan BlinkInterval = TimeSpan.FromMilliseconds(530); + private TerminalEmulator? _subscribed; // Rows the view is scrolled back by; 0 is the live screen. private int _scrollOffset; @@ -120,8 +167,6 @@ private enum Handle { None, Start, End } // radius wins, so the handles stay catchable without hijacking taps elsewhere. private const double HandleTouchSlop = 30; - private static readonly IBrush SelectionBrush = - new SolidColorBrush(Color.FromArgb(0x66, 0x3b, 0x8e, 0xea)); private static readonly IBrush HandleBrush = new SolidColorBrush(Color.Parse("#3b8eea")); public TerminalView() @@ -142,10 +187,38 @@ protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs chang _glyphs.Clear(); EnsureMetrics(); RecomputeGrid(); + InvalidateMeasure(); + InvalidateVisual(); + return; + } + + if (change.Property == ColorThemeProperty) + { + // Cached glyphs and brushes are keyed by resolved color; the old theme's are dead weight. + _glyphs.Clear(); + _brushes.Clear(); InvalidateVisual(); return; } + if (change.Property == CursorStyleProperty) + { + InvalidateVisual(); + return; + } + + if (change.Property == CursorBlinkProperty) + { + UpdateBlinkTimer(); + return; + } + + if (change.Property == FitToEmulatorProperty) + { + InvalidateMeasure(); + return; + } + if (change.Property != EmulatorProperty) return; @@ -155,21 +228,95 @@ protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs chang if (_subscribed is not null) _subscribed.Updated += OnEmulatorUpdated; RecomputeGrid(); + InvalidateMeasure(); InvalidateVisual(); } + protected override Size MeasureOverride(Size availableSize) + { + if (!FitToEmulator || Emulator is not { } emu) + return base.MeasureOverride(availableSize); + return new Size(emu.Columns * _cellWidth, emu.Rows * _cellHeight); + } + + protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) + { + base.OnAttachedToVisualTree(e); + UpdateBlinkTimer(); + } + + protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e) + { + base.OnDetachedFromVisualTree(e); + _blinkTimer?.Stop(); + } + + private void UpdateBlinkTimer() + { + _blinkOn = true; + if (!CursorBlink || VisualRoot is null) + { + _blinkTimer?.Stop(); + InvalidateVisual(); + return; + } + + if (_blinkTimer is null) + { + _blinkTimer = new DispatcherTimer { Interval = BlinkInterval }; + _blinkTimer.Tick += (_, _) => + { + _blinkOn = !_blinkOn; + InvalidateVisual(); + }; + } + _blinkTimer.Stop(); + _blinkTimer.Start(); + } + + // Show the cursor now and start the blink cycle over. + private void WakeCursor() + { + if (!CursorBlink || _blinkTimer is null) + return; + _blinkOn = true; + _blinkTimer.Stop(); + _blinkTimer.Start(); + } + // Updated fires on the UI thread (the VM marshals shell data), so a direct // invalidate is safe. - private void OnEmulatorUpdated() => InvalidateVisual(); + private void OnEmulatorUpdated() + { + WakeCursor(); + InvalidateVisual(); + } private void EnsureMetrics() { var sample = new FormattedText("M", CultureInfo.InvariantCulture, FlowDirection.LeftToRight, - _typeface, TerminalFontSize, DefaultFgBrush); + _typeface, TerminalFontSize, Brushes.White); _cellWidth = sample.Width; _cellHeight = sample.Height; } + private IBrush BrushFor(uint argb) + { + if (_brushes.TryGetValue(argb, out var brush)) + return brush; + if (_brushes.Count > 4096) + _brushes.Clear(); + brush = new SolidColorBrush(Color.FromUInt32(argb)); + _brushes[argb] = brush; + return brush; + } + + private IBrush OpaqueBrush(uint rgb) => BrushFor(0xFF00_0000 | rgb); + + // A coordinate rounded to the nearest physical pixel at the render scale of this frame. + private double _renderScale = 1; + private double Snap(double v) => Math.Round(v * _renderScale) / _renderScale; + private void RecomputeGrid() { if (_cellWidth <= 0 || _cellHeight <= 0) @@ -188,7 +335,8 @@ private void RecomputeGrid() public override void Render(DrawingContext context) { base.Render(context); - context.FillRectangle(new SolidColorBrush(DefaultBg), new Rect(Bounds.Size)); + _renderScale = TopLevel.GetTopLevel(this)?.RenderScaling ?? 1; + context.FillRectangle(OpaqueBrush(ColorTheme.Background), new Rect(Bounds.Size)); var emu = Emulator; if (emu is null) @@ -712,21 +860,34 @@ protected override void OnHolding(HoldingRoutedEventArgs e) private void DrawRow(DrawingContext context, TerminalCell[] cells, double y, int selStart, int selEnd) { - // Background fills first (only non-default cells). - for (var c = 0; c < cells.Length; c++) + var theme = ColorTheme; + + // Background fills first — only where a cell's background differs from the screen's, + // which is also what makes an inverse cell on the default colors show up. A run of cells + // sharing a color is one rectangle, and every edge is snapped to a device pixel: cell + // widths are fractional, and abutting rectangles with antialiased edges left a visible + // seam between every pair of colored cells. + var top = Snap(y); + var bottom = Snap(y + _cellHeight); + for (var c = 0; c < cells.Length;) { - var cell = cells[c]; - if (cell.Background == TerminalPalette.DefaultBackground) - continue; - context.FillRectangle( - new SolidColorBrush(Palette[cell.Background & 0x0F]), - new Rect(c * _cellWidth, y, _cellWidth, _cellHeight)); + var (_, bg) = theme.ColorsOf(cells[c]); + var end = c + 1; + while (end < cells.Length && theme.ColorsOf(cells[end]).Background == bg) + end++; + if (bg != theme.Background) + { + var left = Snap(c * _cellWidth); + context.FillRectangle(OpaqueBrush(bg), + new Rect(left, top, Snap(end * _cellWidth) - left, bottom - top)); + } + c = end; } // Selection sits over the cell backgrounds and under the glyphs, so highlighted text // stays readable and coloured output keeps its own background showing through. if (selEnd > selStart) - context.FillRectangle(SelectionBrush, + context.FillRectangle(BrushFor(theme.Selection), new Rect(selStart * _cellWidth, y, (selEnd - selStart) * _cellWidth, _cellHeight)); // One character at a time, each pinned to its own cell. @@ -744,41 +905,100 @@ private void DrawRow(DrawingContext context, TerminalCell[] cells, double y, int for (var c = 0; c < cells.Length; c++) { var cell = cells[c]; + var (fg, _) = theme.ColorsOf(cell); + var x = c * _cellWidth; + // Everything at or below U+0020 is blank or a control code: nothing to paint, and a - // default-constructed cell carries NUL rather than a space. - if (cell.Char <= ' ') - continue; - context.DrawText(GlyphFor(cell), new Point(c * _cellWidth, y)); + // default-constructed cell carries NUL rather than a space. Hidden text (SGR 8) has + // already been given the background color, so it paints as nothing too. + if (cell.Char > ' ' && (cell.Attributes & TerminalAttributes.Hidden) == 0) + context.DrawText(GlyphFor(cell.Char, fg, cell.Attributes), new Point(x, y)); + + DrawLines(context, cell.Attributes, fg, x, y); } } - private FormattedText GlyphFor(TerminalCell cell) + // Underline and strikethrough are drawn by hand rather than as text decorations: they have to + // run under blanks too (an underlined field in a form is mostly spaces) and join up from one + // cell to the next. + private void DrawLines(DrawingContext context, TerminalAttributes attrs, uint fg, double x, double y) + { + if ((attrs & (TerminalAttributes.Underline | TerminalAttributes.Strikethrough)) == 0 || + (attrs & TerminalAttributes.Hidden) != 0) + return; + + var thickness = Math.Max(1, Math.Round(TerminalFontSize / 14)); + var brush = OpaqueBrush(fg); + if ((attrs & TerminalAttributes.Underline) != 0) + context.FillRectangle(brush, new Rect(x, y + _cellHeight - thickness * 2, _cellWidth, thickness)); + if ((attrs & TerminalAttributes.Strikethrough) != 0) + context.FillRectangle(brush, new Rect(x, y + _cellHeight / 2, _cellWidth, thickness)); + } + + private FormattedText GlyphFor(char ch, uint rgb, TerminalAttributes attrs) { - var key = (cell.Char, cell.Foreground, cell.Bold); + var style = attrs & GlyphStyle; + var key = (ch, rgb, style); if (_glyphs.TryGetValue(key, out var cached)) return cached; // Cheap insurance against a pathological stream of distinct glyphs; the working set of a - // shell session is a few hundred entries. + // shell session is a few hundred entries, more with 256-color or truecolor output. if (_glyphs.Count > 4096) _glyphs.Clear(); - var brush = cell.Foreground == TerminalPalette.DefaultForeground - ? DefaultFgBrush - : new SolidColorBrush(Palette[cell.Foreground & 0x0F]); - var text = new FormattedText(cell.Char.ToString(), CultureInfo.InvariantCulture, - FlowDirection.LeftToRight, cell.Bold ? _boldTypeface : _typeface, TerminalFontSize, brush); + var typeface = style switch + { + TerminalAttributes.Bold => _boldTypeface, + TerminalAttributes.Italic => _italicTypeface, + GlyphStyle => _boldItalicTypeface, + _ => _typeface, + }; + var text = new FormattedText(ch.ToString(), CultureInfo.InvariantCulture, + FlowDirection.LeftToRight, typeface, TerminalFontSize, OpaqueBrush(rgb)); _glyphs[key] = text; return text; } private void DrawCursor(DrawingContext context, TerminalEmulator emu) { + if (!emu.CursorVisible || (CursorBlink && !_blinkOn)) + return; + + var theme = ColorTheme; var x = emu.CursorColumn * _cellWidth; var y = emu.CursorRow * _cellHeight; - // Hollow block so the character under the cursor stays readable. - context.DrawRectangle(null, new Pen(CursorBrush, 1), - new Rect(x, y, _cellWidth, _cellHeight)); + var cursor = OpaqueBrush(theme.Cursor); + var thickness = Math.Max(2, Math.Round(TerminalFontSize / 7)); + + switch (CursorStyle) + { + case TerminalCursorStyle.Block: + { + // Solid box, and the character under it redrawn in the screen color so it stays + // readable — the way a hardware terminal inverts the cell. + context.FillRectangle(cursor, new Rect(x, y, _cellWidth, _cellHeight)); + // The column runs one past the last cell while a wrap is pending. + var row = emu.GetRow(emu.CursorRow); + var cell = emu.CursorColumn < row.Length ? row[emu.CursorColumn] : TerminalCell.Blank; + if (cell.Char > ' ') + { + var (_, bg) = theme.ColorsOf(cell); + context.DrawText(GlyphFor(cell.Char, bg, cell.Attributes), new Point(x, y)); + } + break; + } + case TerminalCursorStyle.Bar: + context.FillRectangle(cursor, new Rect(x, y, thickness, _cellHeight)); + break; + case TerminalCursorStyle.Underline: + context.FillRectangle(cursor, new Rect(x, y + _cellHeight - thickness, _cellWidth, thickness)); + break; + default: + // Hollow box so the character under the cursor stays readable. + context.DrawRectangle(null, new Pen(cursor, 1), new Rect(x, y, _cellWidth, _cellHeight)); + break; + } } protected override void OnTextInput(TextInputEventArgs e) diff --git a/src/OpenIPC.Viewer.App/Services/Localizer.cs b/src/OpenIPC.Viewer.App/Services/Localizer.cs index 2731cfd..709f040 100644 --- a/src/OpenIPC.Viewer.App/Services/Localizer.cs +++ b/src/OpenIPC.Viewer.App/Services/Localizer.cs @@ -258,6 +258,20 @@ private static LangCode DetectSystem() ["Settings.Ssh.StrictHostKey"] = "Strict host-key checking", ["Settings.Ssh.DefaultPort"] = "Default SSH port", ["Settings.Ssh.TerminalFontSize"] = "Terminal font size", + ["Settings.Ssh.Theme"] = "Terminal theme", + ["Settings.Ssh.Theme.default"] = "Classic", + ["Settings.Ssh.Theme.gruvbox-dark"] = "Gruvbox Dark", + ["Settings.Ssh.Theme.dracula"] = "Dracula", + ["Settings.Ssh.Theme.solarized-dark"] = "Solarized Dark", + ["Settings.Ssh.Theme.solarized-light"] = "Solarized Light", + ["Settings.Ssh.Theme.phosphor"] = "Green phosphor", + ["Settings.Ssh.CursorStyle"] = "Cursor", + ["Settings.Ssh.Cursor.Outline"] = "Outline", + ["Settings.Ssh.Cursor.Block"] = "Block", + ["Settings.Ssh.Cursor.Bar"] = "Bar", + ["Settings.Ssh.Cursor.Underline"] = "Underline", + ["Settings.Ssh.CursorBlink"] = "Blinking cursor", + ["Settings.Ssh.Scrollback"] = "History lines", ["Settings.Ssh.MajesticPath"] = "majestic.yaml path", ["Settings.Ssh.ResetHostKeys"] = "Forget all host keys", ["Settings.Ssh.HostKeysCleared"] = "All pinned host keys were forgotten.", @@ -899,6 +913,20 @@ private static LangCode DetectSystem() ["Settings.Ssh.StrictHostKey"] = "Строгая проверка host-key", ["Settings.Ssh.DefaultPort"] = "SSH-порт по умолчанию", ["Settings.Ssh.TerminalFontSize"] = "Размер шрифта терминала", + ["Settings.Ssh.Theme"] = "Тема терминала", + ["Settings.Ssh.Theme.default"] = "Классическая", + ["Settings.Ssh.Theme.gruvbox-dark"] = "Gruvbox Dark", + ["Settings.Ssh.Theme.dracula"] = "Dracula", + ["Settings.Ssh.Theme.solarized-dark"] = "Solarized Dark", + ["Settings.Ssh.Theme.solarized-light"] = "Solarized Light", + ["Settings.Ssh.Theme.phosphor"] = "Зелёный люминофор", + ["Settings.Ssh.CursorStyle"] = "Курсор", + ["Settings.Ssh.Cursor.Outline"] = "Контур", + ["Settings.Ssh.Cursor.Block"] = "Блок", + ["Settings.Ssh.Cursor.Bar"] = "Черта", + ["Settings.Ssh.Cursor.Underline"] = "Подчёркивание", + ["Settings.Ssh.CursorBlink"] = "Мигающий курсор", + ["Settings.Ssh.Scrollback"] = "Строк в истории", ["Settings.Ssh.MajesticPath"] = "Путь к majestic.yaml", ["Settings.Ssh.ResetHostKeys"] = "Забыть все host-ключи", ["Settings.Ssh.HostKeysCleared"] = "Все запомненные host-ключи забыты.", diff --git a/src/OpenIPC.Viewer.App/Services/UserSettings.cs b/src/OpenIPC.Viewer.App/Services/UserSettings.cs index bb14822..c122e28 100644 --- a/src/OpenIPC.Viewer.App/Services/UserSettings.cs +++ b/src/OpenIPC.Viewer.App/Services/UserSettings.cs @@ -48,6 +48,12 @@ public sealed record UserSettings( // only ever appears when the user asks for it with the key-bar button — which is what you // want while selecting text, because the keyboard's inset resizes the grid under you. bool SshTerminalAutoKeyboard = true, + // Terminal look: a TerminalTheme.Id, a TerminalCursorStyle name (outline = the original + // hollow box), blink, and how many rows of history the terminal keeps. + string SshTerminalTheme = "default", + string SshTerminalCursorStyle = "outline", + bool SshTerminalCursorBlink = false, + int SshTerminalScrollback = 1000, string MajesticConfigPath = "/etc/majestic.yaml", // Local AI analytics (Phase 15.2). "auto" lets the detector pick the // platform execution provider with a CPU fallback; "force-cpu" pins CPU. diff --git a/src/OpenIPC.Viewer.App/ViewModels/SettingsPageViewModel.cs b/src/OpenIPC.Viewer.App/ViewModels/SettingsPageViewModel.cs index 0ac2d52..d5b64c0 100644 --- a/src/OpenIPC.Viewer.App/ViewModels/SettingsPageViewModel.cs +++ b/src/OpenIPC.Viewer.App/ViewModels/SettingsPageViewModel.cs @@ -13,6 +13,7 @@ using OpenIPC.Viewer.Core.Onvif.Discovery; using OpenIPC.Viewer.Core.Platform; using OpenIPC.Viewer.Core.Ssh; +using OpenIPC.Viewer.Core.Ssh.Terminal; namespace OpenIPC.Viewer.App.ViewModels; @@ -69,10 +70,46 @@ public sealed partial class SettingsPageViewModel : ViewModelBase, IBackNavigabl // SSH section (Phase 13). [ObservableProperty] private bool _sshStrictHostKey = true; [ObservableProperty] private int _sshDefaultPort = 22; - [ObservableProperty] private int _sshTerminalFontSize = 14; + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(SshTerminalPreviewFontSize))] + private int _sshTerminalFontSize = 14; [ObservableProperty] private string _majesticConfigPath = "/etc/majestic.yaml"; [ObservableProperty] private bool _hostKeysJustCleared; + // Terminal look. The preview is a fixed sample screen drawn by the real TerminalView, so + // what the user picks here is exactly what the terminal will show. + [ObservableProperty] private TerminalThemeOption? _sshTerminalTheme; + [ObservableProperty] private TerminalCursorStyleOption? _sshTerminalCursorStyle; + [ObservableProperty] private bool _sshTerminalCursorBlink; + [ObservableProperty] private int _sshTerminalScrollback = TerminalEmulator.DefaultScrollbackLimit; + public IReadOnlyList TerminalThemeOptions { get; } + public IReadOnlyList TerminalCursorStyleOptions { get; } + public TerminalEmulator TerminalPreview { get; } = CreateTerminalPreview(); + public double SshTerminalPreviewFontSize => SshTerminalFontSize; + + // A prompt, a colored `ls`, both rows of the ANSI palette and the renditions — enough to + // judge a theme by. Kept within 44 columns so it fits a phone. + private static TerminalEmulator CreateTerminalPreview() + { + const string prompt = "\x1b[1;32mroot@openipc\x1b[0m:\x1b[1;34m~\x1b[0m# "; + var preview = new TerminalEmulator(44, 7); + preview.Feed( + prompt + "ls\r\n" + + "\x1b[1;34mbin\x1b[0m \x1b[1;36mlib\x1b[0m \x1b[1;32mmajestic\x1b[0m majestic.yaml\r\n" + + "\x1b[31mred \x1b[32mgreen \x1b[33myellow \x1b[34mblue \x1b[35mmagenta \x1b[36mcyan\x1b[0m\r\n" + + "\x1b[91mred \x1b[92mgreen \x1b[93myellow \x1b[94mblue \x1b[95mmagenta \x1b[96mcyan\x1b[0m\r\n" + + "\x1b[1mbold\x1b[0m \x1b[2mdim\x1b[0m \x1b[3mitalic\x1b[0m \x1b[4munderline\x1b[0m \x1b[7m inverse \x1b[0m\r\n"); + for (var i = 0; i < 44; i++) + { + // A truecolor ramp, to show 24-bit output comes through. + var r = 255 - i * 255 / 43; + var b = i * 255 / 43; + preview.Feed($"\x1b[48;2;{r};64;{b}m "); + } + preview.Feed("\x1b[0m\r\n" + prompt); + return preview; + } + // Notifications (Phase 19.3). [ObservableProperty] private bool _notificationsEnabled = true; [ObservableProperty] private bool _notifyOnMotion = true; @@ -226,6 +263,17 @@ public SettingsPageViewModel( }; StartupLayoutOptions.Add(new StartupLayoutOption(Localizer.Instance["Settings.Appearance.StartupLayout.LastUsed"], 0)); + TerminalThemeOptions = TerminalTheme.BuiltIn + .Select(t => new TerminalThemeOption(Localizer.Instance[$"Settings.Ssh.Theme.{t.Id}"], t)) + .ToList(); + TerminalCursorStyleOptions = new[] + { + TerminalCursorStyle.Outline, TerminalCursorStyle.Block, + TerminalCursorStyle.Bar, TerminalCursorStyle.Underline, + } + .Select(c => new TerminalCursorStyleOption(Localizer.Instance[$"Settings.Ssh.Cursor.{c}"], c)) + .ToList(); + Load(); } @@ -261,6 +309,14 @@ private void Load() SshStrictHostKey = s.SshStrictHostKey; SshDefaultPort = s.SshDefaultPort; SshTerminalFontSize = s.SshTerminalFontSize; + var theme = TerminalTheme.ById(s.SshTerminalTheme); + SshTerminalTheme = TerminalThemeOptions.FirstOrDefault(o => o.Theme == theme) ?? TerminalThemeOptions[0]; + SshTerminalCursorStyle = + TerminalCursorStyleOptions.FirstOrDefault(o => + string.Equals(o.Style.ToString(), s.SshTerminalCursorStyle, StringComparison.OrdinalIgnoreCase)) + ?? TerminalCursorStyleOptions[0]; + SshTerminalCursorBlink = s.SshTerminalCursorBlink; + SshTerminalScrollback = s.SshTerminalScrollback; MajesticConfigPath = s.MajesticConfigPath; NotificationsEnabled = s.NotificationsEnabled; NotifyOnMotion = s.NotifyOnMotion; @@ -296,6 +352,10 @@ private void Load() partial void OnSshStrictHostKeyChanged(bool value) => Persist(); partial void OnSshDefaultPortChanged(int value) => Persist(); partial void OnSshTerminalFontSizeChanged(int value) => Persist(); + partial void OnSshTerminalThemeChanged(TerminalThemeOption? value) => Persist(); + partial void OnSshTerminalCursorStyleChanged(TerminalCursorStyleOption? value) => Persist(); + partial void OnSshTerminalCursorBlinkChanged(bool value) => Persist(); + partial void OnSshTerminalScrollbackChanged(int value) => Persist(); partial void OnMajesticConfigPathChanged(string value) => Persist(); partial void OnNotificationsEnabledChanged(bool value) => Persist(); partial void OnNotifyOnMotionChanged(bool value) => Persist(); @@ -331,6 +391,12 @@ private void Persist() SshStrictHostKey = SshStrictHostKey, SshDefaultPort = SshDefaultPort, SshTerminalFontSize = SshTerminalFontSize, + SshTerminalTheme = SshTerminalTheme?.Theme.Id ?? TerminalTheme.DefaultId, + SshTerminalCursorStyle = (SshTerminalCursorStyle?.Style ?? TerminalCursorStyle.Outline) + .ToString().ToLowerInvariant(), + SshTerminalCursorBlink = SshTerminalCursorBlink, + SshTerminalScrollback = Math.Clamp(SshTerminalScrollback, + TerminalEmulator.MinScrollbackLimit, TerminalEmulator.MaxScrollbackLimit), MajesticConfigPath = MajesticConfigPath, NotificationsEnabled = NotificationsEnabled, NotifyOnMotion = NotifyOnMotion, @@ -579,3 +645,5 @@ public sealed record IdleTimeoutOption(string Display, int Minutes); // Value is the persisted UserSettings.StartupPage; Id is the LayoutId (0 = last used). public sealed record StartPageOption(string Display, string Value); public sealed record StartupLayoutOption(string Display, int Id); +public sealed record TerminalThemeOption(string Display, TerminalTheme Theme); +public sealed record TerminalCursorStyleOption(string Display, TerminalCursorStyle Style); diff --git a/src/OpenIPC.Viewer.App/ViewModels/SshTerminalViewModel.cs b/src/OpenIPC.Viewer.App/ViewModels/SshTerminalViewModel.cs index df14d8c..b2f2948 100644 --- a/src/OpenIPC.Viewer.App/ViewModels/SshTerminalViewModel.cs +++ b/src/OpenIPC.Viewer.App/ViewModels/SshTerminalViewModel.cs @@ -31,9 +31,12 @@ public sealed partial class SshTerminalViewModel : ViewModelBase, IAsyncDisposab private int _columns = 80; private int _rows = 24; - public TerminalEmulator Emulator { get; } = new(80, 24); + public TerminalEmulator Emulator { get; } public string Title => $"SSH — {_camera.Name}"; public double FontSize { get; } + public TerminalTheme Theme { get; } + public TerminalCursorStyle CursorStyle { get; } + public bool CursorBlink { get; } /// Whether a tap on the terminal is allowed to raise the soft keyboard. public bool AutoKeyboard => _settings.Current.SshTerminalAutoKeyboard; @@ -59,6 +62,14 @@ public SshTerminalViewModel( _settings = settings; FontSize = fontSize >= 8 ? fontSize : 14; _logger = logger; + + var s = settings.Current; + Emulator = new TerminalEmulator(80, 24, s.SshTerminalScrollback); + Theme = TerminalTheme.ById(s.SshTerminalTheme); + CursorStyle = Enum.TryParse(s.SshTerminalCursorStyle, ignoreCase: true, out var style) + ? style + : TerminalCursorStyle.Outline; + CursorBlink = s.SshTerminalCursorBlink; } public async Task ConnectAsync() diff --git a/src/OpenIPC.Viewer.App/Views/Dialogs/SshTerminalContent.axaml.cs b/src/OpenIPC.Viewer.App/Views/Dialogs/SshTerminalContent.axaml.cs index 832bc40..18ee3b2 100644 --- a/src/OpenIPC.Viewer.App/Views/Dialogs/SshTerminalContent.axaml.cs +++ b/src/OpenIPC.Viewer.App/Views/Dialogs/SshTerminalContent.axaml.cs @@ -7,6 +7,7 @@ using Avalonia.Input; using Avalonia.Input.Platform; using Avalonia.Interactivity; +using Avalonia.Media; using OpenIPC.Viewer.App.Controls; using OpenIPC.Viewer.App.Services; using OpenIPC.Viewer.App.ViewModels; @@ -116,6 +117,12 @@ protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) _term.Emulator = vm.Emulator; _term.TerminalFontSize = vm.FontSize; + _term.ColorTheme = vm.Theme; + _term.CursorStyle = vm.CursorStyle; + _term.CursorBlink = vm.CursorBlink; + // The strip around the grid (whatever is left when the size isn't a whole number of + // cells) takes the theme's background rather than the original dark one. + Background = new SolidColorBrush(Color.FromUInt32(0xFF00_0000 | vm.Theme.Background)); _term.Input += (_, text) => SendInput(text); _term.PasteRequested += (_, _) => _ = PasteAsync(); _term.CopyRequested += (_, _) => _ = CopyAsync(_term.GetSelectedText()); diff --git a/src/OpenIPC.Viewer.App/Views/Pages/SettingsPage.axaml b/src/OpenIPC.Viewer.App/Views/Pages/SettingsPage.axaml index 83d1640..8010d0a 100644 --- a/src/OpenIPC.Viewer.App/Views/Pages/SettingsPage.axaml +++ b/src/OpenIPC.Viewer.App/Views/Pages/SettingsPage.axaml @@ -3,6 +3,7 @@ xmlns:vm="using:OpenIPC.Viewer.App.ViewModels" xmlns:svc="using:OpenIPC.Viewer.App.Services" xmlns:conv="using:OpenIPC.Viewer.App.Converters" + xmlns:controls="using:OpenIPC.Viewer.App.Controls" x:Class="OpenIPC.Viewer.App.Views.Pages.SettingsPage" x:DataType="vm:SettingsPageViewModel"> @@ -405,6 +406,53 @@ Minimum="8" Maximum="32" Increment="1" FormatString="0" MinWidth="120" /> + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/OpenIPC.Viewer.Core/Ssh/Terminal/TerminalCell.cs b/src/OpenIPC.Viewer.Core/Ssh/Terminal/TerminalCell.cs index c38831b..e296f21 100644 --- a/src/OpenIPC.Viewer.Core/Ssh/Terminal/TerminalCell.cs +++ b/src/OpenIPC.Viewer.Core/Ssh/Terminal/TerminalCell.cs @@ -1,19 +1,91 @@ +using System; + namespace OpenIPC.Viewer.Core.Ssh.Terminal; /// One character cell in the terminal grid. -public readonly record struct TerminalCell(char Char, byte Foreground, byte Background, bool Bold) +public readonly record struct TerminalCell( + char Char, TerminalColor Foreground, TerminalColor Background, TerminalAttributes Attributes) { public static readonly TerminalCell Blank = - new(' ', TerminalPalette.DefaultForeground, TerminalPalette.DefaultBackground, false); + new(' ', TerminalColor.Default, TerminalColor.Default, TerminalAttributes.None); + + public bool Bold => (Attributes & TerminalAttributes.Bold) != 0; +} + +/// SGR rendition flags carried by a . +[Flags] +public enum TerminalAttributes : byte +{ + None = 0, + Bold = 1, + Dim = 2, + Italic = 4, + Underline = 8, + Inverse = 16, + Hidden = 32, + Strikethrough = 64, } /// -/// Color indices used by . 0–7 are the standard ANSI -/// colors, 8–15 their bright variants; two sentinels mark "use the theme -/// default". The renderer maps these to brushes. +/// A cell color as the remote asked for it: the theme default, a palette index (0–15 are the +/// theme's ANSI colors, 16–255 the fixed xterm cube and gray ramp), or a 24-bit RGB value. +/// Turning it into a pixel is the theme's job (), so a +/// screen keeps its meaning when the user switches themes. /// +public readonly record struct TerminalColor +{ + private const uint KindMask = 0xFF00_0000; + private const uint IndexedKind = 0x0100_0000; + private const uint RgbKind = 0x0200_0000; + + private readonly uint _value; + + private TerminalColor(uint value) => _value = value; + + public static TerminalColor Default => default; + + public static TerminalColor FromIndex(int index) => new(IndexedKind | (uint)(index & 0xFF)); + + public static TerminalColor FromRgb(int r, int g, int b) => + new(RgbKind | (Channel(r) << 16) | (Channel(g) << 8) | Channel(b)); + + public bool IsDefault => _value == 0; + public bool IsIndexed => (_value & KindMask) == IndexedKind; + public bool IsRgb => (_value & KindMask) == RgbKind; + + /// Palette index; meaningful when . + public int Index => (int)(_value & 0xFF); + + /// 0xRRGGBB; meaningful when . + public uint Rgb => _value & 0x00FF_FFFF; + + private static uint Channel(int v) => (uint)Math.Clamp(v, 0, 255); +} + +/// The fixed part of the xterm 256-color palette. public static class TerminalPalette { - public const byte DefaultForeground = 0xFF; - public const byte DefaultBackground = 0xFE; + private static readonly int[] CubeSteps = { 0, 95, 135, 175, 215, 255 }; + + /// + /// 0xRRGGBB for palette indices 16–255: a 6×6×6 color cube followed by a 24-step gray ramp. + /// Indices 0–15 are theme colors and are not answered here. + /// + public static uint Xterm256(int index) + { + if (index is < 16 or > 255) + throw new ArgumentOutOfRangeException(nameof(index)); + + if (index >= 232) + { + var gray = (uint)(8 + (index - 232) * 10); + return (gray << 16) | (gray << 8) | gray; + } + + var cube = index - 16; + var r = (uint)CubeSteps[cube / 36]; + var g = (uint)CubeSteps[cube / 6 % 6]; + var b = (uint)CubeSteps[cube % 6]; + return (r << 16) | (g << 8) | b; + } } diff --git a/src/OpenIPC.Viewer.Core/Ssh/Terminal/TerminalEmulator.cs b/src/OpenIPC.Viewer.Core/Ssh/Terminal/TerminalEmulator.cs index 0786dda..6867564 100644 --- a/src/OpenIPC.Viewer.Core/Ssh/Terminal/TerminalEmulator.cs +++ b/src/OpenIPC.Viewer.Core/Ssh/Terminal/TerminalEmulator.cs @@ -10,8 +10,9 @@ namespace OpenIPC.Viewer.Core.Ssh.Terminal; /// A deliberately small VT/ANSI terminal emulator (phase-13 §13.3 "basic VT" — /// no alt-screen, mouse, or full ncurses). Feeds UTF-8 bytes through a state /// machine that maintains a character grid: printable text, the common control -/// codes, CSI cursor/erase moves, and SGR colors. Unknown escapes are consumed -/// and ignored rather than corrupting the screen. +/// codes, CSI cursor/erase moves, and SGR renditions (16/256/truecolor, bold, dim, +/// italic, underline, inverse, hidden, strikethrough). Unknown escapes are +/// consumed and ignored rather than corrupting the screen. /// public sealed class TerminalEmulator { @@ -22,7 +23,11 @@ private enum State { Ground, Escape, Csi, String } private TerminalCell[][] _screen = Array.Empty(); private readonly List _scrollback = new(); - private const int MaxScrollback = 1000; + private int _scrollbackLimit; + + public const int DefaultScrollbackLimit = 1000; + public const int MinScrollbackLimit = 100; + public const int MaxScrollbackLimit = 50_000; // Rows that ran out of width and carried on in the row below (autowrap), as opposed to rows a // line break ended. Copying needs the difference: a long command wraps across two or three rows @@ -37,9 +42,9 @@ private enum State { Ground, Escape, Csi, String } private int _savedRow; private int _savedCol; - private byte _fg = TerminalPalette.DefaultForeground; - private byte _bg = TerminalPalette.DefaultBackground; - private bool _bold; + private TerminalColor _fg; + private TerminalColor _bg; + private TerminalAttributes _attrs; private State _state = State.Ground; private readonly StringBuilder _params = new(); @@ -49,7 +54,8 @@ private enum State { Ground, Escape, Csi, String } // rest of the session: every byte after it disappears into the parser and the screen stops // changing at all. private const int MaxSequenceLength = 4096; - private const int MaxParamsLength = 64; + // Room for a truecolor foreground and background plus a few flags in one SGR. + private const int MaxParamsLength = 128; private int _stringLength; private readonly Decoder _decoder = Encoding.UTF8.GetDecoder(); @@ -66,6 +72,23 @@ private enum State { Ground, Escape, Csi, String } /// public bool BracketedPaste { get; private set; } + /// False while the remote has hidden the cursor (CSI ?25l) — full-screen tools do. + public bool CursorVisible { get; private set; } = true; + + /// + /// How many rows that scrolled off the top are remembered. Lowering it drops the oldest rows + /// straight away. + /// + public int ScrollbackLimit + { + get => _scrollbackLimit; + set + { + _scrollbackLimit = Math.Clamp(value, MinScrollbackLimit, MaxScrollbackLimit); + TrimScrollback(); + } + } + /// /// True when (from or /// ) continues in the next row — the text ran past the right @@ -76,7 +99,11 @@ private enum State { Ground, Escape, Csi, String } /// Raised after a batch mutates the grid. public event Action? Updated; - public TerminalEmulator(int columns, int rows) => Resize(columns, rows); + public TerminalEmulator(int columns, int rows, int scrollbackLimit = DefaultScrollbackLimit) + { + ScrollbackLimit = scrollbackLimit; + Resize(columns, rows); + } public TerminalCell[] GetRow(int row) => _screen[row]; @@ -100,11 +127,8 @@ public void Resize(int columns, int rows) // time the view got shorter — which on a phone is every time the soft keyboard opens. var drop = had ? Math.Max(0, Rows - rows) : 0; for (var r = 0; r < drop; r++) - { _scrollback.Add(_screen[r]); - if (_scrollback.Count > MaxScrollback) - _scrollback.RemoveAt(0); - } + TrimScrollback(); // And they come back when the view grows again. Without this the trip is one-way: showing // and hiding the keyboard once ate a few rows of output for good, and doing it a few times @@ -213,6 +237,7 @@ private void ProcessEscape(char c) case 'c': // RIS — full reset ResetScreen(); BracketedPaste = false; + CursorVisible = true; _state = State.Ground; break; default: @@ -269,10 +294,10 @@ private void ProcessCsi(char c) } if ((c >= '0' && c <= '9') || c == ';' || c == ':') { - // Sub-parameters (SGR 38:5:n) read the same as the ';' spelling for the colours we - // model, so they are folded into one syntax rather than given a parser of their own. + // ':' separates sub-parameters (SGR 38:2::r:g:b). They are kept as written: only SGR + // reads them, and everything else looks at the number before the first colon. if (_params.Length < MaxParamsLength) - _params.Append(c == ':' ? ';' : c); + _params.Append(c); return; } // Intermediate bytes (0x20–0x2F) belong to sequences we don't implement — consume them @@ -296,18 +321,23 @@ private void ProcessCsi(char c) private void DispatchCsi(char final, int[] ps) { - // Private sequences (ESC[?…) are mode toggles. The one we model is bracketed paste; the - // rest (cursor visibility, alt screen, …) are consumed and ignored. + // Private sequences (ESC[?…) are mode toggles. The ones we model are bracketed paste and + // cursor visibility; the rest (alt screen, mouse, …) are consumed and ignored. if (_privateSeq) { - if (final is 'h' or 'l' && Array.IndexOf(ps, 2004) >= 0) - BracketedPaste = final == 'h'; + if (final is 'h' or 'l') + { + if (Array.IndexOf(ps, 2004) >= 0) + BracketedPaste = final == 'h'; + if (Array.IndexOf(ps, 25) >= 0) + CursorVisible = final == 'h'; + } return; } switch (final) { - case 'm': ApplySgr(ps); break; + case 'm': ApplySgr(_params.ToString()); break; case 'H' or 'f': _cursorRow = ClampRow(Arg(ps, 0, 1) - 1); _cursorCol = ClampCol(Arg(ps, 1, 1) - 1); @@ -326,54 +356,112 @@ private void DispatchCsi(char final, int[] ps) } } - private void ApplySgr(int[] ps) + // SGR arrives in two spellings for the extended colors: the common ';' one, where 38;5;n and + // 38;2;r;g;b borrow the parameters that follow, and the ITU ':' one, where the whole color + // sits inside a single parameter (38:5:n, 38:2::r:g:b, 38:2:r:g:b). + private void ApplySgr(string raw) { - if (ps.Length == 0) + if (raw.Length == 0) { ResetAttributes(); return; } - for (var i = 0; i < ps.Length; i++) + var parts = raw.Split(';'); + for (var i = 0; i < parts.Length; i++) { - var p = ps[i]; + var part = parts[i]; + if (part.IndexOf(':') >= 0) + { + ApplySgrWithSubParams(part.Split(':')); + continue; + } + + var p = ParseInt(part); switch (p) { case 0: ResetAttributes(); break; - case 1: _bold = true; break; - case 22: _bold = false; break; - case >= 30 and <= 37: _fg = (byte)(p - 30); break; - case 39: _fg = TerminalPalette.DefaultForeground; break; - case >= 40 and <= 47: _bg = (byte)(p - 40); break; - case 49: _bg = TerminalPalette.DefaultBackground; break; - case >= 90 and <= 97: _fg = (byte)(p - 90 + 8); break; - case >= 100 and <= 107: _bg = (byte)(p - 100 + 8); break; - case 38: i = ConsumeExtendedColor(ps, i, out _fg); break; - case 48: i = ConsumeExtendedColor(ps, i, out _bg); break; + case 1: _attrs |= TerminalAttributes.Bold; break; + case 2: _attrs |= TerminalAttributes.Dim; break; + case 3: _attrs |= TerminalAttributes.Italic; break; + case 4 or 21: _attrs |= TerminalAttributes.Underline; break; + case 7: _attrs |= TerminalAttributes.Inverse; break; + case 8: _attrs |= TerminalAttributes.Hidden; break; + case 9: _attrs |= TerminalAttributes.Strikethrough; break; + case 22: _attrs &= ~(TerminalAttributes.Bold | TerminalAttributes.Dim); break; + case 23: _attrs &= ~TerminalAttributes.Italic; break; + case 24: _attrs &= ~TerminalAttributes.Underline; break; + case 27: _attrs &= ~TerminalAttributes.Inverse; break; + case 28: _attrs &= ~TerminalAttributes.Hidden; break; + case 29: _attrs &= ~TerminalAttributes.Strikethrough; break; + case >= 30 and <= 37: _fg = TerminalColor.FromIndex(p - 30); break; + case 39: _fg = TerminalColor.Default; break; + case >= 40 and <= 47: _bg = TerminalColor.FromIndex(p - 40); break; + case 49: _bg = TerminalColor.Default; break; + case >= 90 and <= 97: _fg = TerminalColor.FromIndex(p - 90 + 8); break; + case >= 100 and <= 107: _bg = TerminalColor.FromIndex(p - 100 + 8); break; + case 38: i = ConsumeExtendedColor(parts, i, ref _fg); break; + case 48: i = ConsumeExtendedColor(parts, i, ref _bg); break; default: break; } } } - // 38/48 ; 5 ; n (256-color) or 38/48 ; 2 ; r ; g ; b (truecolor). We map - // a low index to its palette slot and fall back to default for the rest. - private static int ConsumeExtendedColor(int[] ps, int i, out byte color) + private void ApplySgrWithSubParams(string[] sub) { - color = TerminalPalette.DefaultForeground; - if (i + 1 >= ps.Length) + switch (ParseInt(sub[0])) + { + case 38: _fg = ExtendedColor(sub) ?? _fg; break; + case 48: _bg = ExtendedColor(sub) ?? _bg; break; + // 4:0 is "no underline"; 4:1..4:5 are underline styles, all drawn as one line here. + case 4: + if (sub.Length > 1 && ParseInt(sub[1]) == 0) + _attrs &= ~TerminalAttributes.Underline; + else + _attrs |= TerminalAttributes.Underline; + break; + default: break; + } + } + + // ';' spelling: 38;5;n or 38;2;r;g;b. Returns the index of the last parameter consumed. A + // truncated sequence leaves the color as it was rather than guessing. + private static int ConsumeExtendedColor(string[] parts, int i, ref TerminalColor color) + { + if (i + 1 >= parts.Length) return i; - var mode = ps[i + 1]; - if (mode == 5 && i + 2 < ps.Length) + switch (ParseInt(parts[i + 1])) { - var n = ps[i + 2]; - color = n < 16 ? (byte)n : TerminalPalette.DefaultForeground; - return i + 2; + case 5 when i + 2 < parts.Length: + color = TerminalColor.FromIndex(Math.Clamp(ParseInt(parts[i + 2]), 0, 255)); + return i + 2; + case 2 when i + 4 < parts.Length: + color = TerminalColor.FromRgb( + ParseInt(parts[i + 2]), ParseInt(parts[i + 3]), ParseInt(parts[i + 4])); + return i + 4; + default: + return i + 1; } - if (mode == 2 && i + 4 < ps.Length) - return i + 4; // truecolor not represented — leave default + } - return i + 1; + // ':' spelling — sub[0] is 38/48, then 5:n, 2:r:g:b, or 2:colorspace:r:g:b. + private static TerminalColor? ExtendedColor(string[] sub) + { + if (sub.Length < 2) + return null; + + var mode = ParseInt(sub[1]); + var args = sub.Length - 2; + if (mode == 5 && args >= 1) + return TerminalColor.FromIndex(Math.Clamp(ParseInt(sub[2]), 0, 255)); + if (mode == 2 && args >= 3) + { + // With four arguments the first is the color-space id, usually left empty. + var r = args >= 4 ? 3 : 2; + return TerminalColor.FromRgb(ParseInt(sub[r]), ParseInt(sub[r + 1]), ParseInt(sub[r + 2])); + } + return null; } private void PutChar(char c) @@ -384,7 +472,7 @@ private void PutChar(char c) _cursorCol = 0; LineFeed(); } - _screen[_cursorRow][_cursorCol] = new TerminalCell(c, _fg, _bg, _bold); + _screen[_cursorRow][_cursorCol] = new TerminalCell(c, _fg, _bg, _attrs); _cursorCol++; } @@ -399,8 +487,7 @@ private void LineFeed() private void ScrollUp() { _scrollback.Add(_screen[0]); - if (_scrollback.Count > MaxScrollback) - _scrollback.RemoveAt(0); + TrimScrollback(); for (var r = 1; r < Rows; r++) _screen[r - 1] = _screen[r]; @@ -443,6 +530,13 @@ private void EraseInDisplay(int mode) } } + private void TrimScrollback() + { + var excess = _scrollback.Count - _scrollbackLimit; + if (excess > 0) + _scrollback.RemoveRange(0, excess); + } + private void ClearRow(int row) { var r = _screen[row]; @@ -469,14 +563,14 @@ private void ResetScreen() private void ResetAttributes() { - _fg = TerminalPalette.DefaultForeground; - _bg = TerminalPalette.DefaultBackground; - _bold = false; + _fg = TerminalColor.Default; + _bg = TerminalColor.Default; + _attrs = TerminalAttributes.None; } // Erased cells keep the active background so colored fills survive a clear. private TerminalCell BlankCell() => - new(' ', TerminalPalette.DefaultForeground, _bg, false); + new(' ', TerminalColor.Default, _bg, TerminalAttributes.None); private int[] ParseParams() { @@ -486,10 +580,17 @@ private int[] ParseParams() var parts = _params.ToString().Split(';'); var result = new int[parts.Length]; for (var i = 0; i < parts.Length; i++) - result[i] = int.TryParse(parts[i], NumberStyles.Integer, CultureInfo.InvariantCulture, out var v) ? v : 0; + { + var part = parts[i]; + var colon = part.IndexOf(':'); + result[i] = ParseInt(colon >= 0 ? part.Substring(0, colon) : part); + } return result; } + private static int ParseInt(string s) => + int.TryParse(s, NumberStyles.Integer, CultureInfo.InvariantCulture, out var v) ? v : 0; + private static int Arg(int[] ps, int index, int fallback) => index < ps.Length && ps[index] > 0 ? ps[index] : fallback; diff --git a/src/OpenIPC.Viewer.Core/Ssh/Terminal/TerminalTheme.cs b/src/OpenIPC.Viewer.Core/Ssh/Terminal/TerminalTheme.cs new file mode 100644 index 0000000..a3bae09 --- /dev/null +++ b/src/OpenIPC.Viewer.Core/Ssh/Terminal/TerminalTheme.cs @@ -0,0 +1,136 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; + +namespace OpenIPC.Viewer.Core.Ssh.Terminal; + +/// +/// The colors a terminal is drawn with: default text and background, the cursor, the selection +/// highlight and the 16 ANSI colors. Colors are 0xRRGGBB; is 0xAARRGGBB +/// because it is laid over the text. Terminals keep their own palette rather than following the +/// app theme — the remote picks colors assuming a terminal, not our UI. +/// +public sealed class TerminalTheme +{ + public string Id { get; } + public uint Foreground { get; } + public uint Background { get; } + public uint Cursor { get; } + public uint Selection { get; } + public IReadOnlyList Ansi { get; } + + private TerminalTheme(string id, string fg, string bg, string cursor, uint selection, string[] ansi) + { + if (ansi.Length != 16) + throw new ArgumentException("A terminal theme needs exactly 16 ANSI colors.", nameof(ansi)); + Id = id; + Foreground = Hex(fg); + Background = Hex(bg); + Cursor = Hex(cursor); + Selection = selection; + Ansi = ansi.Select(Hex).ToArray(); + } + + /// 0xRRGGBB for a cell color; stands in for the default. + public uint Resolve(TerminalColor color, uint fallback) + { + if (color.IsRgb) + return color.Rgb; + if (color.IsIndexed) + return color.Index < 16 ? Ansi[color.Index] : TerminalPalette.Xterm256(color.Index); + return fallback; + } + + /// + /// What a cell actually looks like: its colors resolved against this theme, then inverse, + /// dim and hidden applied on top. + /// + public (uint Foreground, uint Background) ColorsOf(in TerminalCell cell) + { + var fg = Resolve(cell.Foreground, Foreground); + var bg = Resolve(cell.Background, Background); + var attrs = cell.Attributes; + if ((attrs & TerminalAttributes.Inverse) != 0) + (fg, bg) = (bg, fg); + if ((attrs & TerminalAttributes.Dim) != 0) + fg = Halfway(fg, bg); + if ((attrs & TerminalAttributes.Hidden) != 0) + fg = bg; + return (fg, bg); + } + + // Dim text is the foreground pulled halfway to the background — readable on any theme, + // light or dark, which a fixed darkening would not be. + private static uint Halfway(uint a, uint b) + { + uint Mix(int shift) => ((((a >> shift) & 0xFF) + ((b >> shift) & 0xFF)) / 2) << shift; + return Mix(16) | Mix(8) | Mix(0); + } + + private static uint Hex(string hex) => + uint.Parse(hex.TrimStart('#'), NumberStyles.HexNumber, CultureInfo.InvariantCulture); + + public const string DefaultId = "default"; + + public static TerminalTheme Default { get; } = new(DefaultId, + "#d4d4d4", "#0c0f14", "#d4d4d4", 0x663B8EEA, new[] + { + "#1e1e1e", "#cd3131", "#0dbc79", "#e5e510", "#2472c8", "#bc3fbc", "#11a8cd", "#cccccc", + "#666666", "#f14c4c", "#23d18b", "#f5f543", "#3b8eea", "#d670d6", "#29b8db", "#ffffff", + }); + + /// The built-in themes, in the order the settings page lists them. + public static IReadOnlyList BuiltIn { get; } = new[] + { + Default, + new TerminalTheme("gruvbox-dark", + "#ebdbb2", "#282828", "#ebdbb2", 0x99665C54, new[] + { + "#282828", "#cc241d", "#98971a", "#d79921", "#458588", "#b16286", "#689d6a", "#a89984", + "#928374", "#fb4934", "#b8bb26", "#fabd2f", "#83a598", "#d3869b", "#8ec07c", "#ebdbb2", + }), + new TerminalTheme("dracula", + "#f8f8f2", "#282a36", "#f8f8f2", 0xCC44475A, new[] + { + "#21222c", "#ff5555", "#50fa7b", "#f1fa8c", "#bd93f9", "#ff79c6", "#8be9fd", "#f8f8f2", + "#6272a4", "#ff6e6e", "#69ff94", "#ffffa5", "#d6acff", "#ff92df", "#a4ffff", "#ffffff", + }), + new TerminalTheme("solarized-dark", + "#839496", "#002b36", "#93a1a1", 0x99586E75, Solarized), + new TerminalTheme("solarized-light", + "#657b83", "#fdf6e3", "#586e75", 0x6693A1A1, Solarized), + new TerminalTheme("phosphor", + "#33ff66", "#050a05", "#33ff66", 0x5533FF66, new[] + { + "#1a2a1a", "#e06c4c", "#33ff66", "#d7ff5f", "#4cb2a0", "#a8d08d", "#66ffcc", "#b8ffc8", + "#3d5c3d", "#ff8566", "#66ff8c", "#e8ff8c", "#66d4bf", "#c4e8a8", "#99ffdd", "#e6ffe9", + }), + }; + + // Shared by the dark and light variants, as in the original scheme. Bright black is base01 + // rather than base03 here: base03 IS the dark background, which made "bright black" text + // (comments, `ls` for some file types) disappear. + private static string[] Solarized => new[] + { + "#073642", "#dc322f", "#859900", "#b58900", "#268bd2", "#d33682", "#2aa198", "#eee8d5", + "#586e75", "#cb4b16", "#586e75", "#657b83", "#839496", "#6c71c4", "#93a1a1", "#fdf6e3", + }; + + /// The built-in theme with this id, or for an unknown one. + public static TerminalTheme ById(string? id) => + BuiltIn.FirstOrDefault(t => string.Equals(t.Id, id, StringComparison.OrdinalIgnoreCase)) ?? Default; +} + +/// How the cursor is drawn. +public enum TerminalCursorStyle +{ + /// A hollow box; the character under it stays as it is. + Outline, + /// A solid box with the character drawn inverted inside it. + Block, + /// A thin vertical bar at the left edge of the cell. + Bar, + /// A line along the bottom of the cell. + Underline, +} diff --git a/tests/OpenIPC.Viewer.Core.Tests/Ssh/TerminalEmulatorTests.cs b/tests/OpenIPC.Viewer.Core.Tests/Ssh/TerminalEmulatorTests.cs index 839fb95..7ee44ae 100644 --- a/tests/OpenIPC.Viewer.Core.Tests/Ssh/TerminalEmulatorTests.cs +++ b/tests/OpenIPC.Viewer.Core.Tests/Ssh/TerminalEmulatorTests.cs @@ -37,10 +37,10 @@ public void Sgr_SetsForegroundColorIndex() { var t = new TerminalEmulator(20, 5); t.Feed("\x1b[31mX"); - Assert.Equal(1, t.GetRow(0)[0].Foreground); // 31 -> index 1 (red) + Assert.Equal(TerminalColor.FromIndex(1), t.GetRow(0)[0].Foreground); // 31 -> index 1 (red) // Reset returns to default. t.Feed("\x1b[0mY"); - Assert.Equal(TerminalPalette.DefaultForeground, t.GetRow(0)[1].Foreground); + Assert.True(t.GetRow(0)[1].Foreground.IsDefault); } [Fact] diff --git a/tests/OpenIPC.Viewer.Core.Tests/Ssh/TerminalRenditionTests.cs b/tests/OpenIPC.Viewer.Core.Tests/Ssh/TerminalRenditionTests.cs new file mode 100644 index 0000000..1bcc8cb --- /dev/null +++ b/tests/OpenIPC.Viewer.Core.Tests/Ssh/TerminalRenditionTests.cs @@ -0,0 +1,178 @@ +using System; +using System.Linq; +using OpenIPC.Viewer.Core.Ssh.Terminal; +using Xunit; + +namespace OpenIPC.Viewer.Core.Tests.Ssh; + +// SGR renditions beyond the 16 basic colors (256-color, truecolor, both parameter spellings), +// the text attributes, cursor visibility, the scrollback limit, and how a theme turns a cell +// into the colors that are actually drawn. +public sealed class TerminalRenditionTests +{ + private static TerminalCell Cell(TerminalEmulator t, int col) => t.GetRow(0)[col]; + + [Fact] + public void Sgr256_KeepsTheFullIndex() + { + var t = new TerminalEmulator(20, 5); + t.Feed("\x1b[38;5;196mA\x1b[48;5;21mB"); + Assert.Equal(TerminalColor.FromIndex(196), Cell(t, 0).Foreground); + Assert.Equal(TerminalColor.FromIndex(21), Cell(t, 1).Background); + } + + [Fact] + public void SgrTruecolor_SemicolonSpelling() + { + var t = new TerminalEmulator(20, 5); + t.Feed("\x1b[38;2;10;20;30;48;2;200;100;50mX"); + Assert.Equal(TerminalColor.FromRgb(10, 20, 30), Cell(t, 0).Foreground); + Assert.Equal(TerminalColor.FromRgb(200, 100, 50), Cell(t, 0).Background); + } + + [Fact] + public void SgrTruecolor_ColonSpelling_WithAndWithoutColorSpace() + { + var t = new TerminalEmulator(20, 5); + t.Feed("\x1b[38:2::1:2:3mA\x1b[38:2:4:5:6mB\x1b[38:5:100mC"); + Assert.Equal(TerminalColor.FromRgb(1, 2, 3), Cell(t, 0).Foreground); + Assert.Equal(TerminalColor.FromRgb(4, 5, 6), Cell(t, 1).Foreground); + Assert.Equal(TerminalColor.FromIndex(100), Cell(t, 2).Foreground); + } + + [Fact] + public void SgrColonColor_DoesNotLeakIntoFollowingParameters() + { + // With ':' folded into ';' the trailing blue component (4) used to read as "underline". + var t = new TerminalEmulator(20, 5); + t.Feed("\x1b[38:2::1:2:4mX"); + Assert.Equal(TerminalAttributes.None, Cell(t, 0).Attributes); + } + + [Fact] + public void SgrTruncatedExtendedColor_LeavesColorAlone() + { + var t = new TerminalEmulator(20, 5); + t.Feed("\x1b[32m\x1b[38;5mX\x1b[48;2;1mY"); + Assert.Equal(TerminalColor.FromIndex(2), Cell(t, 0).Foreground); + Assert.True(Cell(t, 1).Background.IsDefault); + } + + [Fact] + public void SgrAttributes_SetAndClearIndividually() + { + var t = new TerminalEmulator(30, 5); + t.Feed("\x1b[1;2;3;4;7;8;9mA"); + Assert.Equal( + TerminalAttributes.Bold | TerminalAttributes.Dim | TerminalAttributes.Italic | + TerminalAttributes.Underline | TerminalAttributes.Inverse | TerminalAttributes.Hidden | + TerminalAttributes.Strikethrough, + Cell(t, 0).Attributes); + + t.Feed("\x1b[22;23;24;27;28;29mB"); + Assert.Equal(TerminalAttributes.None, Cell(t, 1).Attributes); + + t.Feed("\x1b[4m\x1b[4:0mC\x1b[4:3mD"); + Assert.Equal(TerminalAttributes.None, Cell(t, 2).Attributes); + Assert.Equal(TerminalAttributes.Underline, Cell(t, 3).Attributes); + } + + [Fact] + public void SgrReset_ClearsAttributesAndColors() + { + var t = new TerminalEmulator(20, 5); + t.Feed("\x1b[1;7;31;44mA\x1b[mB"); + Assert.Equal(TerminalCell.Blank with { Char = 'B' }, Cell(t, 1)); + } + + [Fact] + public void CursorVisibility_FollowsPrivateMode25() + { + var t = new TerminalEmulator(20, 5); + Assert.True(t.CursorVisible); + t.Feed("\x1b[?25l"); + Assert.False(t.CursorVisible); + t.Feed("\x1b[?25h"); + Assert.True(t.CursorVisible); + t.Feed("\x1b[?25l\x1b" + "c"); + Assert.True(t.CursorVisible); + } + + [Fact] + public void Scrollback_IsCappedAtTheLimit_AndShrinksWithIt() + { + var t = new TerminalEmulator(10, 2, scrollbackLimit: 150); + for (var i = 0; i < 300; i++) + t.Feed($"{i}\r\n"); + Assert.Equal(150, t.ScrollbackRows); + + t.ScrollbackLimit = 120; + Assert.Equal(120, t.ScrollbackRows); + // The newest rows are the ones kept. + Assert.Equal('2', t.GetScrollbackRow(t.ScrollbackRows - 1)[0].Char); + } + + [Fact] + public void ScrollbackLimit_IsClamped() + { + Assert.Equal(TerminalEmulator.MinScrollbackLimit, new TerminalEmulator(10, 2, 1).ScrollbackLimit); + Assert.Equal(TerminalEmulator.MaxScrollbackLimit, new TerminalEmulator(10, 2, int.MaxValue).ScrollbackLimit); + } + + [Theory] + [InlineData(16, 0x000000u)] + [InlineData(196, 0xFF0000u)] + [InlineData(21, 0x0000FFu)] + [InlineData(231, 0xFFFFFFu)] + [InlineData(232, 0x080808u)] + [InlineData(244, 0x808080u)] + [InlineData(255, 0xEEEEEEu)] + public void Xterm256_MatchesTheStandardPalette(int index, uint rgb) => + Assert.Equal(rgb, TerminalPalette.Xterm256(index)); + + [Fact] + public void Theme_ResolvesEachKindOfColor() + { + var theme = TerminalTheme.Default; + Assert.Equal(0x123456u, theme.Resolve(TerminalColor.Default, 0x123456)); + Assert.Equal(theme.Ansi[9], theme.Resolve(TerminalColor.FromIndex(9), 0)); + Assert.Equal(0xFF0000u, theme.Resolve(TerminalColor.FromIndex(196), 0)); + Assert.Equal(0x0A141Eu, theme.Resolve(TerminalColor.FromRgb(10, 20, 30), 0)); + } + + [Fact] + public void Theme_InverseSwapsResolvedColors_EvenForDefaults() + { + var theme = TerminalTheme.Default; + var cell = TerminalCell.Blank with { Char = 'x', Attributes = TerminalAttributes.Inverse }; + Assert.Equal((theme.Background, theme.Foreground), theme.ColorsOf(cell)); + } + + [Fact] + public void Theme_DimBlendsTowardsBackground_HiddenMatchesIt() + { + var theme = TerminalTheme.Default; + var red = TerminalColor.FromRgb(200, 0, 0); + var black = TerminalColor.FromRgb(0, 0, 0); + + var dim = new TerminalCell('x', red, black, TerminalAttributes.Dim); + Assert.Equal(0x640000u, theme.ColorsOf(dim).Foreground); + + var hidden = new TerminalCell('x', red, black, TerminalAttributes.Hidden); + Assert.Equal(0x000000u, theme.ColorsOf(hidden).Foreground); + } + + [Fact] + public void Themes_AreCompleteAndFoundById() + { + Assert.All(TerminalTheme.BuiltIn, t => Assert.Equal(16, t.Ansi.Count)); + Assert.Equal(TerminalTheme.BuiltIn.Count, TerminalTheme.BuiltIn.Select(t => t.Id).Distinct().Count()); + Assert.Same(TerminalTheme.BuiltIn[2], TerminalTheme.ById(TerminalTheme.BuiltIn[2].Id.ToUpperInvariant())); + Assert.Same(TerminalTheme.Default, TerminalTheme.ById("no-such-theme")); + Assert.Same(TerminalTheme.Default, TerminalTheme.ById(null)); + } + + [Fact] + public void Xterm256_RejectsThemeIndices() => + Assert.Throws(() => TerminalPalette.Xterm256(15)); +} From 57b7552a0fc9446f3694992ba2fe06342daf8fcb Mon Sep 17 00:00:00 2001 From: keyldev Date: Mon, 5 Oct 2026 22:30:27 +0300 Subject: [PATCH 2/2] feat(ssh): alternate screen and full-screen terminal support - Alternate screen (?47/1047/1049), scroll regions, origin mode, IL/DL/ICH/DCH/ECH, SU/SD, REP, autowrap toggle - Application cursor keys, DEC line-drawing charset, DSR/DA replies; ESC ( B no longer prints a stray B - Box-drawing and block characters drawn per cell so frames join; one key table adds Delete, PgUp/PgDn, F1-F12 - Wheel and swipe on the alternate screen send arrow keys --- .../Controls/TerminalView.cs | 256 ++++++++- .../ViewModels/SshTerminalViewModel.cs | 4 + .../Views/Dialogs/SshTerminalContent.axaml.cs | 38 +- .../Ssh/Terminal/TerminalEmulator.cs | 533 +++++++++++++++--- .../Ssh/TerminalFullScreenTests.cs | 232 ++++++++ 5 files changed, 945 insertions(+), 118 deletions(-) create mode 100644 tests/OpenIPC.Viewer.Core.Tests/Ssh/TerminalFullScreenTests.cs diff --git a/src/OpenIPC.Viewer.App/Controls/TerminalView.cs b/src/OpenIPC.Viewer.App/Controls/TerminalView.cs index b386492..42f6aaa 100644 --- a/src/OpenIPC.Viewer.App/Controls/TerminalView.cs +++ b/src/OpenIPC.Viewer.App/Controls/TerminalView.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Globalization; +using System.Linq; using System.Text; using Avalonia; using Avalonia.Controls; @@ -288,10 +289,38 @@ private void WakeCursor() // invalidate is safe. private void OnEmulatorUpdated() { + // Switching screens changes what the row indices mean — the alternate screen has no + // history — so a selection or a scrolled-back view from before is meaningless after. + var alternate = _subscribed?.IsAlternateScreen ?? false; + if (alternate != _wasAlternate) + { + _wasAlternate = alternate; + _scrollOffset = 0; + ClearSelection(); + } + WakeCursor(); InvalidateVisual(); } + private bool _wasAlternate; + + // Wheel and touch scrolling. On the alternate screen there is no history to show, so — like + // xterm's alternateScroll — the gesture becomes cursor keys and the tool (less, vi, htop) + // scrolls its own content. Positive rows are "back in time", i.e. up. + private void ScrollOrSendKeys(int rows) + { + if (rows == 0) + return; + if (Emulator is { IsAlternateScreen: true } emu) + { + var key = emu.CursorKey(rows > 0 ? 'A' : 'B'); + Input?.Invoke(this, string.Concat(Enumerable.Repeat(key, Math.Abs(rows)))); + return; + } + ScrollBy(rows); + } + private void EnsureMetrics() { var sample = new FormattedText("M", CultureInfo.InvariantCulture, FlowDirection.LeftToRight, @@ -648,7 +677,7 @@ private static (int Start, int End) SelectedColumns( protected override void OnPointerWheelChanged(PointerWheelEventArgs e) { base.OnPointerWheelChanged(e); - ScrollBy((int)Math.Round(e.Delta.Y) * 3); + ScrollOrSendKeys((int)Math.Round(e.Delta.Y) * 3); e.Handled = true; } @@ -734,7 +763,7 @@ protected override void OnPointerMoved(PointerEventArgs e) // Pull down to go back in time, the way every list on the platform behaves. _dragAnchorY += rows * _cellHeight; - ScrollBy(rows); + ScrollOrSendKeys(rows); } // Dragging a selection against either edge walks the view, so it can run past the top of the @@ -912,7 +941,7 @@ private void DrawRow(DrawingContext context, TerminalCell[] cells, double y, int // default-constructed cell carries NUL rather than a space. Hidden text (SGR 8) has // already been given the background color, so it paints as nothing too. if (cell.Char > ' ' && (cell.Attributes & TerminalAttributes.Hidden) == 0) - context.DrawText(GlyphFor(cell.Char, fg, cell.Attributes), new Point(x, y)); + DrawCellChar(context, cell.Char, fg, cell.Attributes, x, y); DrawLines(context, cell.Attributes, fg, x, y); } @@ -935,6 +964,170 @@ private void DrawLines(DrawingContext context, TerminalAttributes attrs, uint fg context.FillRectangle(brush, new Rect(x, y + _cellHeight / 2, _cellWidth, thickness)); } + private void DrawCellChar(DrawingContext context, char ch, uint rgb, TerminalAttributes attrs, double x, double y) + { + if (!DrawBoxChar(context, ch, rgb, x, y)) + context.DrawText(GlyphFor(ch, rgb, attrs), new Point(x, y)); + } + + // --- Box drawing ----------------------------------------------------- + // + // Line and block characters are drawn as rectangles instead of being taken from the font. A + // font's │ is only as tall as its glyph box, which is shorter than the cell once line spacing + // is added, so every frame a full-screen tool drew came out as dashes with gaps between rows — + // and where the font has no such glyph at all, as whatever the fallback font made of it. + // Drawn, the strokes meet the cell edges exactly and join up with their neighbours. + + // Light/heavy lines as four arms from the cell centre: 0 none, 1 light, 2 heavy. + // Packed as up | down << 2 | left << 4 | right << 6. + private static readonly Dictionary BoxArms = BuildBoxArms(); + + private static Dictionary BuildBoxArms() + { + static int A(int up, int down, int left, int right) => up | (down << 2) | (left << 4) | (right << 6); + var map = new Dictionary(); + void Both(char light, char heavy, int up, int down, int left, int right) + { + map[light] = A(up, down, left, right); + map[heavy] = A(up * 2, down * 2, left * 2, right * 2); + } + Both('─', '━', 0, 0, 1, 1); + Both('│', '┃', 1, 1, 0, 0); + Both('┌', '┏', 0, 1, 0, 1); + Both('┐', '┓', 0, 1, 1, 0); + Both('└', '┗', 1, 0, 0, 1); + Both('┘', '┛', 1, 0, 1, 0); + Both('├', '┣', 1, 1, 0, 1); + Both('┤', '┫', 1, 1, 1, 0); + Both('┬', '┳', 0, 1, 1, 1); + Both('┴', '┻', 1, 0, 1, 1); + Both('┼', '╋', 1, 1, 1, 1); + Both('╴', '╸', 0, 0, 1, 0); + Both('╵', '╹', 1, 0, 0, 0); + Both('╶', '╺', 0, 0, 0, 1); + Both('╷', '╻', 0, 1, 0, 0); + // Rounded corners, drawn square: at terminal sizes the difference is a pixel or two. + map['╭'] = map['┌']; + map['╮'] = map['┐']; + map['╰'] = map['└']; + map['╯'] = map['┘']; + return map; + } + + // Double lines as explicit segments, four characters each: H or V, the line's level + // (a/b = the two parallel strokes either side of the centre), then where it starts and ends + // (L/R or T/B = the cell edges, a/b = the near and far stroke of the crossing direction). + private static readonly Dictionary BoxDouble = new() + { + ['═'] = new[] { "HaLR", "HbLR" }, + ['║'] = new[] { "VaTB", "VbTB" }, + ['╔'] = new[] { "HaaR", "HbbR", "VaaB", "VbbB" }, + ['╗'] = new[] { "HaLb", "HbLa", "VabB", "VbaB" }, + ['╚'] = new[] { "HabR", "HbaR", "VaTb", "VbTa" }, + ['╝'] = new[] { "HaLa", "HbLb", "VaTa", "VbTb" }, + ['╠'] = new[] { "VaTB", "VbTa", "VbbB", "HabR", "HbbR" }, + ['╣'] = new[] { "VbTB", "VaTa", "VabB", "HaLa", "HbLa" }, + ['╦'] = new[] { "HaLR", "HbLa", "HbbR", "VabB", "VbbB" }, + ['╩'] = new[] { "HbLR", "HaLa", "HabR", "VaTa", "VbTa" }, + ['╬'] = new[] { "HaLa", "HabR", "HbLa", "HbbR", "VaTa", "VabB", "VbTa", "VbbB" }, + }; + + private bool DrawBoxChar(DrawingContext context, char ch, uint rgb, double x, double y) + { + if (ch < '─' || ch > '▟') + return false; + + var left = Snap(x); + var right = Snap(x + _cellWidth); + var top = Snap(y); + var bottom = Snap(y + _cellHeight); + var brush = OpaqueBrush(rgb); + var light = Math.Max(1, Math.Round(TerminalFontSize / 14)); + var cx = (left + right) / 2; + var cy = (top + bottom) / 2; + + if (BoxArms.TryGetValue(ch, out var arms)) + { + // Each arm runs from its edge to just past the centre, so the joint is filled + // whatever mix of weights meets there. + void Arm(int weight, bool horizontal, double from, double to) + { + if (weight == 0) + return; + var t = weight == 2 ? light * 2 : light; + if (horizontal) + FillSnapped(context, brush, from, cy - t / 2, to, cy + t / 2); + else + FillSnapped(context, brush, cx - t / 2, from, cx + t / 2, to); + } + + Arm(arms & 3, false, top, cy + light); + Arm((arms >> 2) & 3, false, cy - light, bottom); + Arm((arms >> 4) & 3, true, left, cx + light); + Arm((arms >> 6) & 3, true, cx - light, right); + return true; + } + + if (BoxDouble.TryGetValue(ch, out var segments)) + { + var gap = light * 1.5; + double Level(char s, double centre) => s == 'a' ? centre - gap : centre + gap; + // Ends at a stroke reach half a stroke further, so two strokes meeting at a corner + // close it instead of leaving a notch. + double End(char s, double edgeLow, double edgeHigh, double centre) => s switch + { + 'L' or 'T' => edgeLow, + 'R' or 'B' => edgeHigh, + 'a' => centre - gap - light / 2, + _ => centre + gap + light / 2, + }; + + foreach (var s in segments) + { + if (s[0] == 'H') + { + var level = Level(s[1], cy); + FillSnapped(context, brush, End(s[2], left, right, cx), level - light / 2, + End(s[3], left, right, cx), level + light / 2); + } + else + { + var level = Level(s[1], cx); + FillSnapped(context, brush, level - light / 2, End(s[2], top, bottom, cy), + level + light / 2, End(s[3], top, bottom, cy)); + } + } + return true; + } + + // Block elements: full, halves, and the three shades as translucent fills. + switch (ch) + { + case '█': FillSnapped(context, brush, left, top, right, bottom); return true; + case '▀': FillSnapped(context, brush, left, top, right, cy); return true; + case '▄': FillSnapped(context, brush, left, cy, right, bottom); return true; + case '▌': FillSnapped(context, brush, left, top, cx, bottom); return true; + case '▐': FillSnapped(context, brush, cx, top, right, bottom); return true; + case '░': FillSnapped(context, BrushFor(0x4000_0000 | rgb), left, top, right, bottom); return true; + case '▒': FillSnapped(context, BrushFor(0x8000_0000 | rgb), left, top, right, bottom); return true; + case '▓': FillSnapped(context, BrushFor(0xC000_0000 | rgb), left, top, right, bottom); return true; + default: return false; + } + } + + // Both ends go to the pixel grid, and a stroke never collapses below one physical pixel: a + // one-pixel line centred on a whole pixel has both ends at .5, which round to the same value + // and left thin verticals simply missing at small font sizes. + private void FillSnapped(DrawingContext context, IBrush brush, double x0, double y0, double x1, double y1) + { + var pixel = 1 / _renderScale; + x0 = Snap(x0); + y0 = Snap(y0); + x1 = Math.Max(Snap(x1), x0 + pixel); + y1 = Math.Max(Snap(y1), y0 + pixel); + context.FillRectangle(brush, new Rect(x0, y0, x1 - x0, y1 - y0)); + } + private FormattedText GlyphFor(char ch, uint rgb, TerminalAttributes attrs) { var style = attrs & GlyphStyle; @@ -984,7 +1177,7 @@ private void DrawCursor(DrawingContext context, TerminalEmulator emu) if (cell.Char > ' ') { var (_, bg) = theme.ColorsOf(cell); - context.DrawText(GlyphFor(cell.Char, bg, cell.Attributes), new Point(x, y)); + DrawCellChar(context, cell.Char, bg, cell.Attributes, x, y); } break; } @@ -1052,24 +1245,53 @@ protected override void OnKeyDown(KeyEventArgs e) return; } - var seq = e.Key switch + var seq = SequenceFor(e.Key, e.KeyModifiers); + if (seq is not null) + { + Input?.Invoke(this, seq); + e.Handled = true; + } + } + + /// + /// The bytes a non-text key sends, or null for keys that arrive as text input. Cursor keys + /// follow the mode the remote asked for (ESC O A once an ncurses tool has switched to + /// application keys); the rest are the fixed xterm spellings mc, htop and vi look for — F10 + /// is how mc is quit. + /// + public string? SequenceFor(Key key, KeyModifiers modifiers) + { + var emu = Emulator; + string Cursor(char final) => emu?.CursorKey(final) ?? "\x1b[" + final; + return key switch { Key.Enter => "\r", Key.Back => "\x7f", - Key.Tab => "\t", + Key.Tab => modifiers.HasFlag(KeyModifiers.Shift) ? "\x1b[Z" : "\t", Key.Escape => "\x1b", - Key.Up => "\x1b[A", - Key.Down => "\x1b[B", - Key.Right => "\x1b[C", - Key.Left => "\x1b[D", - Key.Home => "\x1b[H", - Key.End => "\x1b[F", + Key.Up => Cursor('A'), + Key.Down => Cursor('B'), + Key.Right => Cursor('C'), + Key.Left => Cursor('D'), + Key.Home => Cursor('H'), + Key.End => Cursor('F'), + Key.Insert => "\x1b[2~", + Key.Delete => "\x1b[3~", + Key.PageUp => "\x1b[5~", + Key.PageDown => "\x1b[6~", + Key.F1 => "\x1bOP", + Key.F2 => "\x1bOQ", + Key.F3 => "\x1bOR", + Key.F4 => "\x1bOS", + Key.F5 => "\x1b[15~", + Key.F6 => "\x1b[17~", + Key.F7 => "\x1b[18~", + Key.F8 => "\x1b[19~", + Key.F9 => "\x1b[20~", + Key.F10 => "\x1b[21~", + Key.F11 => "\x1b[23~", + Key.F12 => "\x1b[24~", _ => null, }; - if (seq is not null) - { - Input?.Invoke(this, seq); - e.Handled = true; - } } } diff --git a/src/OpenIPC.Viewer.App/ViewModels/SshTerminalViewModel.cs b/src/OpenIPC.Viewer.App/ViewModels/SshTerminalViewModel.cs index b2f2948..e6da43e 100644 --- a/src/OpenIPC.Viewer.App/ViewModels/SshTerminalViewModel.cs +++ b/src/OpenIPC.Viewer.App/ViewModels/SshTerminalViewModel.cs @@ -70,6 +70,10 @@ public SshTerminalViewModel( ? style : TerminalCursorStyle.Outline; CursorBlink = s.SshTerminalCursorBlink; + + // Status queries (cursor position, device attributes) are answered over the same shell; + // a tool that asks and gets no answer can sit waiting for one. + Emulator.Reply += reply => _ = SendAsync(reply); } public async Task ConnectAsync() diff --git a/src/OpenIPC.Viewer.App/Views/Dialogs/SshTerminalContent.axaml.cs b/src/OpenIPC.Viewer.App/Views/Dialogs/SshTerminalContent.axaml.cs index 18ee3b2..c59923e 100644 --- a/src/OpenIPC.Viewer.App/Views/Dialogs/SshTerminalContent.axaml.cs +++ b/src/OpenIPC.Viewer.App/Views/Dialogs/SshTerminalContent.axaml.cs @@ -80,19 +80,24 @@ private void SetUpImeProxy() this.FindControl