Add live display support

This commit also adds functionality to LiveRenderable that should
fix some problems related to vertical overflow.

Closes #316
Closes #415
This commit is contained in:
Patrik Svensson
2021-05-20 12:05:47 +02:00
committed by Phil Scott
parent 5d68020abb
commit 3dea412785
22 changed files with 756 additions and 20 deletions

View File

@ -0,0 +1,20 @@
using Spectre.Console.Rendering;
namespace Spectre.Console
{
/// <summary>
/// A console capable of writing ANSI escape sequences.
/// </summary>
public static partial class AnsiConsole
{
/// <summary>
/// Creates a new <see cref="LiveDisplay"/> instance.
/// </summary>
/// <param name="target">The target renderable to update.</param>
/// <returns>A <see cref="LiveDisplay"/> instance.</returns>
public static LiveDisplay Live(IRenderable target)
{
return Console.Live(target);
}
}
}

View File

@ -0,0 +1,32 @@
using System;
using Spectre.Console.Rendering;
namespace Spectre.Console
{
/// <summary>
/// Contains extension methods for <see cref="IAnsiConsole"/>.
/// </summary>
public static partial class AnsiConsoleExtensions
{
/// <summary>
/// Creates a new <see cref="LiveDisplay"/> instance for the console.
/// </summary>
/// <param name="console">The console.</param>
/// <param name="target">The target renderable to update.</param>
/// <returns>A <see cref="LiveDisplay"/> instance.</returns>
public static LiveDisplay Live(this IAnsiConsole console, IRenderable target)
{
if (console is null)
{
throw new ArgumentNullException(nameof(console));
}
if (target is null)
{
throw new ArgumentNullException(nameof(target));
}
return new LiveDisplay(console, target);
}
}
}

View File

@ -0,0 +1,65 @@
using System;
namespace Spectre.Console
{
/// <summary>
/// Contains extension methods for <see cref="LiveDisplay"/>.
/// </summary>
public static class LiveDisplayExtensions
{
/// <summary>
/// Sets whether or not auto clear is enabled.
/// If enabled, the live display will be cleared when done.
/// </summary>
/// <param name="live">The <see cref="LiveDisplay"/> instance.</param>
/// <param name="enabled">Whether or not auto clear is enabled.</param>
/// <returns>The same instance so that multiple calls can be chained.</returns>
public static LiveDisplay AutoClear(this LiveDisplay live, bool enabled)
{
if (live is null)
{
throw new ArgumentNullException(nameof(live));
}
live.AutoClear = enabled;
return live;
}
/// <summary>
/// Sets the vertical overflow strategy.
/// </summary>
/// <param name="live">The <see cref="LiveDisplay"/> instance.</param>
/// <param name="overflow">The overflow strategy to use.</param>
/// <returns>The same instance so that multiple calls can be chained.</returns>
public static LiveDisplay Overflow(this LiveDisplay live, VerticalOverflow overflow)
{
if (live is null)
{
throw new ArgumentNullException(nameof(live));
}
live.Overflow = overflow;
return live;
}
/// <summary>
/// Sets the vertical overflow cropping strategy.
/// </summary>
/// <param name="live">The <see cref="LiveDisplay"/> instance.</param>
/// <param name="cropping">The overflow cropping strategy to use.</param>
/// <returns>The same instance so that multiple calls can be chained.</returns>
public static LiveDisplay Cropping(this LiveDisplay live, VerticalOverflowCropping cropping)
{
if (live is null)
{
throw new ArgumentNullException(nameof(live));
}
live.Cropping = cropping;
return live;
}
}
}

View File

