// // Copyright (c) Antmicro // // Full license details are defined in the 'LICENSE' file. // #define REMOVE_DUMMY_ROWS using System; using System.Collections.Generic; using System.Diagnostics; using System.Threading.Tasks; using TermSharp.Misc; using TermSharp.Rows; using Xwt; using Xwt.Drawing; namespace TermSharp { public class Terminal : HBox { public Terminal(Func focusProvider = null) { if(focusProvider == null) { this.focusProvider = () => canvas.HasFocus; } else { this.focusProvider = () => focusProvider() && canvas.HasFocus; } rows = new List(); heightMap = new List(); layoutParameters = new LayoutParameters(Font, DefaultGray, Colors.Black, Colors.LightSlateGray); layoutParameters.Font = Font.SystemMonospaceFont; canvas = new TerminalCanvas(this); cursor = new Cursor(this, canvas); PackStart(canvas, true, true); scrollbar = new VScrollbar(); scrollbar.Sensitive = false; PackEnd(scrollbar); canvas.MouseScrolled += OnCanvasMouseScroll; canvas.BoundsChanged += OnCanvasBoundsChanged; canvas.ButtonPressed += OnCanvasButtonPressed; canvas.ButtonReleased += OnCanvasButtonReleased; canvas.MouseMoved += OnCanvasMouseMoved; scrollbar.ValueChanged += OnScrollbarValueChanged; autoscrollEnabled = new TaskCompletionSource(); HandleAutoscrollAsync(); canvas.CanGetFocus = true; scrollbar.StepIncrement = Utilities.GetLineSizeFromLayoutParams(layoutParameters).Height; } public void Close() { scrollbar.ValueChanged -= OnScrollbarValueChanged; canvas.MouseScrolled -= OnCanvasMouseScroll; canvas.BoundsChanged -= OnCanvasBoundsChanged; canvas.ButtonPressed -= OnCanvasButtonPressed; canvas.ButtonReleased -= OnCanvasButtonReleased; canvas.MouseMoved -= OnCanvasMouseMoved; cursor.Dispose(); base.Dispose(disposing: true); } public void AppendRow(IRow row, bool treatRowAsNonDummy = false) { var weWereAtEnd = scrollbar.Value == GetMaximumScrollbarValue(); var heightMapNeedsRebuilding = false; if(treatRowAsNonDummy) { var position = GetScreenRowIndex(Cursor.Position.Y); var rowId = Math.Min(position, rows.Count - 1); if(rowId < rows.Count - 1) { rows.RemoveRange(rowId + 1, rows.Count - rowId - 1); heightMapNeedsRebuilding = true; } // this counts in the row we are about to add lastNonDummyRow = rows.Count; } rows.Add(row); row.PrepareForDrawing(layoutParameters); if(heightMapNeedsRebuilding) { // this must be done after adding the row, otherwise the cursor will be wrongly placed RebuildHeightMap(true); } else { AddToHeightMap(row.PrepareForDrawing(layoutParameters)); } RefreshInner(weWereAtEnd); } public new void Clear() { Cursor.Position = new IntegerPosition(); rows.Clear(); RebuildHeightMap(true); } public void Refresh() { var weWereAtEnd = scrollbar.Value == GetMaximumScrollbarValue(); RebuildHeightMap(true); RefreshInner(weWereAtEnd); } public void Redraw() { canvas.Redraw(); } public void PageUp() { SetScrollbarValue(scrollbar.Value - scrollbar.PageSize); } public void PageDown() { SetScrollbarValue(scrollbar.Value + scrollbar.PageSize); } public void LineUp() { ScrollRows(-1); } public void LineDown() { ScrollRows(1); } public void ClearSelection() { canvas.SelectedArea = default(Rectangle); currentScrollStart = null; RefreshSelection(); } public ClipboardData CollectClipboardData() { var result = new ClipboardData(); foreach(var row in rows) { row.FillClipboardData(result); } return result; } public IRow GetScreenRow(int screenPosition, bool treatRowAsNonDummy = false) { var position = GetScreenRowIndex(screenPosition); if(treatRowAsNonDummy) { lastNonDummyRow = Math.Max(lastNonDummyRow, position); } return rows[Math.Min(position, rows.Count - 1)]; } public IRow GetFirstScreenRow(out double hiddenHeight) { double rowStart; var result = rows[FindRowIndexAtPosition(GetMaximumScrollbarValue(), out rowStart)]; hiddenHeight = GetMaximumScrollbarValue() - rowStart; return result; } public void EraseScreen(IntegerPosition from, IntegerPosition to, Color? background) { for(var rowScreenPosition = from.Y; rowScreenPosition <= to.Y; rowScreenPosition++) { var row = GetScreenRow(rowScreenPosition); row.Erase(rowScreenPosition == from.Y ? from.X : 0, rowScreenPosition == to.Y ? to.X : row.CurrentMaximalCursorPosition, background); } canvas.Redraw(); } public void MoveScrollbarToBeginning() { SetScrollbarValue(scrollbar.LowerValue); } public void MoveScrollbarToEnd() { SetScrollbarValue(scrollbar.UpperValue - canvas.CachedBounds.Height); } public Font CurrentFont { get { return layoutParameters.Font; } set { bool fontSizeDecreased = layoutParameters.Font.Size > value.Size; layoutParameters.Font = value; Redraw(); if(rows.Count > 0) { if(fontSizeDecreased) { RebuildHeightMap(); } OnCanvasBoundsChanged(null, null); } } } public int RowCount { get { return rows.Count; } } public int ScreenRowCount { get { double unused; var firstRowNo = FindRowIndexAtPosition(GetMaximumScrollbarValue(), out unused); return RowCount - firstRowNo; } } public double ScreenSize { get { return canvas.CachedBounds.Height; } } public SelectionMode SelectionMode { get { return canvas.SelectionMode; } set { canvas.SelectionMode = value; } } public new Cursor Cursor { get { return cursor; } } public Color DefaultForeground { get { return layoutParameters.DefaultForeground; } set { layoutParameters.DefaultForeground = value; } } public Color DefaultBackground { get { return layoutParameters.DefaultBackground; } set { layoutParameters.DefaultBackground = value; } } public Color SelectionColor { get { return layoutParameters.SelectionColor; } set { layoutParameters.SelectionColor = value; } } public double InnerMarginLeft { get { return canvas.MarginLeft; } set { canvas.MarginLeft = value; } } public double InnerMarginRight { get { return canvas.MarginRight; } set { canvas.MarginRight = value; } } public double InnerMarginBottom { get { return canvas.MarginBottom; } set { canvas.MarginBottom = value; } } public double InnerMarginTop { get { return canvas.MarginTop; } set { canvas.MarginTop = value; } } public WidgetSpacing InnerMargin { get { return canvas.Margin; } set { canvas.Margin = value; } } public Menu ContextMenu { get; set; } public new event EventHandler KeyPressed { add { canvas.KeyPressed += value; } remove { canvas.KeyPressed -= value; } } public event Action Initialized; static internal Color DefaultGray = new Color(0.75, 0.75, 0.75); private void OnScrollbarValueChanged(object sender, EventArgs e) { ScrollbarValueChanged(); } private void ScrollbarValueChanged() { double rowOffset; canvas.FirstRowToDisplay = FindRowIndexAtPosition(scrollbar.Value, out rowOffset); canvas.FirstRowHeight = rowOffset; canvas.OffsetFromFirstRow = scrollbar.Value - rowOffset; RefreshSelection(); } private void OnCanvasButtonPressed(object sender, ButtonEventArgs e) { if(e.Button == PointerButton.Left) { canvas.SetFocus(); var position = e.Position; lastMousePosition = position; position.Y += scrollbar.Value; currentScrollStart = position; foreach(var row in rows) { row.ResetSelection(); } HandleMultiplePresses(e); RefreshSelection(); } if(e.Button == PointerButton.Right) { var contextMenu = ContextMenu; if(contextMenu != null) { contextMenu.Popup(); } } } private Rectangle GetWordRect(int x, int y) { var row = rows[FindRowIndexAtPosition(y, out var yStart)]; var rowText = row.TextContent; var colIdx = row.GetColumnIndex(x); var wrappedRow = (row.LineHeight == 0.0) ? 0 : (int)((y - yStart) / row.LineHeight); var selectedWordStart = colIdx + (row.MaximalColumn + 1) * wrappedRow; while(selectedWordStart > 0 && selectedWordStart < rowText.Length) { if(char.IsWhiteSpace(rowText[selectedWordStart - 1])) { break; } selectedWordStart--; } var selectedWordEnd = colIdx + (row.MaximalColumn + 1) * wrappedRow; while(selectedWordEnd > 0 && selectedWordEnd < rowText.Length - 1) { if(char.IsWhiteSpace(rowText[selectedWordEnd + 1])) { break; } selectedWordEnd++; } var startX = row.IndexToColumn(selectedWordStart % (row.MaximalColumn + 1)); var endX = row.IndexToColumn(selectedWordEnd % (row.MaximalColumn + 1)); var startWrapRow = (int)(selectedWordStart / (row.MaximalColumn + 1)); var endWrapRow = (int)(selectedWordEnd / (row.MaximalColumn + 1)); var wrapRowDiff = endWrapRow - startWrapRow; var width = endX - startX; // The required y range is exclusive so we need it to be bigger by an epsilon, since the +0.001 var rectY = yStart + 0.001 + (startWrapRow*row.LineHeight); return new Rectangle(startX, rectY, width, wrapRowDiff * row.LineHeight); } private Rectangle GetLineRect(int x, int y) { var row = rows[FindRowIndexAtPosition(y, out var yStart)]; var rowText = row.TextContent; // See comment in GetWordRect return new Rectangle(0, yStart + 0.001, row.IndexToColumn(rowText.Length), (row.SublineCount - 1) * row.LineHeight); } private void HandleMultiplePresses(ButtonEventArgs e) { switch(e.MultiplePress) { case 1: SelectionMode = SelectionMode.Normal; break; case 2: SelectionMode = SelectionMode.Word; break; case 3: SelectionMode = SelectionMode.Line; break; } } private void OnCanvasButtonReleased(object sender, ButtonEventArgs e) { if(e.Button == PointerButton.Left) { SetAutoscrollValue(0); var mousePosition = e.Position; mousePosition.Y += scrollbar.Value; if(mousePosition == (currentScrollStart ?? default(Point)) && SelectionMode == SelectionMode.Normal) { canvas.SelectedArea = default(Rectangle); } currentScrollStart = null; RefreshSelection(); if(canvas.SelectedArea != default(Rectangle)) { Clipboard.SetPrimaryText(CollectClipboardData().Text); } } } private void OnCanvasMouseMoved(object sender, MouseMovedEventArgs e) { if(!currentScrollStart.HasValue) { return; } lastMousePosition = e.Position; if(e.Position.Y < 0) { SetAutoscrollValue((int)e.Position.Y); } else if(e.Position.Y > canvas.CachedBounds.Height) { SetAutoscrollValue((int)(e.Position.Y - canvas.CachedBounds.Height)); } else { SetAutoscrollValue(0); } RefreshSelection(); } private void OnCanvasMouseScroll(object sender, MouseScrolledEventArgs e) { int modifier; switch(e.Direction) { case ScrollDirection.Up: modifier = -1; break; case ScrollDirection.Down: modifier = 1; break; default: modifier = 0; break; } SetScrollbarValue(scrollbar.Value + scrollbar.StepIncrement * modifier); } #if DEBUG private async void OnCanvasBoundsChanged(object sender, EventArgs e) #else private void OnCanvasBoundsChanged(object sender, EventArgs e) #endif { canvas.SelectedArea = default(Rectangle); // refresh cached bounds value canvas.CachedBounds = canvas.Bounds; double oldPosition; var firstDisplayedRowIndex = FindRowIndexAtPosition(scrollbar.Value, out oldPosition); var oldScrollbarValue = scrollbar.Value; var weWereAtEnd = scrollbar.Value == GetMaximumScrollbarValue(); layoutParameters.Width = canvas.Size.Width; #if REMOVE_DUMMY_ROWS var numberOfVisibleLines = (int)Math.Floor(ScreenSize / rows[0].LineHeight); if(heightMap.Count > lastNonDummyRow + 1 && lastNonDummyRow < numberOfVisibleLines) { rows.RemoveRange(lastNonDummyRow + 1, heightMap.Count - lastNonDummyRow - 2); } #endif #if DEBUG if(!RebuildHeightMap(false)) { var boundChangedGeneration = ++canvasBoundChangedGeneration; await Task.Delay(TimeSpan.FromMilliseconds(200)); if(boundChangedGeneration != canvasBoundChangedGeneration) { return; } RebuildHeightMap(true); } #else RebuildHeightMap(true); #endif scrollbar.UpperValue = GetMaximumHeight(); if(!weWereAtEnd) { // difference between old and new position of the first displayed row: var diff = GetPositionOfTheRow(firstDisplayedRowIndex) - oldPosition; SetScrollbarValue(oldScrollbarValue + diff); } else { MoveScrollbarToEnd(); } canvas.Redraw(); if(!isInitialized) { isInitialized = true; CallInitializedEvent(); } } private void CallInitializedEvent() { var initialized = Initialized; if(initialized != null) { initialized(); } } private void RefreshInner(bool weWereAtEnd) { canvas.Redraw(); scrollbar.Sensitive = GetMaximumHeight() > canvas.CachedBounds.Height; scrollbar.UpperValue = GetMaximumHeight(); if(weWereAtEnd) { MoveScrollbarToEnd(); } } private void SetAutoscrollValue(int value) { autoscrollStep = value; if(value != 0) { autoscrollEnabled.TrySetResult(true); } else { if(autoscrollEnabled.Task.IsCompleted) { autoscrollEnabled = new TaskCompletionSource(); } } } private void SetScrollbarValue(double value) { var finalValue = Math.Max(0, value); finalValue = Math.Min(finalValue, GetMaximumScrollbarValue()); scrollbar.Value = finalValue; } // This is not the same as `Rectangle.Union`. It's possible for the width to be // a negative value and it's intentional because later we use it to derive the // 'selection direction' in `MonospaceTextRow`. This is for handling single lines with // multiple line breaks. There are two possible cases (not 4 because of the swapping): // // 1) First one is the 'normal one' when a.X < b.X, where we get a union // a // +---+-----+ // | | | // +---+ b | // | +---+ // | | | // +-----+---+ // // 2) Second is the 'negative width one' when a.X > b.X, where we go // from a's top left to b's bottom right // a // +----+---+ // | | | // b | +---+ // +---+ | // | | | // +---+----+ // private Rectangle CombineTwoRects(Rectangle a, Rectangle b) { if(a.Y > b.Y || (a.Y == b.Y && a.X > b.X) || (a.X == b.X && a.Y == b.Y && a.Width > b.Width)) { Utilities.Swap(ref a, ref b); } return new Rectangle(a.X, a.Y, b.X - a.X + b.Width, b.Y - a.Y + b.Height); } private bool ShouldNotHighlight(Rectangle selectedArea, SelectionMode selectionMode) { if(selectionMode != SelectionMode.Normal) { return false; } const int boxSize = 3; return Math.Abs(canvas.SelectedArea.Width) < boxSize && Math.Abs(canvas.SelectedArea.Height) < boxSize; } private void RefreshSelection() { if(currentScrollStart.HasValue) { var scrollStart = currentScrollStart.Value; if(SelectionMode == SelectionMode.Normal) { canvas.SelectedArea = new Rectangle(scrollStart.X, scrollStart.Y, lastMousePosition.X - scrollStart.X, lastMousePosition.Y + scrollbar.Value - scrollStart.Y); } else if(SelectionMode == SelectionMode.Word) { var startRect = GetWordRect((int)scrollStart.X, (int)scrollStart.Y); var endRect = GetWordRect((int)lastMousePosition.X, (int)(lastMousePosition.Y + scrollbar.Value)); canvas.SelectedArea = CombineTwoRects(startRect, endRect); } else if(SelectionMode == SelectionMode.Line) { Rectangle startRect = GetLineRect((int)scrollStart.X, (int)scrollStart.Y); Rectangle endRect = GetLineRect((int)lastMousePosition.X, (int)(lastMousePosition.Y + scrollbar.Value)); canvas.SelectedArea = CombineTwoRects(startRect, endRect); } if(ShouldNotHighlight(canvas.SelectedArea, SelectionMode)) { canvas.SelectedArea = default(Rectangle); } } canvas.Redraw(); } private async void HandleAutoscrollAsync() { while(true) { await Task.Delay(TimeSpan.FromMilliseconds(40)); if(autoscrollStep != 0) { if(Math.Abs(autoscrollStep) > scrollbar.PageSize / 2) { autoscrollStep = (int)(Math.Sign(autoscrollStep) * scrollbar.PageSize / 2); } SetScrollbarValue(scrollbar.Value + autoscrollStep); } await autoscrollEnabled.Task; } } private bool RebuildHeightMap(bool continueEvenIfLongTask = true) { if(rows.Count == 0) { heightMap = new List(); return true; } var oldFirstScreenRow = GetScreenRowIndex(0); var stopwatch = new Stopwatch(); stopwatch.Start(); List newHeightMap; if(unfinishedHeightMap != null && unfinishedHeightMap.Count == rows.Count) { newHeightMap = unfinishedHeightMap; } else { newHeightMap = new List(rows.Count); unfinishedHeightMap = newHeightMap; } var heightSoFar = 0.0; for(var i = 0; i < rows.Count; i++) { heightSoFar += rows[i].PrepareForDrawing(layoutParameters); newHeightMap.Add(heightSoFar); if(!continueEvenIfLongTask && (i % HeightMapCheckTimeoutEveryNthRow) == 1 && stopwatch.Elapsed > HeightMapRebuildTimeout) { return false; } } heightMap = newHeightMap; unfinishedHeightMap = null; scrollbar.PageSize = canvas.CachedBounds.Height; // we update it here to get new value on GetScreenRowId (it depends on height map and this value) var firstScreenRow = GetScreenRowIndex(0); var diff = firstScreenRow - oldFirstScreenRow; cursor.Position = cursor.Position.ShiftedByY(-diff); return true; } private void AddToHeightMap(double value) { if(heightMap.Count == 0) { heightMap.Add(value); return; } heightMap.Add(value + heightMap[heightMap.Count - 1]); } private double GetPositionOfTheRow(int rowIndex) { return rowIndex > 0 ? heightMap[rowIndex - 1] : 0.0; } private double GetMaximumScrollbarValue() { return Math.Max(0, GetMaximumHeight() - scrollbar.PageSize); } private double GetMaximumHeight() { if(heightMap.Count == 0) { return 0; } return heightMap[heightMap.Count - 1]; } private int FindRowIndexAtPosition(double position, out double rowStart) { var result = heightMap.BinarySearch(position); if(result < 0) { result = ~result; } else { result++; // because heightMap[i] shows where ith row *ends* and therefore where (i+1)th starts } if(result == heightMap.Count) { result--; } rowStart = GetPositionOfTheRow(result); return result; } private int GetScreenRowIndex(int screenPosition) { double unused; return FindRowIndexAtPosition(GetMaximumScrollbarValue(), out unused) + screenPosition; } private void ScrollRows(int offset) { var firstDisplayedRowIndex = FindRowIndexAtPosition(scrollbar.Value, out _); var newPosition = GetPositionOfTheRow(firstDisplayedRowIndex + offset); SetScrollbarValue(newPosition); } private List heightMap; private List unfinishedHeightMap; #if DEBUG private int canvasBoundChangedGeneration; #endif private Point? currentScrollStart; private Point lastMousePosition; private int autoscrollStep; private TaskCompletionSource autoscrollEnabled; private int lastNonDummyRow; private bool isInitialized; private readonly List rows; private readonly LayoutParameters layoutParameters; private readonly VScrollbar scrollbar; private readonly TerminalCanvas canvas; private readonly Cursor cursor; private readonly Func focusProvider; private static readonly TimeSpan HeightMapRebuildTimeout = TimeSpan.FromMilliseconds(30); private const int HeightMapCheckTimeoutEveryNthRow = 1000; internal sealed class TerminalCanvas : Canvas { public TerminalCanvas(Terminal parent) { this.parent = parent; Cursor = CursorType.IBeam; } public void Redraw() { if(drawn) { QueueDraw(); drawn = false; } } public Rectangle CachedBounds { get { if(!cachedBounds.HasValue) { cachedBounds = Bounds; } return cachedBounds.Value; } set { cachedBounds = value; } } public int FirstRowToDisplay { get; set; } public double FirstRowHeight { get; set; } public double OffsetFromFirstRow { get; set; } public Rectangle SelectedArea { get; set; } public SelectionMode SelectionMode { get; set; } protected override void OnDraw(Context ctx, Rectangle dirtyRect) { var screenSelectedArea = SelectedArea; var selectionDirection = SelectionDirection.SE; if(screenSelectedArea.Width < 0) { selectionDirection = (SelectionDirection)((int)selectionDirection + 1); screenSelectedArea.X += screenSelectedArea.Width; screenSelectedArea.Width = -screenSelectedArea.Width; } if(screenSelectedArea.Height < 0) { selectionDirection = (SelectionDirection)((int)selectionDirection + 2); screenSelectedArea.Y += screenSelectedArea.Height; screenSelectedArea.Height = -screenSelectedArea.Height; } screenSelectedArea.Y -= FirstRowHeight; var heightSoFar = 0.0; ctx.Save(); ctx.SetColor(parent.DefaultBackground); ctx.Rectangle(new Rectangle(0, 0, CachedBounds.Width, CachedBounds.Height)); ctx.Fill(); ctx.Restore(); ctx.Translate(0, -OffsetFromFirstRow); ctx.Save(); var i = FirstRowToDisplay; var cursorRow = parent.GetScreenRowIndex(parent.Cursor.Position.Y); while(i < parent.rows.Count && heightSoFar - OffsetFromFirstRow < CachedBounds.Height) { var height = parent.rows[i].PrepareForDrawing(parent.layoutParameters); var rowRectangle = new Rectangle(0, heightSoFar, parent.layoutParameters.Width, height); var selectedAreaInRow = rowRectangle.Intersect(screenSelectedArea); if(SelectionMode != SelectionMode.Block && selectedAreaInRow != default(Rectangle) && (screenSelectedArea.Y <= rowRectangle.Y || screenSelectedArea.Y + screenSelectedArea.Height >= rowRectangle.Y + rowRectangle.Height)) { if(rowRectangle.Y < screenSelectedArea.Y) { // I'm the first row (and there is a second row) selectedAreaInRow.X = SelectedArea.Height > 0 ? SelectedArea.X : SelectedArea.X + SelectedArea.Width; selectedAreaInRow.Width = parent.layoutParameters.Width - selectedAreaInRow.X; } else if(rowRectangle.Y + rowRectangle.Height > screenSelectedArea.Y + screenSelectedArea.Height) { // I'm the last row (and there is some other row) selectedAreaInRow.Width = SelectedArea.Height > 0 ? SelectedArea.X + SelectedArea.Width : SelectedArea.X; selectedAreaInRow.X = 0; } else { // nor the first neither the last one - must be one of the middle rows selectedAreaInRow.X = 0; selectedAreaInRow.Width = parent.layoutParameters.Width; } } if(selectedAreaInRow != default(Rectangle)) { selectedAreaInRow.Y -= heightSoFar; } ctx.Save(); var hasFocus = parent.focusProvider(); parent.rows[i].Draw(ctx, selectedAreaInRow, selectionDirection, parent.SelectionMode); if(parent.Cursor.Enabled && i == cursorRow && (parent.Cursor.BlinkState || !hasFocus)) { parent.rows[i].DrawCursor(ctx, parent.Cursor.Position.X, hasFocus); } ctx.Restore(); heightSoFar += height; ctx.Translate(0, height); i++; } ctx.Restore(); drawn = true; } private bool drawn; private Rectangle? cachedBounds; private readonly Terminal parent; } } }