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). --> - + + + + + + +