@ -8,11 +8,55 @@ namespace Spectre.Console
/// </summary>
public static class TableColumnExtensions
{
/// <summary>
/// Sets the table column header.
/// </summary>
/// <param name="column">The table column.</param>
/// <param name="header">The table column header markup text.</param>
/// <returns>The same instance so that multiple calls can be chained.</returns>
public static TableColumn Header(this TableColumn column, string header)
{
if (column is null)
{
throw new ArgumentNullException(nameof(column));
}
if (header is null)
{
throw new ArgumentNullException(nameof(header));
}
column.Header = new Markup(header);
return column;
}
/// <summary>
/// Sets the table column header.
/// </summary>
/// <param name="column">The table column.</param>
/// <param name="header">The table column header.</param>
/// <returns>The same instance so that multiple calls can be chained.</returns>
public static TableColumn Header(this TableColumn column, IRenderable header)
{
if (column is null)
{
throw new ArgumentNullException(nameof(column));
}
if (header is null)
{
throw new ArgumentNullException(nameof(header));
}
column.Footer = header;
return column;
}
/// <summary>
/// Sets the table column footer.
/// </summary>
/// <param name="column">The table column.</param>
/// <param name="footer">The table column markup text.</param>
/// <param name="footer">The table column footer markup text.</param>
/// <returns>The same instance so that multiple calls can be chained.</returns>
public static TableColumn Footer(this TableColumn column, string footer)
{

View File

@ -1,4 +1,6 @@
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using static Spectre.Console.AnsiSequences;
namespace Spectre.Console.Rendering
@ -6,12 +8,33 @@ namespace Spectre.Console.Rendering
internal sealed class LiveRenderable : Renderable
{
private readonly object _lock = new object();
private readonly IAnsiConsole _console;
private IRenderable? _renderable;
private SegmentShape? _shape;
public bool HasRenderable => _renderable != null;
public IRenderable? Target => _renderable;
public bool DidOverflow { get; private set; }
public void SetRenderable(IRenderable renderable)
[MemberNotNullWhen(true, nameof(Target))]
public bool HasRenderable => _renderable != null;
public VerticalOverflow Overflow { get; set; }
public VerticalOverflowCropping OverflowCropping { get; set; }
public LiveRenderable(IAnsiConsole console)
{
_console = console ?? throw new ArgumentNullException(nameof(console));
Overflow = VerticalOverflow.Ellipsis;
OverflowCropping = VerticalOverflowCropping.Top;
}
public LiveRenderable(IAnsiConsole console, IRenderable renderable)
: this(console)
{
_renderable = renderable ?? throw new ArgumentNullException(nameof(renderable));
}
public void SetRenderable(IRenderable? renderable)
{
lock (_lock)
{
@ -51,12 +74,61 @@ namespace Spectre.Console.Rendering
{
lock (_lock)
{
DidOverflow = false;
if (_renderable != null)
{
var segments = _renderable.Render(context, maxWidth);
var lines = Segment.SplitLines(segments);
var shape = SegmentShape.Calculate(context, lines);
if (shape.Height > _console.Profile.Height)
{
if (Overflow == VerticalOverflow.Crop)
{
if (OverflowCropping == VerticalOverflowCropping.Bottom)
{
// Remove bottom lines
var index = Math.Min(_console.Profile.Height, lines.Count);
var count = lines.Count - index;
lines.RemoveRange(index, count);
}
else
{
// Remove top lines
var start = lines.Count - _console.Profile.Height;
lines.RemoveRange(0, start);
}
shape = SegmentShape.Calculate(context, lines);
}
else if (Overflow == VerticalOverflow.Ellipsis)
{
var ellipsisText = _console.Profile.Capabilities.Unicode ? "…" : "...";
var ellipsis = new SegmentLine(((IRenderable)new Markup($"[yellow]{ellipsisText}[/]")).Render(context, maxWidth));
if (OverflowCropping == VerticalOverflowCropping.Bottom)
{
// Remove bottom lines
var index = Math.Min(_console.Profile.Height - 1, lines.Count);
var count = lines.Count - index;
lines.RemoveRange(index, count);
lines.Add(ellipsis);
}
else
{
// Remove top lines
var start = lines.Count - _console.Profile.Height;
lines.RemoveRange(0, start + 1);
lines.Insert(0, ellipsis);
}
shape = SegmentShape.Calculate(context, lines);
}
DidOverflow = true;
}
_shape = _shape == null ? shape : _shape.Value.Inflate(shape);
_shape.Value.Apply(context, ref lines);

View File

@ -13,6 +13,22 @@ namespace Spectre.Console.Rendering
/// </summary>
public int Length => this.Sum(line => line.Text.Length);
/// <summary>
/// Initializes a new instance of the <see cref="SegmentLine"/> class.
/// </summary>
public SegmentLine()
{
}
/// <summary>
/// Initializes a new instance of the <see cref="SegmentLine"/> class.
/// </summary>
/// <param name="segments">The segments.</param>
public SegmentLine(IEnumerable<Segment> segments)
: base(segments)
{
}
/// <summary>
/// Gets the number of cells the segment line occupies.
/// </summary>

View File

@ -0,0 +1,23 @@
namespace Spectre.Console
{
/// <summary>
/// Represents vertical overflow.
/// </summary>
public enum VerticalOverflow
{
/// <summary>
/// Crop overflow.
/// </summary>
Crop = 0,
/// <summary>
/// Add an ellipsis at the end.
/// </summary>
Ellipsis = 1,
/// <summary>
/// Do not do anything about overflow.
/// </summary>
Visible = 2,
}
}

View File

@ -0,0 +1,18 @@
namespace Spectre.Console
{
/// <summary>
/// Represent vertical overflow cropping.
/// </summary>
public enum VerticalOverflowCropping
{
/// <summary>
/// Crops the top.
/// </summary>
Top = 0,
/// <summary>
/// Crops the bottom.
/// </summary>
Bottom = 1,
}
}

View File

@ -0,0 +1,126 @@
using System;
using System.Threading.Tasks;
using Spectre.Console.Rendering;
namespace Spectre.Console
{
/// <summary>
/// Represents a live display.
/// </summary>
public sealed class LiveDisplay
{
private readonly IAnsiConsole _console;
private readonly IRenderable _target;
/// <summary>
/// Gets or sets a value indicating whether or not the live display should
/// be cleared when it's done.
/// Defaults to <c>false</c>.
/// </summary>
public bool AutoClear { get; set; }
/// <summary>
/// Gets or sets the vertical overflow strategy.
/// </summary>
public VerticalOverflow Overflow { get; set; } = VerticalOverflow.Ellipsis;
/// <summary>
/// Gets or sets the vertical overflow cropping strategy.
/// </summary>
public VerticalOverflowCropping Cropping { get; set; } = VerticalOverflowCropping.Top;
/// <summary>
/// Initializes a new instance of the <see cref="LiveDisplay"/> class.
/// </summary>
/// <param name="console">The console.</param>
/// <param name="target">The target renderable to update.</param>
public LiveDisplay(IAnsiConsole console, IRenderable target)
{
_console = console ?? throw new ArgumentNullException(nameof(console));
_target = target ?? throw new ArgumentNullException(nameof(target));
}
/// <summary>
/// Starts the live display.
/// </summary>
/// <param name="action">The action to execute.</param>
public void Start(Action<LiveDisplayContext> action)
{
var task = StartAsync(ctx =>
{
action(ctx);
return Task.CompletedTask;
});
task.GetAwaiter().GetResult();
}
/// <summary>
/// Starts the live display.
/// </summary>
/// <typeparam name="T">The result type.</typeparam>
/// <param name="func">The action to execute.</param>
/// <returns>The result.</returns>
public T Start<T>(Func<LiveDisplayContext, T> func)
{
var task = StartAsync(ctx => Task.FromResult(func(ctx)));
return task.GetAwaiter().GetResult();
}
/// <summary>
/// Starts the live display.
/// </summary>
/// <param name="func">The action to execute.</param>
/// <returns>The result.</returns>
public async Task StartAsync(Func<LiveDisplayContext, Task> func)
{
if (func is null)
{
throw new ArgumentNullException(nameof(func));
}
_ = await StartAsync<object?>(async ctx =>
{
await func(ctx).ConfigureAwait(false);
return default;
}).ConfigureAwait(false);
}
/// <summary>
/// Starts the live display.
/// </summary>
/// <typeparam name="T">The result type.</typeparam>
/// <param name="func">The action to execute.</param>
/// <returns>The result.</returns>
public async Task<T> StartAsync<T>(Func<LiveDisplayContext, Task<T>> func)
{
if (func is null)
{
throw new ArgumentNullException(nameof(func));
}
return await _console.RunExclusive(async () =>
{
var context = new LiveDisplayContext(_console, _target);
context.SetOverflow(Overflow, Cropping);
var renderer = new LiveDisplayRenderer(_console, context);
renderer.Started();
try
{
using (new RenderHookScope(_console, renderer))
{
var result = await func(context).ConfigureAwait(false);
context.Refresh();
return result;
}
}
finally
{
renderer.Completed(AutoClear);
}
}).ConfigureAwait(false);
}
}
}

View File

@ -0,0 +1,54 @@
using System;
using Spectre.Console.Rendering;
namespace Spectre.Console
{
/// <summary>
/// Represents a context that can be used to interact with a <see cref="LiveDisplay"/>.
/// </summary>
public sealed class LiveDisplayContext
{
private readonly IAnsiConsole _console;
internal object Lock { get; }
internal LiveRenderable Live { get; }
internal LiveDisplayContext(IAnsiConsole console, IRenderable target)
{
_console = console ?? throw new ArgumentNullException(nameof(console));
Live = new LiveRenderable(_console, target);
Lock = new object();
}
/// <summary>
/// Updates the live display target.
/// </summary>
/// <param name="target">The new live display target.</param>
public void UpdateTarget(IRenderable? target)
{
lock (Lock)
{
Live.SetRenderable(target);
Refresh();
}
}
/// <summary>
/// Refreshes the live display.
/// </summary>
public void Refresh()
{
lock (Lock)
{
_console.Write(new ControlCode(string.Empty));
}
}
internal void SetOverflow(VerticalOverflow overflow, VerticalOverflowCropping cropping)
{
Live.Overflow = overflow;
Live.OverflowCropping = cropping;
}
}
}

View File

@ -0,0 +1,62 @@
using System.Collections.Generic;
using Spectre.Console.Rendering;
namespace Spectre.Console
{
internal sealed class LiveDisplayRenderer : IRenderHook
{
private readonly IAnsiConsole _console;
private readonly LiveDisplayContext _context;
public LiveDisplayRenderer(IAnsiConsole console, LiveDisplayContext context)
{
_console = console;
_context = context;
}
public void Started()
{
_console.Cursor.Hide();
}
public void Completed(bool autoclear)
{
lock (_context.Lock)
{
if (autoclear)
{
_console.Write(_context.Live.RestoreCursor());
}
else
{
if (_context.Live.HasRenderable && _context.Live.DidOverflow)
{
// Redraw the whole live renderable
_console.Write(_context.Live.RestoreCursor());
_context.Live.Overflow = VerticalOverflow.Visible;
_console.Write(_context.Live.Target);
}
_console.WriteLine();
}
_console.Cursor.Show();
}
}
public IEnumerable<IRenderable> Process(RenderContext context, IEnumerable<IRenderable> renderables)
{
lock (_context.Lock)
{
yield return _context.Live.PositionCursor();
foreach (var renderable in renderables)
{
yield return renderable;
}
yield return _context.Live;
}
}
}
}

View File

@ -13,8 +13,8 @@ namespace Spectre.Console
private readonly LiveRenderable _live;
private readonly object _lock;
private readonly Stopwatch _stopwatch;
private readonly bool _hideCompleted;
private TimeSpan _lastUpdate;
private bool _hideCompleted;
public override TimeSpan RefreshRate { get; }
@ -22,7 +22,7 @@ namespace Spectre.Console
{
_console = console ?? throw new ArgumentNullException(nameof(console));
_columns = columns ?? throw new ArgumentNullException(nameof(columns));
_live = new LiveRenderable();
_live = new LiveRenderable(console);
_lock = new object();
_stopwatch = new Stopwatch();
_lastUpdate = TimeSpan.Zero;
@ -46,6 +46,14 @@ namespace Spectre.Console
}
else
{
if (_live.HasRenderable && _live.DidOverflow)
{
// Redraw the whole live renderable
_console.Write(_live.RestoreCursor());
_live.Overflow = VerticalOverflow.Visible;
_console.Write(_live.Target);
}
_console.WriteLine();
}

View File

@ -7,20 +7,21 @@ namespace Spectre.Console
internal sealed class ListPromptRenderHook<T> : IRenderHook
where T : notnull
{
private readonly LiveRenderable _live;
private readonly object _lock;
private readonly IAnsiConsole _console;
private readonly Func<IRenderable> _builder;
private readonly LiveRenderable _live;
private readonly object _lock;
private bool _dirty;
public ListPromptRenderHook(
IAnsiConsole console,
Func<IRenderable> builder)
{
_live = new LiveRenderable();
_console = console ?? throw new ArgumentNullException(nameof(console));
_builder = builder ?? throw new ArgumentNullException(nameof(builder));
_live = new LiveRenderable(console);
_lock = new object();
_console = console;
_builder = builder;
_dirty = true;
}

View File

@ -9,9 +9,9 @@ namespace Spectre.Console
public sealed class TableColumn : IColumn
{
/// <summary>
/// Gets the column header.
/// Gets or sets the column header.
/// </summary>
public IRenderable Header { get; }
public IRenderable Header { get; set; }
/// <summary>
/// Gets or sets the column footer.