diff --git a/src/OpenIPC.Viewer.Android/MainActivity.cs b/src/OpenIPC.Viewer.Android/MainActivity.cs
index e1c06dc..aee8587 100644
--- a/src/OpenIPC.Viewer.Android/MainActivity.cs
+++ b/src/OpenIPC.Viewer.Android/MainActivity.cs
@@ -12,12 +12,12 @@ namespace OpenIPC.Viewer.Android;
Theme = "@style/MyTheme.NoActionBar",
Icon = "@mipmap/icon",
MainLauncher = true,
- // AdjustResize shrinks the TopLevel client area when the soft keyboard opens
- // (instead of the keyboard overlaying content). The overlay dialog presenter
- // caps its bottom-sheet to ClientSize and wraps content in a ScrollViewer, so
- // this lets a focused field (e.g. ONVIF login/password at the bottom of the
- // add sheet) scroll into view above the keyboard. StateHidden keeps the
- // keyboard down until the user taps a field.
+ // AdjustResize asks Android to shrink the TopLevel client area when the soft keyboard opens
+ // rather than let it overlay the content. Under the edge-to-edge display a targetSdk-36 app
+ // gets, the system ignores that and slides the keyboard over the window instead — so the app
+ // pads for it itself (App.Services.SoftKeyboardInset, applied by MainView and the overlay
+ // presenter). The flag stays for the platforms where it is still honoured; the two paths are
+ // written not to double-count. StateHidden keeps the keyboard down until the user taps a field.
WindowSoftInputMode = SoftInput.AdjustResize | SoftInput.StateHidden,
ConfigurationChanges = ConfigChanges.Orientation | ConfigChanges.ScreenSize
| ConfigChanges.UiMode | ConfigChanges.Density)]
diff --git a/src/OpenIPC.Viewer.App/Controls/TerminalView.cs b/src/OpenIPC.Viewer.App/Controls/TerminalView.cs
index 981dc6f..17f881b 100644
--- a/src/OpenIPC.Viewer.App/Controls/TerminalView.cs
+++ b/src/OpenIPC.Viewer.App/Controls/TerminalView.cs
@@ -1,10 +1,12 @@
using System;
+using System.Collections.Generic;
using System.Globalization;
using System.Text;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Media;
+using Avalonia.Threading;
using OpenIPC.Viewer.Core.Ssh.Terminal;
namespace OpenIPC.Viewer.App.Controls;
@@ -55,19 +57,78 @@ public double TerminalFontSize
/// Raised when the available size maps to a new column/row count.
public event EventHandler<(int Columns, int Rows)>? GridResized;
- private readonly Typeface _typeface = new(new FontFamily("Cascadia Mono,Consolas,Menlo,monospace"));
+ /// Raised when the user asks for a clipboard paste (Ctrl+V, Shift+Insert, menu).
+ public event EventHandler? PasteRequested;
+
+ /// Raised when the user asks to copy (Ctrl+Shift+C, Ctrl+Insert).
+ public event EventHandler? CopyRequested;
+
+ ///
+ /// Raised when a touch selection gesture ends — the long-press picked a word, the drag that
+ /// may have followed sized it, and the menu belongs over the result.
+ ///
+ public event EventHandler? MenuRequested;
+
+ // Desktop names first, then the two families Android actually ships, then the generic alias.
+ // 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("Cascadia Mono,Consolas,Menlo,monospace"), weight: FontWeight.Bold);
+ new(new FontFamily(MonoFamilies), weight: FontWeight.Bold);
private double _cellWidth;
private double _cellHeight;
+ private readonly Dictionary<(char Char, byte Foreground, bool Bold), FormattedText> _glyphs = new();
private TerminalEmulator? _subscribed;
+ // Rows the view is scrolled back by; 0 is the live screen.
+ private int _scrollOffset;
+ private double _dragAnchorY;
+ private bool _dragging;
private int _lastCols = -1;
private int _lastRows = -1;
+ // Selection, in absolute row coordinates: scrollback rows are 0..ScrollbackRows-1 and the
+ // live screen continues from there. Rows are appended at the end, so a row keeps its index as
+ // output pushes the screen upwards and a selection survives a burst of output. The one
+ // exception is the 1000-row scrollback cap: past it the oldest rows are dropped and the
+ // indices below shift with them, which can slide a highlight left standing for that long.
+ private (int Row, int Col)? _selAnchor;
+ private (int Row, int Col)? _selFocus;
+ private bool _selecting;
+ // A touch selection is the one that opens the menu when the finger comes up.
+ private bool _selectingByTouch;
+ // Grab handles under the two ends of a touch selection, the way the platform's own text
+ // selection works: word-at-a-time is a coarse instrument, and the handles are what turn it
+ // into "from here to there". Only drawn for a selection a finger made.
+ private bool _handlesVisible;
+ private Handle _grabbed = Handle.None;
+ private Vector _grabOffset;
+
+ private enum Handle { None, Start, End }
+
+ // Auto-scroll while a selection drag sits against the top or bottom edge. Timer-driven, at a
+ // fixed pace: stepping once per pointer event made the speed depend on how much the finger
+ // trembled, and a resting fingertip produces a stream of them.
+ private DispatcherTimer? _edgeScroll;
+ private int _edgeDirection;
+ private Point _selectPoint;
+ private static readonly TimeSpan EdgeScrollInterval = TimeSpan.FromMilliseconds(110);
+
+ private const double HandleRadius = 9;
+ // Generous, because a fingertip is: the knob is 18px across and the nearest one inside this
+ // 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()
{
Focusable = true;
+ // Long-press is the only way to reach the menu on a phone (see OnHolding).
+ SetValue(InputElement.IsHoldingEnabledProperty, true);
EnsureMetrics();
this.GetObservable(BoundsProperty).Subscribe(_ => RecomputeGrid());
}
@@ -78,6 +139,7 @@ protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs chang
if (change.Property == TerminalFontSizeProperty)
{
+ _glyphs.Clear();
EnsureMetrics();
RecomputeGrid();
InvalidateVisual();
@@ -132,17 +194,523 @@ public override void Render(DrawingContext context)
if (emu is null)
return;
+ var history = emu.ScrollbackRows;
+ _scrollOffset = Math.Clamp(_scrollOffset, 0, history);
+
+ // The visible window is the last emu.Rows of (scrollback + screen), moved back by however
+ // far the user has scrolled. A phone shows about twenty rows, so anything longer than that
+ // is only readable if the rows above can be brought back.
+ var top = history - _scrollOffset;
+ var selection = NormalizedSelection();
+ for (var row = 0; row < emu.Rows; row++)
+ {
+ var index = top + row;
+ var cells = index < history ? emu.GetScrollbackRow(index) : emu.GetRow(index - history);
+ var (selStart, selEnd) = SelectedColumns(selection, index, cells.Length);
+ DrawRow(context, cells, row * _cellHeight, selStart, selEnd);
+ }
+
+ // The cursor belongs to the live screen; drawing it over history would be a lie.
+ if (_scrollOffset == 0)
+ DrawCursor(context, emu);
+
+ if (_handlesVisible && selection is not null)
+ {
+ DrawHandle(context, true);
+ DrawHandle(context, false);
+ }
+ }
+
+ // A bar down the edge of the selection plus a knob just clear of the row, so the knob never
+ // sits on the text it belongs to. On the bottom row there is no room below and it goes above.
+ private void DrawHandle(DrawingContext context, bool start)
+ {
+ if (HandleAnchor(start) is not { } anchor)
+ return;
+
+ var rowTop = anchor.Y - _cellHeight / 2;
+ context.FillRectangle(HandleBrush, new Rect(anchor.X - 1, rowTop, 2, _cellHeight));
+ context.DrawEllipse(HandleBrush, null, KnobCenter(anchor), HandleRadius, HandleRadius);
+ }
+
+ // Where the knob of a handle is drawn, and therefore where a finger has to land to pick it up.
+ // One place for both: on the bottom row the knob flips above the row, and hit-testing used to
+ // keep looking for it below, so the knob you could see there could not be grabbed and the
+ // press scrolled the terminal instead.
+ private Point KnobCenter(Point anchor)
+ {
+ var rowTop = anchor.Y - _cellHeight / 2;
+ var below = rowTop + _cellHeight + HandleRadius;
+ var y = below + HandleRadius <= Bounds.Height ? below : rowTop - HandleRadius;
+ return new Point(anchor.X, y);
+ }
+
+ // Where a handle points: the middle of its end of the selection, in view coordinates. Null
+ // when that end has been scrolled off the screen.
+ private Point? HandleAnchor(bool start)
+ {
+ var emu = Emulator;
+ if (emu is null || NormalizedSelection() is not { } selection || _cellHeight <= 0)
+ return null;
+
+ var (row, col) = start ? selection.Start : selection.End;
+ var viewRow = row - (emu.ScrollbackRows - _scrollOffset);
+ if (viewRow < 0 || viewRow >= emu.Rows)
+ return null;
+
+ return new Point(col * _cellWidth, viewRow * _cellHeight + _cellHeight / 2);
+ }
+
+ // The handle a finger landed on, if it landed on one: the nearest knob within reach.
+ private Handle HandleAt(Point point)
+ {
+ if (!_handlesVisible)
+ return Handle.None;
+
+ var best = Handle.None;
+ var bestDistance = HandleTouchSlop;
+ foreach (var start in new[] { true, false })
+ {
+ if (HandleAnchor(start) is not { } anchor)
+ continue;
+ // Measured from the knob, which is where the finger aims.
+ var knob = KnobCenter(anchor);
+ var distance = Math.Sqrt(
+ ((point.X - knob.X) * (point.X - knob.X)) + ((point.Y - knob.Y) * (point.Y - knob.Y)));
+ if (distance >= bestDistance)
+ continue;
+ bestDistance = distance;
+ best = start ? Handle.Start : Handle.End;
+ }
+ return best;
+ }
+
+ /// Back to the live screen — what typing into a scrolled-back terminal does.
+ public void ScrollToLive()
+ {
+ if (_scrollOffset == 0)
+ return;
+ _scrollOffset = 0;
+ InvalidateVisual();
+ }
+
+ private void ScrollBy(int rows)
+ {
+ var history = Emulator?.ScrollbackRows ?? 0;
+ var next = Math.Clamp(_scrollOffset + rows, 0, history);
+ if (next == _scrollOffset)
+ return;
+ _scrollOffset = next;
+ InvalidateVisual();
+ }
+
+ // --- Selection -------------------------------------------------------
+
+ /// True when something is highlighted and has content.
+ public bool HasSelection => NormalizedSelection() is not null;
+
+ /// Drops the highlight (a tap, a copy that has been taken, closing the menu).
+ public void ClearSelection()
+ {
+ if (_selAnchor is null && _selFocus is null)
+ return;
+ _selAnchor = null;
+ _selFocus = null;
+ _handlesVisible = false;
+ InvalidateVisual();
+ }
+
+ /// Highlights the whole line under .
+ public void SelectLineAt(Point point)
+ {
+ var emu = Emulator;
+ if (emu is null)
+ return;
+
+ var (row, _) = CellAt(point);
+ _selAnchor = (row, 0);
+ _selFocus = (row, RowAt(emu, row).Length);
+ InvalidateVisual();
+ }
+
+ ///
+ /// Highlights the word under , or the whole line when the spot is
+ /// blank. The starting point of a touch selection: a word is the smallest thing worth reaching
+ /// for, and the drag that follows grows it to whatever the user actually wants.
+ ///
+ public void SelectWordAt(Point point)
+ {
+ var emu = Emulator;
+ if (emu is null)
+ return;
+
+ var (row, _) = CellAt(point);
+ var cells = RowAt(emu, row);
+ var text = TextOf(cells, 0, cells.Length);
+ // The column the finger is over, not the nearest gap between columns.
+ var col = _cellWidth > 0 ? (int)(point.X / _cellWidth) : 0;
+
+ // Nothing under the finger: a hold on blank space is reaching for the line it is on.
+ if (text.Length == 0 || col >= text.Length || col < 0 || text[col] == ' ')
+ {
+ SelectLineAt(point);
+ return;
+ }
+
+ var start = col;
+ while (start > 0 && text[start - 1] != ' ')
+ start--;
+ var end = col;
+ while (end < text.Length && text[end] != ' ')
+ end++;
+
+ _selAnchor = (row, start);
+ _selFocus = (row, end);
+ InvalidateVisual();
+ }
+
+ /// The highlighted text, or null when nothing is highlighted.
+ public string? GetSelectedText()
+ {
+ var emu = Emulator;
+ if (emu is null || NormalizedSelection() is not { } selection)
+ return null;
+
+ var (start, end) = selection;
+ var text = new LineJoiner(emu);
+ for (var row = start.Row; row <= end.Row; row++)
+ {
+ var cells = RowAt(emu, row);
+ var from = row == start.Row ? Math.Clamp(start.Col, 0, cells.Length) : 0;
+ var to = row == end.Row ? Math.Clamp(end.Col, 0, cells.Length) : cells.Length;
+ text.Add(cells, from, to, last: row == end.Row);
+ }
+ return text.ToString();
+ }
+
+ /// Everything on screen right now — what a touch user gets when copying without a highlight.
+ public string GetVisibleText()
+ {
+ var emu = Emulator;
+ if (emu is null)
+ return "";
+
+ var top = emu.ScrollbackRows - _scrollOffset;
+ var text = new LineJoiner(emu);
for (var row = 0; row < emu.Rows; row++)
{
- var cells = emu.GetRow(row);
- var y = row * _cellHeight;
- DrawRow(context, cells, y);
+ var cells = RowAt(emu, top + row);
+ text.Add(cells, 0, cells.Length, last: row == emu.Rows - 1);
+ }
+ // Trailing blank rows are the unused part of the screen, not content.
+ return text.ToString().TrimEnd();
+ }
+
+ // Rows back into lines of text. A row the emulator wrapped runs straight on into the next one
+ // with no break, and keeps its trailing blanks, which are real spaces when the wrap fell
+ // between two words. Every other row ends its line.
+ private readonly struct LineJoiner
+ {
+ private readonly TerminalEmulator _emu;
+ private readonly StringBuilder _text;
+
+ public LineJoiner(TerminalEmulator emu)
+ {
+ _emu = emu;
+ _text = new StringBuilder();
+ }
+
+ public void Add(TerminalCell[] cells, int from, int to, bool last)
+ {
+ var continues = !last && to == cells.Length && cells.Length > 0 && _emu.IsWrapped(cells);
+ _text.Append(TextOf(cells, from, to, trim: !continues));
+ if (!last && !continues)
+ _text.Append(Environment.NewLine);
+ }
+
+ public override string ToString() => _text.ToString();
+ }
+
+ private static string TextOf(TerminalCell[] cells, int from, int to, bool trim = true)
+ {
+ var buffer = new char[Math.Max(0, to - from)];
+ for (var i = 0; i < buffer.Length; i++)
+ {
+ var ch = cells[from + i].Char;
+ // A never-written cell holds NUL; on the way out it is a space like any other blank.
+ buffer[i] = ch < ' ' ? ' ' : ch;
+ }
+ var text = new string(buffer);
+ return trim ? text.TrimEnd() : text;
+ }
+
+ private static TerminalCell[] RowAt(TerminalEmulator emu, int absoluteRow)
+ {
+ var history = emu.ScrollbackRows;
+ if (absoluteRow < 0)
+ return Array.Empty();
+ if (absoluteRow < history)
+ return emu.GetScrollbackRow(absoluteRow);
+ var screenRow = absoluteRow - history;
+ return screenRow < emu.Rows ? emu.GetRow(screenRow) : Array.Empty();
+ }
+
+ // Point -> (absolute row, column boundary). The column rounds to the nearest edge so a
+ // selection ends where the pointer looks like it ended, not half a character short.
+ private (int Row, int Col) CellAt(Point point)
+ {
+ var emu = Emulator;
+ if (emu is null || _cellWidth <= 0 || _cellHeight <= 0)
+ return (0, 0);
+
+ var top = emu.ScrollbackRows - _scrollOffset;
+ var viewRow = Math.Clamp((int)(point.Y / _cellHeight), 0, Math.Max(0, emu.Rows - 1));
+ var col = Math.Clamp((int)Math.Round(point.X / _cellWidth), 0, emu.Columns);
+ return (top + viewRow, col);
+ }
+
+ // Anchor and focus in the order they appear on screen; null when the two are the same
+ // position, which is a click rather than a selection.
+ private ((int Row, int Col) Start, (int Row, int Col) End)? NormalizedSelection()
+ {
+ if (_selAnchor is not { } anchor || _selFocus is not { } focus)
+ return null;
+ if (anchor == focus)
+ return null;
+
+ var anchorFirst = anchor.Row < focus.Row || (anchor.Row == focus.Row && anchor.Col <= focus.Col);
+ return anchorFirst ? (anchor, focus) : (focus, anchor);
+ }
+
+ private static (int Start, int End) SelectedColumns(
+ ((int Row, int Col) Start, (int Row, int Col) End)? selection, int absoluteRow, int rowLength)
+ {
+ if (selection is not { } range)
+ return (0, 0);
+
+ var (start, end) = range;
+ if (absoluteRow < start.Row || absoluteRow > end.Row)
+ return (0, 0);
+
+ var from = absoluteRow == start.Row ? Math.Clamp(start.Col, 0, rowLength) : 0;
+ var to = absoluteRow == end.Row ? Math.Clamp(end.Col, 0, rowLength) : rowLength;
+ return to > from ? (from, to) : (0, 0);
+ }
+
+ protected override void OnPointerWheelChanged(PointerWheelEventArgs e)
+ {
+ base.OnPointerWheelChanged(e);
+ ScrollBy((int)Math.Round(e.Delta.Y) * 3);
+ e.Handled = true;
+ }
+
+ // Two different drags, decided by what is doing the dragging. A mouse selects text, the way
+ // every desktop terminal does, and scrolls with the wheel. A finger scrolls the history —
+ // there is no wheel, and selecting by dragging would make the scrollback unreachable.
+ // Deliberately not a gesture recognizer: a tap still has to reach the host so it can raise the
+ // soft keyboard, and only movement past a whole row counts as scrolling.
+ protected override void OnPointerPressed(PointerPressedEventArgs e)
+ {
+ base.OnPointerPressed(e);
+ var point = e.GetPosition(this);
+
+ if (e.Pointer.Type == PointerType.Mouse &&
+ e.GetCurrentPoint(this).Properties.IsLeftButtonPressed)
+ {
+ _selecting = true;
+ _selAnchor = CellAt(point);
+ _selFocus = _selAnchor;
+ e.Pointer.Capture(this);
+ InvalidateVisual();
+ return;
+ }
+
+ if (e.Pointer.Type != PointerType.Mouse)
+ {
+ // A finger on one of the handles moves that end of the selection. The other end
+ // becomes the anchor, which is all the drag handler needs to know — from there it is
+ // the same code that sized the selection in the first place.
+ if (HandleAt(point) is var grabbed && grabbed != Handle.None &&
+ NormalizedSelection() is { } selection)
+ {
+ _grabbed = grabbed;
+ var moving = grabbed == Handle.Start ? selection.Start : selection.End;
+ _selAnchor = grabbed == Handle.Start ? selection.End : selection.Start;
+ _selFocus = moving;
+ // Keep the grip: the cell the handle points at stays under the same part of the
+ // finger, instead of jumping to wherever the fingertip happens to land.
+ _grabOffset = (HandleAnchor(grabbed == Handle.Start) ?? point) - point;
+ _selecting = true;
+ _selectingByTouch = true;
+ e.Pointer.Capture(this);
+ InvalidateVisual();
+ return;
+ }
+
+ // Any other press (a tap, a right-click that is about to open the menu) drops a stale
+ // selection — except the right-click itself, which needs the selection it is acting on.
+ ClearSelection();
+ }
+
+ _dragAnchorY = point.Y;
+ _dragging = true;
+ }
+
+ protected override void OnPointerMoved(PointerEventArgs e)
+ {
+ base.OnPointerMoved(e);
+
+ if (_selecting)
+ {
+ var at = e.GetPosition(this);
+
+ // A touch selection starts from a long-press, which never captured the pointer —
+ // take it now so the finger can leave the control without dropping the drag.
+ if (_selectingByTouch && e.Pointer.Captured != this)
+ e.Pointer.Capture(this);
+
+ _selectPoint = _grabbed == Handle.None ? at : at + _grabOffset;
+ UpdateEdgeScroll(at);
+ _selFocus = CellAt(_selectPoint);
+ InvalidateVisual();
+ return;
+ }
+
+ if (!_dragging || _cellHeight <= 0)
+ return;
+
+ var dy = e.GetPosition(this).Y - _dragAnchorY;
+ var rows = (int)(dy / _cellHeight);
+ if (rows == 0)
+ return;
+
+ // Pull down to go back in time, the way every list on the platform behaves.
+ _dragAnchorY += rows * _cellHeight;
+ ScrollBy(rows);
+ }
+
+ // Dragging a selection against either edge walks the view, so it can run past the top of the
+ // screen into the scrollback and past the bottom into the newest output. Top is back in time
+ // (a bigger offset), bottom is towards the live screen. This used to be the other way round,
+ // so a selection dragged down to the last rows sent the view racing off into the history.
+ private void UpdateEdgeScroll(Point at)
+ {
+ var zone = _cellHeight / 2;
+ _edgeDirection = _cellHeight <= 0 ? 0
+ : at.Y < zone ? 1
+ : at.Y > Bounds.Height - zone ? -1
+ : 0;
+
+ if (_edgeDirection == 0)
+ {
+ _edgeScroll?.Stop();
+ return;
+ }
+
+ if (_edgeScroll is null)
+ {
+ _edgeScroll = new DispatcherTimer { Interval = EdgeScrollInterval };
+ _edgeScroll.Tick += (_, _) => EdgeScrollStep();
+ }
+ // The first step waits a beat too: brushing the edge on the way to the last row must not
+ // move anything.
+ if (!_edgeScroll.IsEnabled)
+ _edgeScroll.Start();
+ }
+
+ private void EdgeScrollStep()
+ {
+ if (!_selecting || _edgeDirection == 0)
+ {
+ StopEdgeScroll();
+ return;
+ }
+ ScrollBy(_edgeDirection);
+ // The finger has not moved, but the text under it has.
+ _selFocus = CellAt(_selectPoint);
+ InvalidateVisual();
+ }
+
+ private void StopEdgeScroll()
+ {
+ _edgeDirection = 0;
+ _edgeScroll?.Stop();
+ }
+
+ protected override void OnPointerReleased(PointerReleasedEventArgs e)
+ {
+ base.OnPointerReleased(e);
+ _dragging = false;
+ StopEdgeScroll();
+
+ if (!_selecting)
+ return;
+
+ // Read and clear the touch flag BEFORE dropping the capture: releasing it raises
+ // PointerCaptureLost right here, synchronously, and that handler used to clear the flag
+ // this method was about to test — which is why the menu stopped opening at all.
+ var byTouch = _selectingByTouch;
+ _selecting = false;
+ _selectingByTouch = false;
+ _grabbed = Handle.None;
+ e.Pointer.Capture(null);
+
+ // A click that never moved is not a selection — leave nothing highlighted.
+ if (NormalizedSelection() is null)
+ ClearSelection();
+
+ // The finger is up and the highlight is final: now the menu can open over it, and the
+ // handles come out so the ends can still be moved afterwards. Opening it back when the
+ // press was recognised instead would have covered the very text being selected.
+ if (byTouch)
+ {
+ _handlesVisible = HasSelection;
+ MenuRequested?.Invoke(this, EventArgs.Empty);
}
+ }
+
+ protected override void OnPointerCaptureLost(PointerCaptureLostEventArgs e)
+ {
+ base.OnPointerCaptureLost(e);
+ _dragging = false;
+ _selecting = false;
+ StopEdgeScroll();
+ _grabbed = Handle.None;
+
+ // Something took the gesture away mid-selection (the system, or a popup opening). The
+ // highlight stands, so finish the job the release would have done.
+ if (!_selectingByTouch)
+ return;
+ _selectingByTouch = false;
+ _handlesVisible = HasSelection;
+ MenuRequested?.Invoke(this, EventArgs.Empty);
+ }
- DrawCursor(context, emu);
+ // Touch long-press: there is no right mouse button on a phone, so the hold is what starts a
+ // selection. It takes the word under the finger and then hands the gesture over to the drag
+ // handler — keeping the finger down and moving it grows the highlight a character at a time,
+ // across rows and into the scrollback, and the menu opens when the finger comes up.
+ //
+ // The hold used to grab the whole line and open the menu immediately, which made a line the
+ // only thing a touch user could ever copy: half a path, one field of a listing or a single
+ // number out of a status line were all out of reach.
+ protected override void OnHolding(HoldingRoutedEventArgs e)
+ {
+ base.OnHolding(e);
+ if (e.HoldingState != HoldingState.Started || e.PointerType != PointerType.Touch)
+ return;
+
+ SelectWordAt(e.Position);
+ // The hold cancels the scroll drag it grew out of, so the view doesn't fling mid-selection.
+ _dragging = false;
+ _selecting = true;
+ _selectingByTouch = true;
+ // Handles from the first moment, so both ends of the selection are visible while it grows.
+ _handlesVisible = true;
+ e.Handled = true;
}
- private void DrawRow(DrawingContext context, TerminalCell[] cells, double y)
+ 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++)
@@ -155,37 +723,53 @@ private void DrawRow(DrawingContext context, TerminalCell[] cells, double y)
new Rect(c * _cellWidth, y, _cellWidth, _cellHeight));
}
- // Text in runs of equal foreground/bold to cut FormattedText churn.
- var run = new StringBuilder();
- var runStart = 0;
- byte runFg = cells.Length > 0 ? cells[0].Foreground : TerminalPalette.DefaultForeground;
- var runBold = cells.Length > 0 && cells[0].Bold;
-
- void Flush(int end)
- {
- if (run.Length == 0) return;
- var brush = runFg == TerminalPalette.DefaultForeground
- ? DefaultFgBrush
- : new SolidColorBrush(Palette[runFg & 0x0F]);
- var ft = new FormattedText(run.ToString(), CultureInfo.InvariantCulture, FlowDirection.LeftToRight,
- runBold ? _boldTypeface : _typeface, TerminalFontSize, brush);
- context.DrawText(ft, new Point(runStart * _cellWidth, y));
- run.Clear();
- }
-
+ // 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,
+ new Rect(selStart * _cellWidth, y, (selEnd - selStart) * _cellWidth, _cellHeight));
+
+ // One character at a time, each pinned to its own cell.
+ //
+ // Handing the text engine a whole run and letting it lay the run out itself is what a normal
+ // control does, and it is wrong here: the run is only monospaced if the platform actually has
+ // a monospaced font. None of Cascadia / Consolas / Menlo exists on Android, and what it falls
+ // back to is proportional — so a row of text drew about 70% as wide as the grid it belongs
+ // to. Lines stopped short of the right edge, the cursor sat well past the end of the line it
+ // was on, and the whole screen read as squashed against the left.
+ //
+ // Per-cell drawing costs a DrawText per visible character, which is why the FormattedText
+ // objects are cached: a terminal repaints on arriving output, not per frame, and the set of
+ // (character, colour, weight) it ever shows is small.
for (var c = 0; c < cells.Length; c++)
{
var cell = cells[c];
- if (c == 0 || cell.Foreground != runFg || cell.Bold != runBold)
- {
- Flush(c);
- runStart = c;
- runFg = cell.Foreground;
- runBold = cell.Bold;
- }
- run.Append(cell.Char);
+ // 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));
}
- Flush(cells.Length);
+ }
+
+ private FormattedText GlyphFor(TerminalCell cell)
+ {
+ var key = (cell.Char, cell.Foreground, cell.Bold);
+ 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.
+ 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);
+ _glyphs[key] = text;
+ return text;
}
private void DrawCursor(DrawingContext context, TerminalEmulator emu)
@@ -211,6 +795,34 @@ protected override void OnKeyDown(KeyEventArgs e)
{
base.OnKeyDown(e);
+ // Paste before the Ctrl+letter branch below, which would otherwise swallow Ctrl+V as
+ // 0x16 (readline's quoted-insert — it eats the next keystroke and prints nothing).
+ // Ctrl+Shift+V is the terminal-native spelling, Shift+Insert the old one; plain Ctrl+V
+ // is what everyone actually presses and nothing in a shell needs 0x16.
+ var pasteCombo =
+ (e.KeyModifiers.HasFlag(KeyModifiers.Control) && e.Key == Key.V) ||
+ (e.KeyModifiers.HasFlag(KeyModifiers.Shift) && e.Key == Key.Insert);
+ if (pasteCombo)
+ {
+ PasteRequested?.Invoke(this, EventArgs.Empty);
+ e.Handled = true;
+ return;
+ }
+
+ // Copy, likewise before the Ctrl+letter branch — but only in the spellings that are not
+ // already taken. Plain Ctrl+C must stay SIGINT: it is how a running command is stopped,
+ // and no amount of clipboard convenience is worth losing that.
+ var copyCombo =
+ (e.KeyModifiers.HasFlag(KeyModifiers.Control) && e.KeyModifiers.HasFlag(KeyModifiers.Shift)
+ && e.Key == Key.C) ||
+ (e.KeyModifiers.HasFlag(KeyModifiers.Control) && e.Key == Key.Insert);
+ if (copyCombo)
+ {
+ CopyRequested?.Invoke(this, EventArgs.Empty);
+ e.Handled = true;
+ return;
+ }
+
// Ctrl+letter -> control byte (Ctrl+C = 0x03, etc.).
if (e.KeyModifiers.HasFlag(KeyModifiers.Control) && e.Key is >= Key.A and <= Key.Z)
{
diff --git a/src/OpenIPC.Viewer.App/Services/Localizer.cs b/src/OpenIPC.Viewer.App/Services/Localizer.cs
index daf8c7d..2731cfd 100644
--- a/src/OpenIPC.Viewer.App/Services/Localizer.cs
+++ b/src/OpenIPC.Viewer.App/Services/Localizer.cs
@@ -422,6 +422,14 @@ private static LangCode DetectSystem()
["Terminal.Connecting"] = "Connecting…",
["Terminal.NoCreds"] = "No SSH credentials set for this camera.",
["Terminal.FailedFormat"] = "SSH error: {0}",
+ ["Ssh.Terminal.Copy"] = "Copy",
+ ["Ssh.Terminal.CopyScreen"] = "Copy screen",
+ ["Ssh.Terminal.Paste"] = "Paste",
+ ["Ssh.Terminal.AutoKeyboard"] = "Keyboard on tap",
+ ["Ssh.Terminal.PasteMultilineFormat"] = "Pasting {0} lines: each line break would run a command.",
+ ["Ssh.Terminal.PasteJoin"] = "As one line",
+ ["Ssh.Terminal.PasteRun"] = "Run all",
+ ["Ssh.Terminal.PasteCancel"] = "Cancel",
["Ssh.HostKeyChanged.Title"] = "SSH host key changed",
["Ssh.HostKeyChanged.Message"] = "The SSH key presented by {0} does not match the one pinned earlier.\n\nNew: {1}\nPinned: {2}\n\nThis is expected if the camera was reflashed or replaced — but could also mean someone is intercepting the connection. Trust the new key and continue?",
["Ssh.HostKeyChanged.Trust"] = "Trust new key",
@@ -1056,6 +1064,14 @@ private static LangCode DetectSystem()
["Terminal.Connecting"] = "Подключение…",
["Terminal.NoCreds"] = "Для камеры не заданы SSH-учётные данные.",
["Terminal.FailedFormat"] = "Ошибка SSH: {0}",
+ ["Ssh.Terminal.Copy"] = "Копировать",
+ ["Ssh.Terminal.CopyScreen"] = "Копировать экран",
+ ["Ssh.Terminal.Paste"] = "Вставить",
+ ["Ssh.Terminal.AutoKeyboard"] = "Клавиатура по тапу",
+ ["Ssh.Terminal.PasteMultilineFormat"] = "Строк во вставке: {0}. Каждый перевод строки выполнит команду.",
+ ["Ssh.Terminal.PasteJoin"] = "Одной строкой",
+ ["Ssh.Terminal.PasteRun"] = "Выполнить",
+ ["Ssh.Terminal.PasteCancel"] = "Отмена",
["Ssh.HostKeyChanged.Title"] = "SSH host-key изменился",
["Ssh.HostKeyChanged.Message"] = "SSH-ключ, который предъявил {0}, не совпадает с ранее сохранённым.\n\nНовый: {1}\nСохранённый: {2}\n\nЭто нормально, если камеру перепрошили или заменили, — но может означать и перехват соединения. Доверять новому ключу и продолжить?",
["Ssh.HostKeyChanged.Trust"] = "Доверять ключу",
diff --git a/src/OpenIPC.Viewer.App/Services/OverlayDialogPresenter.cs b/src/OpenIPC.Viewer.App/Services/OverlayDialogPresenter.cs
index f4c4301..bf56824 100644
--- a/src/OpenIPC.Viewer.App/Services/OverlayDialogPresenter.cs
+++ b/src/OpenIPC.Viewer.App/Services/OverlayDialogPresenter.cs
@@ -6,7 +6,10 @@
using Avalonia.Animation.Easings;
using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes;
+using Avalonia.Controls.Platform;
using Avalonia.Controls.Primitives;
+using Avalonia.Input;
+using Avalonia.Interactivity;
using Avalonia.Layout;
using Avalonia.Media;
using Avalonia.Media.Transformation;
@@ -33,7 +36,7 @@ namespace OpenIPC.Viewer.App.Services;
// own background via Bg1Brush, the wrapper provides the modal affordances.
// Designed to share the SAME content UserControl as the desktop Window
// wrapper — each dialog moves its inner Grid/StackPanel into a *Content UC
-// that owns the TCS; the Window wrapper just bridges TCS → Window.Close.
+// that owns the TCS; the Window wrapper just bridges TCS to Window.Close.
public static class OverlayDialogPresenter
{
private static readonly TimeSpan FadeIn = TimeSpan.FromMilliseconds(180);
@@ -43,6 +46,11 @@ public static class OverlayDialogPresenter
// keeps the card's top edge clear of the status bar / notch.
private const double TopPeek = 56;
+ // Ceiling on the safe-area inset we pad the sheet by. System bars are ~24-48dp; a larger value
+ // means the platform folded the soft keyboard into the inset, and it would be counted twice —
+ // SoftKeyboardInset adds the keyboard's own height on top of this.
+ private const double MaxSafeInset = 80;
+
// Air between a sheet's last row and the navigation bar / gesture pill.
private const double SheetBottomGap = 14;
@@ -143,15 +151,47 @@ public static async Task ShowAsync(Control content, Task 0 ? safe.Top : 28, 0, 0)
- : new Thickness(0, 0, 0, safe.Bottom + SheetBottomGap);
+ // Keep whatever the user is typing into visible: the card's bottom padding takes the
+ // keyboard's height, and a field below the fold (the login/password pair at the bottom of
+ // the add-camera form) has to be scrolled back up inside it. Nudge the focused control into
+ // view when focus moves, when the viewport changes, and when the keyboard opens.
+ void ScrollFocusedIntoView() => Dispatcher.UIThread.Post(
+ () => (top?.FocusManager?.GetFocusedElement() as Control)?.BringIntoView(),
+ DispatcherPriority.Background);
+
+ // The overlay layer sits ABOVE the shell, so it does not inherit the
+ // safe-area padding MainView applies to itself — without this the sheet's
+ // action row (Cancel / Add selected) rendered underneath the Android
+ // navigation bar. Full-screen pages also need the status-bar inset.
+ var insets = top?.InsetsManager;
+
+ void ApplySafeArea()
+ {
+ var pad = insets?.SafeAreaPadding ?? default;
+ var left = Clamp(pad.Left);
+ var right = Clamp(pad.Right);
+ // The keyboard is not a system bar and is not subject to MaxSafeInset — it is ~300dp of
+ // screen, and under edge-to-edge it can land on top of the card rather than resize the
+ // window out from under it. A full-screen page (the SSH terminal) shows it worst: the
+ // prompt ends up behind the keys being typed on. Max rather than sum — the keyboard
+ // covers the navigation bar, so the bar's inset is already part of it.
+ var bottom = Math.Max(Clamp(pad.Bottom) + (fullScreen ? 0 : SheetBottomGap), SoftKeyboardInset.Of(top));
+ card.Padding = fullScreen
+ ? new Thickness(left, pad.Top > 0 ? Clamp(pad.Top) : 28, right, bottom)
+ : new Thickness(left, 0, right, bottom);
+ }
+
+ void OnSafeAreaChanged(object? sender, SafeAreaChangedArgs e) => ApplySafeArea();
+
+ ApplySafeArea();
+ if (insets is not null)
+ insets.SafeAreaChanged += OnSafeAreaChanged;
+
+ var keyboardSub = SoftKeyboardInset.Subscribe(top, () =>
+ {
+ ApplySafeArea();
+ ScrollFocusedIntoView();
+ });
var dim = new Border
{
@@ -169,6 +209,9 @@ public static async Task ShowAsync(Control content, Task ScrollFocusedIntoView(),
+ RoutingStrategies.Bubble);
+
// Drive the dim to cover the full client area and cap the sheet to that
// height (minus the top peek) so long forms scroll inside the card
// instead of pushing their title off-screen.
@@ -177,9 +220,12 @@ void ApplySize(Size s)
if (s.Width <= 0 || s.Height <= 0) return;
dim.Width = s.Width;
dim.Height = s.Height;
+ // Rotation arrives here and nowhere else, and it changes what the keyboard is covering.
+ ApplySafeArea();
// Sheet leaves a top peek of the page; a full-screen page fills all.
if (!fullScreen)
card.MaxHeight = Math.Max(0, s.Height - TopPeek);
+ ScrollFocusedIntoView();
}
IDisposable? sizeSub = null;
@@ -214,12 +260,18 @@ void ApplySize(Size s)
finally
{
sizeSub?.Dispose();
+ keyboardSub.Dispose();
+ if (insets is not null)
+ insets.SafeAreaChanged -= OnSafeAreaChanged;
overlay.Children.Remove(dim);
Open.Remove(entry);
ActiveChanged?.Invoke();
}
}
+ private static double Clamp(double inset) => Math.Clamp(inset, 0, MaxSafeInset);
+
+
private static IBrush? ResolveBrush(string key) =>
Application.Current is not null
&& Application.Current.TryFindResource(key, out var value)
diff --git a/src/OpenIPC.Viewer.App/Services/SoftKeyboardInset.cs b/src/OpenIPC.Viewer.App/Services/SoftKeyboardInset.cs
new file mode 100644
index 0000000..7efca85
--- /dev/null
+++ b/src/OpenIPC.Viewer.App/Services/SoftKeyboardInset.cs
@@ -0,0 +1,98 @@
+using System;
+using System.Runtime.CompilerServices;
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Controls.Platform;
+
+namespace OpenIPC.Viewer.App.Services;
+
+// How much room the app still has to make for the soft keyboard, after whatever the platform
+// already did about it.
+//
+// The activity asks for AdjustResize, and for a long time that was the whole story: Android shrank
+// the window, ClientSize shrank with it, and every layout above simply had less room. Under the
+// edge-to-edge display a targetSdk-36 app gets, that is no longer guaranteed — the keyboard can
+// slide over the content instead, leaving a terminal prompt or a password field underneath it.
+//
+// Which of the two happens is not something to guess at, and padding by the keyboard's full height
+// on a window that was already shrunk lifts the content twice as far as it should. So measure it:
+// remember how tall the client area is while the keyboard is down, and when it comes up, ask for
+// only the part of it that the window did not already give up.
+public static class SoftKeyboardInset
+{
+ // A keyboard takes roughly a third to a half of the screen. Anything past this is a number we
+ // don't understand, and acting on it would push the whole UI off the top.
+ private const double MaxShareOfScreen = 0.7;
+
+ // The client size with the keyboard down, per window. The width is stored with it because that
+ // is what tells rotation apart from the keyboard: turning the phone changes both dimensions, and
+ // a baseline taken in portrait says nothing about how tall a landscape window should be. Weak so
+ // a closed window is not held alive by this.
+ private static readonly ConditionalWeakTable> Baselines = new();
+
+ ///
+ /// Height the app must keep clear at the bottom for the soft keyboard, in DIPs. Zero when the
+ /// keyboard is down, and zero when the platform already resized the window for it.
+ ///
+ public static double Of(TopLevel? top)
+ {
+ if (top?.InputPane is not { } pane)
+ return 0;
+
+ var height = top.ClientSize.Height;
+ if (height <= 0)
+ return 0;
+
+ if (pane.State != InputPaneState.Open)
+ {
+ // The keyboard is down, so this is the full size to measure against later.
+ Baselines.GetValue(top, _ => new StrongBox(top.ClientSize)).Value = top.ClientSize;
+ return 0;
+ }
+
+ // A rect positioned in the window says where the keyboard starts; one reported at the
+ // origin is only saying how tall it is. Both readings mean the same height.
+ var rect = pane.OccludedRect;
+ var keyboard = rect.Y > 0 ? height - rect.Y : rect.Height;
+
+ // What the platform already took out of the window on its own — but only if the baseline is
+ // still about this window shape. Rotating with the keyboard up left a portrait height on
+ // record, which made the landscape window look as though the platform had already given up
+ // 400dp for the keyboard: the inset came out zero and the keys covered the prompt.
+ var surrendered = Baselines.TryGetValue(top, out var baseline)
+ && Math.Abs(baseline.Value.Width - top.ClientSize.Width) < 0.5
+ ? Math.Max(0, baseline.Value.Height - height)
+ : 0;
+
+ return Math.Clamp(keyboard - surrendered, 0, height * MaxShareOfScreen);
+ }
+
+ /// Calls whenever the keyboard opens or closes.
+ public static IDisposable Subscribe(TopLevel? top, Action onChanged)
+ {
+ if (top?.InputPane is not { } pane)
+ return NoSubscription.Instance;
+
+ void Handler(object? sender, InputPaneStateEventArgs e) => onChanged();
+ pane.StateChanged += Handler;
+ return new Unsubscribe(() => pane.StateChanged -= Handler);
+ }
+
+ private sealed class Unsubscribe : IDisposable
+ {
+ private Action? _dispose;
+ public Unsubscribe(Action dispose) => _dispose = dispose;
+ public void Dispose()
+ {
+ var d = _dispose;
+ _dispose = null;
+ d?.Invoke();
+ }
+ }
+
+ private sealed class NoSubscription : IDisposable
+ {
+ public static readonly NoSubscription Instance = new();
+ public void Dispose() { }
+ }
+}
diff --git a/src/OpenIPC.Viewer.App/Services/SshTerminalFactory.cs b/src/OpenIPC.Viewer.App/Services/SshTerminalFactory.cs
index 11bf742..430a50b 100644
--- a/src/OpenIPC.Viewer.App/Services/SshTerminalFactory.cs
+++ b/src/OpenIPC.Viewer.App/Services/SshTerminalFactory.cs
@@ -22,5 +22,6 @@ public SshTerminalFactory(CameraDirectoryService directory, ISshSessionFactory s
}
public SshTerminalViewModel Create(Camera camera) =>
- new(camera, _directory, _sessions, _settings.Current.SshTerminalFontSize, _loggerFactory.CreateLogger());
+ new(camera, _directory, _sessions, _settings.Current.SshTerminalFontSize, _settings,
+ _loggerFactory.CreateLogger());
}
diff --git a/src/OpenIPC.Viewer.App/Services/UserSettings.cs b/src/OpenIPC.Viewer.App/Services/UserSettings.cs
index dc6047f..bb14822 100644
--- a/src/OpenIPC.Viewer.App/Services/UserSettings.cs
+++ b/src/OpenIPC.Viewer.App/Services/UserSettings.cs
@@ -44,6 +44,10 @@ public sealed record UserSettings(
bool SshStrictHostKey = true,
int SshDefaultPort = 22,
int SshTerminalFontSize = 14,
+ // Whether a tap on the terminal raises the soft keyboard (mobile). Off means the keyboard
+ // 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,
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/SshTerminalViewModel.cs b/src/OpenIPC.Viewer.App/ViewModels/SshTerminalViewModel.cs
index 4a8ea6c..df14d8c 100644
--- a/src/OpenIPC.Viewer.App/ViewModels/SshTerminalViewModel.cs
+++ b/src/OpenIPC.Viewer.App/ViewModels/SshTerminalViewModel.cs
@@ -23,6 +23,7 @@ public sealed partial class SshTerminalViewModel : ViewModelBase, IAsyncDisposab
private readonly Camera _camera;
private readonly CameraDirectoryService _directory;
private readonly ISshSessionFactory _sessions;
+ private readonly UserSettingsService _settings;
private readonly ILogger _logger;
private ISshSession? _session;
@@ -34,6 +35,13 @@ public sealed partial class SshTerminalViewModel : ViewModelBase, IAsyncDisposab
public string Title => $"SSH — {_camera.Name}";
public double FontSize { get; }
+ /// Whether a tap on the terminal is allowed to raise the soft keyboard.
+ public bool AutoKeyboard => _settings.Current.SshTerminalAutoKeyboard;
+
+ /// Flips and remembers it for the next session.
+ public Task SetAutoKeyboardAsync(bool enabled) =>
+ _settings.UpdateAsync(_settings.Current with { SshTerminalAutoKeyboard = enabled });
+
[ObservableProperty] private string _statusText = "";
[ObservableProperty] private bool _isConnected;
@@ -42,11 +50,13 @@ public SshTerminalViewModel(
CameraDirectoryService directory,
ISshSessionFactory sessions,
double fontSize,
+ UserSettingsService settings,
ILogger logger)
{
_camera = camera;
_directory = directory;
_sessions = sessions;
+ _settings = settings;
FontSize = fontSize >= 8 ? fontSize : 14;
_logger = logger;
}
diff --git a/src/OpenIPC.Viewer.App/Views/Dialogs/SshTerminalContent.axaml b/src/OpenIPC.Viewer.App/Views/Dialogs/SshTerminalContent.axaml
index a8db576..9a1ba72 100644
--- a/src/OpenIPC.Viewer.App/Views/Dialogs/SshTerminalContent.axaml
+++ b/src/OpenIPC.Viewer.App/Views/Dialogs/SshTerminalContent.axaml
@@ -13,7 +13,9 @@
tap doesn't steal focus from the IME proxy (the keyboard would hide). -->
-
+
+
+
+
+
+
+
+
+
+
+
+
+
-
+
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+