Compare commits

...

8 Commits

Author SHA1 Message Date
Patrik Svensson
e280b82679 Fix dividing with infinity bug
When calculating the remaining time for a progress task,
we divide the value delta with the current speed.
If the speed is zero, then the resulting double will
be 'infinity' which will lead to TimeSpan.FromSeconds
throwing.

This commit fixes that bug by setting the speed to 1
if it's 0 when calculating the remaining time.

Closes #169
2020-12-17 00:43:47 +01:00
Patrik Svensson
6932c95731 Fix bug when splitting text containing CJK chars
In Segment.Split, we didn't take cell width into account
when calculating the offset, which resulted in some "fun" bugs.
I've added a new overload for Segment.Split and obsoleted the old one.

Closes #150
2020-12-17 00:02:00 +01:00
Patrik Svensson
ee305702e8 Update issue templates 2020-12-14 10:36:05 +01:00
Patrik Svensson
63abcc92ba Set max value for progress task properly
Also clamp the task value if it's greater than the max value.

Closes #163
2020-12-12 17:29:07 +01:00
Patrik Svensson
acf01e056f Clean up status related code a bit 2020-12-09 08:37:32 +01:00
Patrik Svensson
501db5d287 Add status support 2020-12-09 00:07:02 +01:00
Patrik Svensson
cbed41e637 Add support for different spinners 2020-12-06 15:41:45 +01:00
Patrik Svensson
3c504155bc Fix progress rendering bug 2020-12-04 10:19:09 +01:00
63 changed files with 4729 additions and 122 deletions

28
.github/ISSUE_TEMPLATE/bug_report.md vendored Normal file
View File

@@ -0,0 +1,28 @@
---
name: Bug report
about: Create a report to help us improve
title: ''
labels: bug
assignees: ''
---
**Information**
- OS: [eg Windows/Linux/MacOS]
- Version: [e.g. 0.33.0]
- Terminal: [e.g Windows Terminal]
**Describe the bug**
A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior.
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots**
If applicable, add screenshots to help explain your problem.
**Additional context**
Add any other context about the problem here.

View File

@@ -0,0 +1,20 @@
---
name: Feature request
about: Suggest an idea for this project
title: ''
labels: enhancement
assignees: ''
---
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.
**Additional context**
Add any other context or screenshots about the feature request here.

View File

@@ -0,0 +1,43 @@
Title: Spinners
Order: 4
---
For all available spinners, see https://jsfiddle.net/sindresorhus/2eLtsbey/embedded/result/
# Usage
Spinners can be used with [Progress](xref:progress) and [Status](xref:status).
```csharp
AnsiConsole.Status()
.Spinner(Spinner.Known.Star)
.Start("Thinking...", ctx => {
// Omitted
});
```
# Implementing a spinner
To implement your own spinner, all you have to do is
inherit from the `Spinner` base class.
In the example below, the spinner will alterate between
the characters `A`, `B` and `C` every 100 ms.
```csharp
public sealed class MySpinner : Spinner
{
// The interval for each frame
public override TimeSpan Interval => TimeSpan.FromMilliseconds(100);
// Whether or not the spinner contains unicode characters
public override bool IsUnicode => false;
// The individual frames of the spinner
public override IReadOnlyList<string> Frames =>
new List<string>
{
"A", "B", "C",
};
}
```

Binary file not shown.

After

Width:  |  Height:  |  Size: 257 KiB

View File

@@ -31,5 +31,4 @@ $(document).ready(function () {
}; // keyup
})
}); // ready

60
docs/input/status.md Normal file
View File

@@ -0,0 +1,60 @@
Title: Status
Order: 6
---
Spectre.Console can display information about long running tasks in the console.
<img src="assets/images/status.gif" style="max-width: 100%;margin-bottom:20px;">
If the current terminal isn't considered "interactive", such as when running
in a continuous integration system, or the terminal can't display
ANSI control sequence, any progress will be displayed in a simpler way.
# Usage
```csharp
// Synchronous
AnsiConsole.Status()
.Start("Thinking...", ctx =>
{
// Simulate some work
AnsiConsole.MarkupLine("Doing some work...");
Thread.Sleep(1000);
// Update the status and spinner
ctx.Status("Thinking some more");
ctx.Spinner(Spinner.Known.Star);
ctx.SpinnerStyle(Style.Parse("green"));
// Simulate some work
AnsiConsole.MarkupLine("Doing some more work...");
Thread.Sleep(2000);
});
```
## Asynchronous progress
If you prefer to use async/await, you can use `StartAsync` instead of `Start`.
```csharp
// Asynchronous
await AnsiConsole.Status()
.StartAsync("Thinking...", async ctx =>
{
// Omitted
});
```
# Configure
```csharp
AnsiConsole.Status()
.AutoRefresh(false)
.Spinner(Spinner.Known.Star)
.SpinnerStyle(Style.Parse("green bold"))
.Start("Thinking...", ctx =>
{
// Omitted
ctx.Refresh();
});
```

View File

@@ -1,39 +1,31 @@
using System.Collections.Generic;
using System.Net.Http;
using System.Threading.Tasks;
using Newtonsoft.Json.Linq;
using Spectre.Console;
namespace ColumnsExample
{
public static class Program
{
public static async Task Main()
public static void Main()
{
// Download some random users
using var client = new HttpClient();
dynamic users = JObject.Parse(
await client.GetStringAsync("https://randomuser.me/api/?results=15"));
// Create a card for each user
var cards = new List<Panel>();
foreach(var user in users.results)
foreach(var user in User.LoadUsers())
{
cards.Add(new Panel(GetCardContent(user))
.Header($"{user.location.country}")
.RoundedBorder().Expand());
cards.Add(
new Panel(GetCardContent(user))
.Header($"{user.Country}")
.RoundedBorder().Expand());
}
// Render all cards in columns
AnsiConsole.Render(new Columns(cards));
}
private static string GetCardContent(dynamic user)
private static string GetCardContent(User user)
{
var name = $"{user.name.first} {user.name.last}";
var country = $"{user.location.city}";
var name = $"{user.FirstName} {user.LastName}";
var city = $"{user.City}";
return $"[b]{name}[/]\n[yellow]{country}[/]";
return $"[b]{name}[/]\n[yellow]{city}[/]";
}
}
}

89
examples/Columns/User.cs Normal file
View File

@@ -0,0 +1,89 @@
using System.Collections.Generic;
namespace ColumnsExample
{
public sealed class User
{
public string FirstName { get; set; }
public string LastName { get; set; }
public string City { get; set; }
public string Country { get; set; }
public static List<User> LoadUsers()
{
return new List<User>
{
new User
{
FirstName = "Andrea",
LastName = "Johansen",
City = "Hornbæk",
Country = "Denmark",
},
new User
{
FirstName = "Brandon",
LastName = "Cole",
City = "Washington",
Country = "United States",
},
new User
{
FirstName = "Patrik",
LastName = "Svensson",
City = "Stockholm",
Country = "Sweden",
},
new User
{
FirstName = "Freya",
LastName = "Thompson",
City = "Rotorua",
Country = "New Zealand",
},
new User
{
FirstName = "طاها",
LastName = "رضایی",
City = "اهواز",
Country = "Iran",
},
new User
{
FirstName = "Yara",
LastName = "Simon",
City = "Develier",
Country = "Switzerland",
},
new User
{
FirstName = "Giray",
LastName = "Erbay",
City = "Karabük",
Country = "Turkey",
},
new User
{
FirstName = "Miodrag",
LastName = "Schaffer",
City = "Möckern",
Country = "Germany",
},
new User
{
FirstName = "Carmela",
LastName = "Lo Castro",
City = "Firenze",
Country = "Italy",
},
new User
{
FirstName = "Roberto",
LastName = "Sims",
City = "Mallow",
Country = "Ireland",
},
};
}
}
}

View File

@@ -0,0 +1,70 @@
using System.Threading;
using Spectre.Console;
namespace ProgressExample
{
public static class Program
{
public static void Main()
{
AnsiConsole.Status()
.AutoRefresh(true)
.Spinner(Spinner.Known.Default)
.Start("[yellow]Initializing warp drive[/]", ctx =>
{
// Initialize
Thread.Sleep(3000);
WriteLogMessage("Starting gravimetric field displacement manifold");
Thread.Sleep(1000);
WriteLogMessage("Warming up deuterium chamber");
Thread.Sleep(2000);
WriteLogMessage("Generating antideuterium");
// Warp nacelles
Thread.Sleep(3000);
ctx.Spinner(Spinner.Known.BouncingBar);
ctx.Status("[bold blue]Unfolding warp nacelles[/]");
WriteLogMessage("Unfolding left warp nacelle");
Thread.Sleep(2000);
WriteLogMessage("Left warp nacelle [green]online[/]");
WriteLogMessage("Unfolding right warp nacelle");
Thread.Sleep(1000);
WriteLogMessage("Right warp nacelle [green]online[/]");
// Warp bubble
Thread.Sleep(3000);
ctx.Spinner(Spinner.Known.Star2);
ctx.Status("[bold blue]Generating warp bubble[/]");
Thread.Sleep(3000);
ctx.Spinner(Spinner.Known.Star);
ctx.Status("[bold blue]Stabilizing warp bubble[/]");
// Safety
ctx.Spinner(Spinner.Known.Monkey);
ctx.Status("[bold blue]Performing safety checks[/]");
WriteLogMessage("Enabling interior dampening");
Thread.Sleep(2000);
WriteLogMessage("Interior dampening [green]enabled[/]");
// Warp!
Thread.Sleep(3000);
ctx.Spinner(Spinner.Known.Moon);
WriteLogMessage("Preparing for warp");
Thread.Sleep(1000);
for (var warp = 1; warp < 10; warp++)
{
ctx.Status($"[bold blue]Warp {warp}[/]");
Thread.Sleep(500);
}
});
// Done
AnsiConsole.MarkupLine("[bold green]Crusing at Warp 9.8[/]");
}
private static void WriteLogMessage(string message)
{
AnsiConsole.MarkupLine($"[grey]LOG:[/] {message}[grey]...[/]");
}
}
}

View File

@@ -0,0 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net5.0</TargetFramework>
<IsPackable>false</IsPackable>
<Title>Status</Title>
<Description>Demonstrates how to show status updates.</Description>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Newtonsoft.Json" Version="12.0.3" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Spectre.Console\Spectre.Console.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,22 @@
##########################################################
# Script that generates progress spinners.
##########################################################
$Output = Join-Path $PSScriptRoot "Temp"
$Source = Join-Path $PSScriptRoot "/../../src/Spectre.Console"
if(!(Test-Path $Output -PathType Container)) {
New-Item -ItemType Directory -Path $Output | Out-Null
}
# Generate the files
Push-Location Generator
&dotnet run -- spinners "$Output" --input $Output
if(!$?) {
Pop-Location
Throw "An error occured when generating code."
}
Pop-Location
# Copy the files to the correct location
Copy-Item (Join-Path "$Output" "Spinner.Generated.cs") -Destination "$Source/Progress/Spinner.Generated.cs"

View File

@@ -7,7 +7,7 @@ using Spectre.IO;
namespace Generator.Commands
{
public sealed class ColorGeneratorCommand : Command<GeneratorCommandSettings>
public sealed class ColorGeneratorCommand : Command<ColorGeneratorCommand.Settings>
{
private readonly IFileSystem _fileSystem;
@@ -16,7 +16,13 @@ namespace Generator.Commands
_fileSystem = new FileSystem();
}
public override int Execute(CommandContext context, GeneratorCommandSettings settings)
public sealed class Settings : GeneratorSettings
{
[CommandOption("-i|--input <PATH>")]
public string Input { get; set; }
}
public override int Execute(CommandContext context, Settings settings)
{
var templates = new FilePath[]
{
@@ -50,13 +56,4 @@ namespace Generator.Commands
return 0;
}
}
public sealed class GeneratorCommandSettings : CommandSettings
{
[CommandArgument(0, "<OUTPUT>")]
public string Output { get; set; }
[CommandOption("-i|--input <PATH>")]
public string Input { get; set; }
}
}

View File

@@ -15,7 +15,7 @@ using SpectreEnvironment = Spectre.IO.Environment;
namespace Generator.Commands
{
public sealed class EmojiGeneratorCommand : AsyncCommand<GeneratorCommandSettings>
public sealed class EmojiGeneratorCommand : AsyncCommand<EmojiGeneratorCommand.Settings>
{
private readonly IFileSystem _fileSystem;
private readonly IEnvironment _environment;
@@ -24,9 +24,15 @@ namespace Generator.Commands
private readonly Dictionary<string, string> _templates = new Dictionary<string, string>
{
{ "Templates/Emoji.Generated.template", "Emoji.Generated.cs" },
{ "Templates/Emoji.Json.template", "emojis.json" },
{ "Templates/Emoji.Json.template", "emojis.json" }, // For documentation
};
public sealed class Settings : GeneratorSettings
{
[CommandOption("-i|--input <PATH>")]
public string Input { get; set; }
}
public EmojiGeneratorCommand()
{
_fileSystem = new FileSystem();
@@ -34,7 +40,7 @@ namespace Generator.Commands
_parser = new HtmlParser();
}
public override async Task<int> ExecuteAsync(CommandContext context, GeneratorCommandSettings settings)
public override async Task<int> ExecuteAsync(CommandContext context, Settings settings)
{
var output = new DirectoryPath(settings.Output);
if (!_fileSystem.Directory.Exists(settings.Output))
@@ -60,7 +66,7 @@ namespace Generator.Commands
return 0;
}
private async Task<Stream> FetchEmojis(GeneratorCommandSettings settings)
private async Task<Stream> FetchEmojis(Settings settings)
{
var input = string.IsNullOrEmpty(settings.Input)
? _environment.WorkingDirectory

View File

@@ -0,0 +1,10 @@
using Spectre.Cli;
namespace Generator.Commands
{
public class GeneratorSettings : CommandSettings
{
[CommandArgument(0, "<OUTPUT>")]
public string Output { get; set; }
}
}

View File

@@ -0,0 +1,47 @@
using System;
using System.Collections.Generic;
using System.IO;
using Generator.Models;
using Scriban;
using Spectre.Cli;
using Spectre.IO;
namespace Generator.Commands
{
public sealed class SpinnerGeneratorCommand : Command<GeneratorSettings>
{
private readonly IFileSystem _fileSystem;
public SpinnerGeneratorCommand()
{
_fileSystem = new FileSystem();
}
public override int Execute(CommandContext context, GeneratorSettings settings)
{
// Read the spinner model.
var spinners = new List<Spinner>();
spinners.AddRange(Spinner.Parse(File.ReadAllText("Data/spinners_default.json")));
spinners.AddRange(Spinner.Parse(File.ReadAllText("Data/spinners_sindresorhus.json")));
var output = new DirectoryPath(settings.Output);
if (!_fileSystem.Directory.Exists(settings.Output))
{
_fileSystem.Directory.Create(settings.Output);
}
// Parse the Scriban template.
var templatePath = new FilePath("Templates/Spinner.Generated.template");
var template = Template.Parse(File.ReadAllText(templatePath.FullPath));
// Render the template with the model.
var result = template.Render(new { Spinners = spinners });
// Write output to file
var file = output.CombineWithFilePath(templatePath.GetFilename().ChangeExtension(".cs"));
File.WriteAllText(file.FullPath, result);
return 0;
}
}
}

View File

@@ -0,0 +1,30 @@
{
"Default": {
"interval": 100,
"unicode": true,
"frames": [
"⣷",
"⣯",
"⣟",
"⡿",
"⢿",
"⣻",
"⣽",
"⣾"
]
},
"Ascii": {
"interval": 100,
"unicode": true,
"frames": [
"-",
"\\",
"|",
"/",
"-",
"\\",
"|",
"/"
]
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -15,6 +15,12 @@
<None Update="Data\colors.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<None Update="Data\spinners_default.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<None Update="Data\spinners_sindresorhus.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<None Update="Templates\ColorTable.Generated.template">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
@@ -24,6 +30,9 @@
<None Update="Templates\ColorPalette.Generated.template">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<None Update="Templates\Spinner.Generated.template">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<None Update="Templates\Emoji.Json.template">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>

View File

@@ -0,0 +1,31 @@
using System.Collections.Generic;
using System.Linq;
using Humanizer;
using Newtonsoft.Json;
namespace Generator.Models
{
public sealed class Spinner
{
public string Name { get; set; }
public string NormalizedName { get; set; }
public int Interval { get; set; }
public bool Unicode { get; set; }
public List<string> Frames { get; set; }
public static IEnumerable<Spinner> Parse(string json)
{
var data = JsonConvert.DeserializeObject<Dictionary<string, Spinner>>(json);
foreach (var item in data)
{
item.Value.Name = item.Key;
item.Value.NormalizedName = item.Value.Name.Pascalize();
var frames = item.Value.Frames;
item.Value.Frames = frames.Select(f => f.Replace("\\", "\\\\")).ToList();
}
return data.Values;
}
}
}

View File

@@ -12,6 +12,7 @@ namespace Generator
{
config.AddCommand<ColorGeneratorCommand>("colors");
config.AddCommand<EmojiGeneratorCommand>("emoji");
config.AddCommand<SpinnerGeneratorCommand>("spinners");
});
return app.Run(args);

View File

@@ -0,0 +1,48 @@
//------------------------------------------------------------------------------
// <auto-generated>
// This code was generated by a tool.
// Generated {{ date.now | date.to_string `%F %R` }}
//
// Partly generated from
// https://github.com/sindresorhus/cli-spinners/blob/master/spinners.json
//
// Changes to this file may cause incorrect behavior and will be lost if
// the code is regenerated.
// </auto-generated>
//------------------------------------------------------------------------------
using System;
using System.Collections.Generic;
namespace Spectre.Console
{
public abstract partial class Spinner
{
{{~ for spinner in spinners ~}}
private sealed class {{ spinner.normalized_name }}Spinner : Spinner
{
public override TimeSpan Interval => TimeSpan.FromMilliseconds({{ spinner.interval }});
public override bool IsUnicode => {{ spinner.unicode }};
public override IReadOnlyList<string> Frames => new List<string>
{
{{~ for frame in spinner.frames ~}}
"{{ frame }}",
{{~ end ~}}
};
}
{{~ end ~}}
/// <summary>
/// Contains all predefined spinners.
/// </summary>
public static class Known
{
{{~ for spinner in spinners ~}}
/// <summary>
/// Gets the "{{ spinner.name }}" spinner.
/// </summary>
public static Spinner {{ spinner.normalized_name }} { get; } = new {{ spinner.normalized_name }}Spinner();
{{~ end ~}}
}
}
}

View File

@@ -0,0 +1,7 @@
┌──────────┐
│ ┌──────┐ │
│ │ 测试 │ │
│ ├──────┤ │
│ │ 测试 │ │
│ └──────┘ │
└──────────┘

View File

@@ -0,0 +1,5 @@
foo ━━━ 0% -:--:-- ⣷
bar ━━━ 0% -:--:-- ⣷
baz ━━━ 0% -:--:-- ⣷

View File

@@ -0,0 +1,17 @@
namespace Spectre.Console.Tests
{
public sealed class DummyCursor : IAnsiConsoleCursor
{
public void Move(CursorDirection direction, int steps)
{
}
public void SetPosition(int column, int line)
{
}
public void Show(bool show)
{
}
}
}

View File

@@ -0,0 +1,25 @@
using System;
using System.Collections.Generic;
namespace Spectre.Console.Tests
{
public sealed class DummySpinner1 : Spinner
{
public override TimeSpan Interval => TimeSpan.FromMilliseconds(100);
public override bool IsUnicode => true;
public override IReadOnlyList<string> Frames => new List<string>
{
"*",
};
}
public sealed class DummySpinner2 : Spinner
{
public override TimeSpan Interval => TimeSpan.FromMilliseconds(100);
public override bool IsUnicode => true;
public override IReadOnlyList<string> Frames => new List<string>
{
"-",
};
}
}

View File

@@ -11,7 +11,7 @@ namespace Spectre.Console.Tests
{
public Capabilities Capabilities { get; }
public Encoding Encoding { get; }
public IAnsiConsoleCursor Cursor => throw new NotSupportedException();
public IAnsiConsoleCursor Cursor => new DummyCursor();
public TestableConsoleInput Input { get; }
public int Width { get; }

View File

@@ -1,4 +1,4 @@
namespace Spectre.Console.Tests.Tools
namespace Spectre.Console.Tests
{
public sealed class TestLinkIdentityGenerator : ILinkIdentityGenerator
{

View File

@@ -3,7 +3,6 @@ using System.Collections.Generic;
using System.IO;
using System.Text;
using Spectre.Console.Rendering;
using Spectre.Console.Tests.Tools;
namespace Spectre.Console.Tests
{

View File

@@ -1,5 +1,7 @@
using System.Collections.Generic;
using System.Text;
using System.Threading.Tasks;
using Shouldly;
using Spectre.Console.Rendering;
using VerifyXunit;
using Xunit;
@@ -267,5 +269,23 @@ namespace Spectre.Console.Tests.Unit
// Then
return Verifier.Verify(console.Output);
}
[Fact]
public Task Should_Wrap_Table_With_CJK_Tables_In_Panel_Correctly()
{
// Given
var console = new PlainConsole(width: 80);
var table = new Table();
table.AddColumn("测试");
table.AddRow("测试");
var panel = new Panel(table);
// When
console.Render(panel);
// Then
return Verifier.Verify(console.Output);
}
}
}

View File

@@ -1,8 +1,11 @@
using System.Threading.Tasks;
using Shouldly;
using VerifyXunit;
using Xunit;
namespace Spectre.Console.Tests.Unit
{
[UsesVerify]
public sealed class ProgressTests
{
[Fact]
@@ -54,5 +57,59 @@ namespace Spectre.Console.Tests.Unit
" \n" + // Bottom padding
"[?25h"); // show cursor
}
[Fact]
public Task Should_Reduce_Width_If_Needed()
{
// Given
var console = new PlainConsole(width: 20);
var progress = new Progress(console)
.Columns(new ProgressColumn[]
{
new TaskDescriptionColumn(),
new ProgressBarColumn(),
new PercentageColumn(),
new RemainingTimeColumn(),
new SpinnerColumn(),
})
.AutoRefresh(false)
.AutoClear(false);
// When
progress.Start(ctx =>
{
ctx.AddTask("foo");
ctx.AddTask("bar");
ctx.AddTask("baz");
});
// Then
return Verifier.Verify(console.Output);
}
[Fact]
public void Setting_Max_Value_Should_Set_The_MaxValue_And_Cap_Value()
{
// Given
var task = default(ProgressTask);
var console = new PlainConsole();
var progress = new Progress(console)
.Columns(new[] { new ProgressBarColumn() })
.AutoRefresh(false)
.AutoClear(false);
// When
progress.Start(ctx =>
{
task = ctx.AddTask("foo");
task.Increment(100);
task.MaxValue = 20;
});
// Then
task.MaxValue.ShouldBe(20);
task.Value.ShouldBe(20);
}
}
}

View File

@@ -22,18 +22,43 @@ namespace Spectre.Console.Tests.Unit
[UsesVerify]
public sealed class TheSplitMethod
{
[Fact]
public Task Should_Split_Segment_Correctly()
[Theory]
[InlineData("Foo Bar", 0, "", "Foo Bar")]
[InlineData("Foo Bar", 1, "F", "oo Bar")]
[InlineData("Foo Bar", 2, "Fo", "o Bar")]
[InlineData("Foo Bar", 3, "Foo", " Bar")]
[InlineData("Foo Bar", 4, "Foo ", "Bar")]
[InlineData("Foo Bar", 5, "Foo B", "ar")]
[InlineData("Foo Bar", 6, "Foo Ba", "r")]
[InlineData("Foo Bar", 7, "Foo Bar", null)]
[InlineData("Foo 测试 Bar", 0, "", "Foo 测试 Bar")]
[InlineData("Foo 测试 Bar", 1, "F", "oo 测试 Bar")]
[InlineData("Foo 测试 Bar", 2, "Fo", "o 测试 Bar")]
[InlineData("Foo 测试 Bar", 3, "Foo", " 测试 Bar")]
[InlineData("Foo 测试 Bar", 4, "Foo ", "测试 Bar")]
[InlineData("Foo 测试 Bar", 5, "Foo 测", "试 Bar")]
[InlineData("Foo 测试 Bar", 6, "Foo 测", "试 Bar")]
[InlineData("Foo 测试 Bar", 7, "Foo 测试", " Bar")]
[InlineData("Foo 测试 Bar", 8, "Foo 测试", " Bar")]
[InlineData("Foo 测试 Bar", 9, "Foo 测试 ", "Bar")]
[InlineData("Foo 测试 Bar", 10, "Foo 测试 B", "ar")]
[InlineData("Foo 测试 Bar", 11, "Foo 测试 Ba", "r")]
[InlineData("Foo 测试 Bar", 12, "Foo 测试 Bar", null)]
public void Should_Split_Segment_Correctly(string text, int offset, string expectedFirst, string expectedSecond)
{
// Given
var style = new Style(Color.Red, Color.Green, Decoration.Bold);
var segment = new Segment("Foo Bar", style);
var context = new RenderContext(Encoding.UTF8, false);
var segment = new Segment(text, style);
// When
var result = segment.Split(3);
var (first, second) = segment.Split(context, offset);
// Then
return Verifier.Verify(result);
first.Text.ShouldBe(expectedFirst);
first.Style.ShouldBe(style);
second?.Text?.ShouldBe(expectedSecond);
second?.Style?.ShouldBe(style);
}
}

View File

@@ -0,0 +1,42 @@
using Shouldly;
using Xunit;
namespace Spectre.Console.Tests.Unit
{
public sealed partial class StatusTests
{
[Fact]
public void Should_Render_Status_Correctly()
{
// Given
var console = new TestableAnsiConsole(ColorSystem.TrueColor, width: 10);
var status = new Status(console);
status.AutoRefresh = false;
status.Spinner = new DummySpinner1();
// When
status.Start("foo", ctx =>
{
ctx.Refresh();
ctx.Spinner(new DummySpinner2());
ctx.Status("bar");
ctx.Refresh();
ctx.Spinner(new DummySpinner1());
ctx.Status("baz");
});
// Then
console.Output
.NormalizeLineEndings()
.ShouldBe(
"[?25l \n" +
"* foo\n" +
"  \n" +
"- bar\n" +
"  \n" +
"* baz\n" +
" [?25h");
}
}
}

View File

@@ -60,6 +60,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Spectre.Console.ImageSharp"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Progress", "..\examples\Progress\Progress.csproj", "{2B712A52-40F1-4C1C-833E-7C869ACA91F3}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Status", "..\examples\Status\Status.csproj", "{3716AFDF-0904-4635-8422-86E6B9356840}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -310,6 +312,18 @@ Global
{2B712A52-40F1-4C1C-833E-7C869ACA91F3}.Release|x64.Build.0 = Release|Any CPU
{2B712A52-40F1-4C1C-833E-7C869ACA91F3}.Release|x86.ActiveCfg = Release|Any CPU
{2B712A52-40F1-4C1C-833E-7C869ACA91F3}.Release|x86.Build.0 = Release|Any CPU
{3716AFDF-0904-4635-8422-86E6B9356840}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{3716AFDF-0904-4635-8422-86E6B9356840}.Debug|Any CPU.Build.0 = Debug|Any CPU
{3716AFDF-0904-4635-8422-86E6B9356840}.Debug|x64.ActiveCfg = Debug|Any CPU
{3716AFDF-0904-4635-8422-86E6B9356840}.Debug|x64.Build.0 = Debug|Any CPU
{3716AFDF-0904-4635-8422-86E6B9356840}.Debug|x86.ActiveCfg = Debug|Any CPU
{3716AFDF-0904-4635-8422-86E6B9356840}.Debug|x86.Build.0 = Debug|Any CPU
{3716AFDF-0904-4635-8422-86E6B9356840}.Release|Any CPU.ActiveCfg = Release|Any CPU
{3716AFDF-0904-4635-8422-86E6B9356840}.Release|Any CPU.Build.0 = Release|Any CPU
{3716AFDF-0904-4635-8422-86E6B9356840}.Release|x64.ActiveCfg = Release|Any CPU
{3716AFDF-0904-4635-8422-86E6B9356840}.Release|x64.Build.0 = Release|Any CPU
{3716AFDF-0904-4635-8422-86E6B9356840}.Release|x86.ActiveCfg = Release|Any CPU
{3716AFDF-0904-4635-8422-86E6B9356840}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -333,6 +347,7 @@ Global
{45BF6302-6553-4E52-BF0F-B10D1AA9A6D1} = {F0575243-121F-4DEE-9F6B-246E26DC0844}
{5693761A-754A-40A8-9144-36510D6A4D69} = {F0575243-121F-4DEE-9F6B-246E26DC0844}
{2B712A52-40F1-4C1C-833E-7C869ACA91F3} = {F0575243-121F-4DEE-9F6B-246E26DC0844}
{3716AFDF-0904-4635-8422-86E6B9356840} = {F0575243-121F-4DEE-9F6B-246E26DC0844}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {5729B071-67A0-48FB-8B1B-275E6822086C}

View File

@@ -13,5 +13,14 @@ namespace Spectre.Console
{
return Console.Progress();
}
/// <summary>
/// Creates a new <see cref="Status"/> instance.
/// </summary>
/// <returns>A <see cref="Status"/> instance.</returns>
public static Status Status()
{
return Console.Status();
}
}
}

View File

@@ -21,5 +21,20 @@ namespace Spectre.Console
return new Progress(console);
}
/// <summary>
/// Creates a new <see cref="Status"/> instance for the console.
/// </summary>
/// <param name="console">The console.</param>
/// <returns>A <see cref="Status"/> instance.</returns>
public static Status Status(this IAnsiConsole console)
{
if (console is null)
{
throw new ArgumentNullException(nameof(console));
}
return new Status(console);
}
}
}

View File

@@ -13,18 +13,13 @@ namespace Spectre.Console
/// <param name="column">The column.</param>
/// <param name="style">The style.</param>
/// <returns>The same instance so that multiple calls can be chained.</returns>
public static SpinnerColumn Style(this SpinnerColumn column, Style style)
public static SpinnerColumn Style(this SpinnerColumn column, Style? style)
{
if (column is null)
{
throw new ArgumentNullException(nameof(column));
}
if (style is null)
{
throw new ArgumentNullException(nameof(style));
}
column.Style = style;
return column;
}

View File

@@ -0,0 +1,61 @@
using System;
namespace Spectre.Console
{
/// <summary>
/// Contains extension methods for <see cref="StatusContext"/>.
/// </summary>
public static class StatusContextExtensions
{
/// <summary>
/// Sets the status message.
/// </summary>
/// <param name="context">The status context.</param>
/// <param name="status">The status message.</param>
/// <returns>The same instance so that multiple calls can be chained.</returns>
public static StatusContext Status(this StatusContext context, string status)
{
if (context is null)
{
throw new ArgumentNullException(nameof(context));
}
context.Status = status;
return context;
}
/// <summary>
/// Sets the spinner.
/// </summary>
/// <param name="context">The status context.</param>
/// <param name="spinner">The spinner.</param>
/// <returns>The same instance so that multiple calls can be chained.</returns>
public static StatusContext Spinner(this StatusContext context, Spinner spinner)
{
if (context is null)
{
throw new ArgumentNullException(nameof(context));
}
context.Spinner = spinner;
return context;
}
/// <summary>
/// Sets the spinner style.
/// </summary>
/// <param name="context">The status context.</param>
/// <param name="style">The spinner style.</param>
/// <returns>The same instance so that multiple calls can be chained.</returns>
public static StatusContext SpinnerStyle(this StatusContext context, Style? style)
{
if (context is null)
{
throw new ArgumentNullException(nameof(context));
}
context.SpinnerStyle = style;
return context;
}
}
}

View File

@@ -0,0 +1,62 @@
using System;
namespace Spectre.Console
{
/// <summary>
/// Contains extension methods for <see cref="Status"/>.
/// </summary>
public static class StatusExtensions
{
/// <summary>
/// Sets whether or not auto refresh is enabled.
/// If disabled, you will manually have to refresh the progress.
/// </summary>
/// <param name="status">The <see cref="Status"/> instance.</param>
/// <param name="enabled">Whether or not auto refresh is enabled.</param>
/// <returns>The same instance so that multiple calls can be chained.</returns>
public static Status AutoRefresh(this Status status, bool enabled)
{
if (status is null)
{
throw new ArgumentNullException(nameof(status));
}
status.AutoRefresh = enabled;
return status;
}
/// <summary>
/// Sets the spinner.
/// </summary>
/// <param name="status">The <see cref="Status"/> instance.</param>
/// <param name="spinner">The spinner.</param>
/// <returns>The same instance so that multiple calls can be chained.</returns>
public static Status Spinner(this Status status, Spinner spinner)
{
if (status is null)
{
throw new ArgumentNullException(nameof(status));
}
status.Spinner = spinner;
return status;
}
/// <summary>
/// Sets the spinner style.
/// </summary>
/// <param name="status">The <see cref="Status"/> instance.</param>
/// <param name="style">The spinner style.</param>
/// <returns>The same instance so that multiple calls can be chained.</returns>
public static Status SpinnerStyle(this Status status, Style? style)
{
if (status is null)
{
throw new ArgumentNullException(nameof(status));
}
status.SpinnerStyle = style;
return status;
}
}
}

View File

@@ -8,32 +8,34 @@ namespace Spectre.Console.Internal
{
public static int GetCellLength(RenderContext context, string text)
{
return text.Sum(rune =>
{
if (context.LegacyConsole)
{
// Is it represented by a single byte?
// In that case we don't have to calculate the
// actual cell width.
if (context.Encoding.GetByteCount(new[] { rune }) == 1)
{
return 1;
}
}
return text.Sum(rune => GetCellLength(context, rune));
}
// TODO: We need to figure out why Segment.SplitLines fails
// if we let wcwidth (which returns -1 instead of 1)
// calculate the size for new line characters.
// That is correct from a Unicode perspective, but the
// algorithm was written before wcwidth was added and used
// to work with string length and not cell length.
if (rune == '\n')
public static int GetCellLength(RenderContext context, char rune)
{
if (context.LegacyConsole)
{
// Is it represented by a single byte?
// In that case we don't have to calculate the
// actual cell width.
if (context.Encoding.GetByteCount(new[] { rune }) == 1)
{
return 1;
}
}
return UnicodeCalculator.GetWidth(rune);
});
// TODO: We need to figure out why Segment.SplitLines fails
// if we let wcwidth (which returns -1 instead of 1)
// calculate the size for new line characters.
// That is correct from a Unicode perspective, but the
// algorithm was written before wcwidth was added and used
// to work with string length and not cell length.
if (rune == '\n')
{
return 1;
}
return UnicodeCalculator.GetWidth(rune);
}
}
}

View File

@@ -8,9 +8,6 @@ namespace Spectre.Console
/// </summary>
public sealed class PercentageColumn : ProgressColumn
{
/// <inheritdoc/>
protected internal override int? ColumnWidth => 4;
/// <summary>
/// Gets or sets the style for a non-complete task.
/// </summary>
@@ -28,5 +25,11 @@ namespace Spectre.Console
var style = percentage == 100 ? CompletedStyle : Style ?? Style.Plain;
return new Text($"{percentage}%", style).RightAligned();
}
/// <inheritdoc/>
public override int? GetColumnWidth(RenderContext context)
{
return 4;
}
}
}

View File

@@ -8,6 +8,9 @@ namespace Spectre.Console
/// </summary>
public sealed class RemainingTimeColumn : ProgressColumn
{
/// <inheritdoc/>
protected internal override bool NoWrap => true;
/// <summary>
/// Gets or sets the style of the remaining time text.
/// </summary>
@@ -24,5 +27,11 @@ namespace Spectre.Console
return new Text($"{remaining.Value:h\\:mm\\:ss}", Style ?? Style.Plain);
}
/// <inheritdoc/>
public override int? GetColumnWidth(RenderContext context)
{
return 7;
}
}
}

View File

@@ -1,4 +1,6 @@
using System;
using System.Linq;
using Spectre.Console.Internal;
using Spectre.Console.Rendering;
namespace Spectre.Console
@@ -11,34 +13,95 @@ namespace Spectre.Console
private const string ACCUMULATED = "SPINNER_ACCUMULATED";
private const string INDEX = "SPINNER_INDEX";
private readonly string _ansiSequence = "⣷⣯⣟⡿⢿⣻⣽⣾";
private readonly string _asciiSequence = "-\\|/-\\|/";
private readonly object _lock;
private Spinner _spinner;
private int? _maxWidth;
/// <inheritdoc/>
protected internal override bool NoWrap => true;
/// <summary>
/// Gets or sets the <see cref="Console.Spinner"/>.
/// </summary>
public Spinner Spinner
{
get => _spinner;
set
{
lock (_lock)
{
_spinner = value ?? Spinner.Known.Default;
_maxWidth = null;
}
}
}
/// <summary>
/// Gets or sets the style of the spinner.
/// </summary>
public Style Style { get; set; } = new Style(foreground: Color.Yellow);
public Style? Style { get; set; } = new Style(foreground: Color.Yellow);
/// <summary>
/// Initializes a new instance of the <see cref="SpinnerColumn"/> class.
/// </summary>
public SpinnerColumn()
: this(Spinner.Known.Default)
{
}
/// <summary>
/// Initializes a new instance of the <see cref="SpinnerColumn"/> class.
/// </summary>
/// <param name="spinner">The spinner to use.</param>
public SpinnerColumn(Spinner spinner)
{
_spinner = spinner ?? throw new ArgumentNullException(nameof(spinner));
_lock = new object();
}
/// <inheritdoc/>
public override IRenderable Render(RenderContext context, ProgressTask task, TimeSpan deltaTime)
{
var useAscii = (context.LegacyConsole || !context.Unicode) && _spinner.IsUnicode;
var spinner = useAscii ? Spinner.Known.Ascii : _spinner ?? Spinner.Known.Default;
if (!task.IsStarted || task.IsFinished)
{
return new Markup(" ");
return new Markup(new string(' ', GetMaxWidth(context)));
}
var accumulated = task.State.Update<double>(ACCUMULATED, acc => acc + deltaTime.TotalMilliseconds);
if (accumulated >= 100)
if (accumulated >= spinner.Interval.TotalMilliseconds)
{
task.State.Update<double>(ACCUMULATED, _ => 0);
task.State.Update<int>(INDEX, index => index + 1);
}
var useAscii = context.LegacyConsole || !context.Unicode;
var sequence = useAscii ? _asciiSequence : _ansiSequence;
var index = task.State.Get<int>(INDEX);
return new Markup(sequence[index % sequence.Length].ToString(), Style ?? Style.Plain);
var frame = spinner.Frames[index % spinner.Frames.Count];
return new Markup(frame.EscapeMarkup(), Style ?? Style.Plain);
}
/// <inheritdoc/>
public override int? GetColumnWidth(RenderContext context)
{
return GetMaxWidth(context);
}
private int GetMaxWidth(RenderContext context)
{
lock (_lock)
{
if (_maxWidth == null)
{
var useAscii = (context.LegacyConsole || !context.Unicode) && _spinner.IsUnicode;
var spinner = useAscii ? Spinner.Known.Ascii : _spinner ?? Spinner.Known.Default;
_maxWidth = spinner.Frames.Max(frame => Cell.GetCellLength(context, frame));
}
return _maxWidth.Value;
}
}
}
}

View File

@@ -8,11 +8,14 @@ namespace Spectre.Console
/// </summary>
public sealed class TaskDescriptionColumn : ProgressColumn
{
/// <inheritdoc/>
protected internal override bool NoWrap => true;
/// <inheritdoc/>
public override IRenderable Render(RenderContext context, ProgressTask task, TimeSpan deltaTime)
{
var text = task.Description?.RemoveNewLines()?.Trim();
return new Markup(text ?? string.Empty).RightAligned();
return new Markup(text ?? string.Empty).Overflow(Overflow.Ellipsis).RightAligned();
}
}
}

View File

@@ -26,8 +26,16 @@ namespace Spectre.Console
/// </summary>
public bool AutoClear { get; set; }
/// <summary>
/// Gets or sets the refresh rate if <c>AutoRefresh</c> is enabled.
/// Defaults to 10 times/second.
/// </summary>
public TimeSpan RefreshRate { get; set; } = TimeSpan.FromMilliseconds(100);
internal List<ProgressColumn> Columns { get; }
internal ProgressRenderer? FallbackRenderer { get; set; }
/// <summary>
/// Initializes a new instance of the <see cref="Progress"/> class.
/// </summary>
@@ -110,11 +118,11 @@ namespace Spectre.Console
if (interactive)
{
var columns = new List<ProgressColumn>(Columns);
return new InteractiveProgressRenderer(_console, columns);
return new DefaultProgressRenderer(_console, columns, RefreshRate);
}
else
{
return new NonInteractiveProgressRenderer();
return FallbackRenderer ?? new FallbackProgressRenderer();
}
}
}

View File

@@ -9,9 +9,9 @@ namespace Spectre.Console
public abstract class ProgressColumn
{
/// <summary>
/// Gets the requested column width for the column.
/// Gets a value indicating whether or not content should not wrap.
/// </summary>
protected internal virtual int? ColumnWidth { get; }
protected internal virtual bool NoWrap { get; }
/// <summary>
/// Gets a renderable representing the column.
@@ -21,5 +21,15 @@ namespace Spectre.Console
/// <param name="deltaTime">The elapsed time since last call.</param>
/// <returns>A renderable representing the column.</returns>
public abstract IRenderable Render(RenderContext context, ProgressTask task, TimeSpan deltaTime);
/// <summary>
/// Gets the width of the column.
/// </summary>
/// <param name="context">The context.</param>
/// <returns>The width of the column, or <c>null</c> to calculate.</returns>
public virtual int? GetColumnWidth(RenderContext context)
{
return null;
}
}
}

View File

@@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using Spectre.Console.Internal;
namespace Spectre.Console
@@ -21,6 +22,8 @@ namespace Spectre.Console
/// </summary>
public bool IsFinished => _tasks.All(task => task.IsFinished);
internal Encoding Encoding => _console.Encoding;
internal ProgressContext(IAnsiConsole console, ProgressRenderer renderer)
{
_tasks = new List<ProgressTask>();
@@ -56,14 +59,11 @@ namespace Spectre.Console
_console.Render(new ControlSequence(string.Empty));
}
internal void EnumerateTasks(Action<ProgressTask> action)
internal IReadOnlyList<ProgressTask> GetTasks()
{
lock (_taskLock)
{
foreach (var task in _tasks)
{
action(task);
}
return new List<ProgressTask>(_tasks);
}
}
}

View File

@@ -167,7 +167,7 @@ namespace Spectre.Console
if (maxValue != null)
{
_maxValue += maxValue.Value;
_maxValue = maxValue.Value;
}
if (increment != null)
@@ -175,6 +175,12 @@ namespace Spectre.Console
Value += increment.Value;
}
// Need to cap the max value?
if (Value > _maxValue)
{
Value = _maxValue;
}
var timestamp = DateTime.Now;
var threshold = timestamp - TimeSpan.FromSeconds(30);
@@ -259,6 +265,14 @@ namespace Spectre.Console
return null;
}
// If the speed is zero, the estimate below
// will return infinity (since it's a double),
// so let's set the speed to 1 in that case.
if (speed == 0)
{
speed = 1;
}
var estimate = (MaxValue - Value) / speed.Value;
return TimeSpan.FromSeconds(estimate);
}

View File

@@ -6,7 +6,7 @@ using Spectre.Console.Rendering;
namespace Spectre.Console.Internal
{
internal sealed class InteractiveProgressRenderer : ProgressRenderer
internal sealed class DefaultProgressRenderer : ProgressRenderer
{
private readonly IAnsiConsole _console;
private readonly List<ProgressColumn> _columns;
@@ -15,9 +15,9 @@ namespace Spectre.Console.Internal
private readonly Stopwatch _stopwatch;
private TimeSpan _lastUpdate;
public override TimeSpan RefreshRate => TimeSpan.FromMilliseconds(100);
public override TimeSpan RefreshRate { get; }
public InteractiveProgressRenderer(IAnsiConsole console, List<ProgressColumn> columns)
public DefaultProgressRenderer(IAnsiConsole console, List<ProgressColumn> columns, TimeSpan refreshRate)
{
_console = console ?? throw new ArgumentNullException(nameof(console));
_columns = columns ?? throw new ArgumentNullException(nameof(columns));
@@ -25,6 +25,8 @@ namespace Spectre.Console.Internal
_lock = new object();
_stopwatch = new Stopwatch();
_lastUpdate = TimeSpan.Zero;
RefreshRate = refreshRate;
}
public override void Started()
@@ -58,6 +60,8 @@ namespace Spectre.Console.Internal
_stopwatch.Start();
}
var renderContext = new RenderContext(_console.Encoding, _console.Capabilities.LegacyConsole);
var delta = _stopwatch.Elapsed - _lastUpdate;
_lastUpdate = _stopwatch.Elapsed;
@@ -65,9 +69,16 @@ namespace Spectre.Console.Internal
for (var columnIndex = 0; columnIndex < _columns.Count; columnIndex++)
{
var column = new GridColumn().PadRight(1);
if (_columns[columnIndex].ColumnWidth != null)
var columnWidth = _columns[columnIndex].GetColumnWidth(renderContext);
if (columnWidth != null)
{
column.Width = _columns[columnIndex].ColumnWidth;
column.Width = columnWidth;
}
if (_columns[columnIndex].NoWrap)
{
column.NoWrap();
}
// Last column?
@@ -80,12 +91,11 @@ namespace Spectre.Console.Internal
}
// Add rows
var renderContext = new RenderContext(_console.Encoding, _console.Capabilities.LegacyConsole);
context.EnumerateTasks(task =>
foreach (var task in context.GetTasks())
{
var columns = _columns.Select(column => column.Render(renderContext, task, delta));
grid.AddRow(columns.ToArray());
});
}
_live.SetRenderable(new Padder(grid, new Padding(0, 1)));
}

View File

@@ -4,7 +4,7 @@ using Spectre.Console.Rendering;
namespace Spectre.Console.Internal
{
internal sealed class NonInteractiveProgressRenderer : ProgressRenderer
internal sealed class FallbackProgressRenderer : ProgressRenderer
{
private const double FirstMilestone = 25;
private static readonly double?[] _milestones = new double?[] { FirstMilestone, 50, 75, 95, 96, 97, 98, 99, 100 };
@@ -16,7 +16,7 @@ namespace Spectre.Console.Internal
public override TimeSpan RefreshRate => TimeSpan.FromSeconds(1);
public NonInteractiveProgressRenderer()
public FallbackProgressRenderer()
{
_taskMilestones = new Dictionary<int, double>();
_lock = new object();
@@ -29,7 +29,7 @@ namespace Spectre.Console.Internal
var hasStartedTasks = false;
var updates = new List<(string, double)>();
context.EnumerateTasks(task =>
foreach (var task in context.GetTasks())
{
if (!task.IsStarted || task.IsFinished)
{
@@ -42,12 +42,15 @@ namespace Spectre.Console.Internal
{
updates.Add((task.Description, task.Percentage));
}
});
}
// Got started tasks but no updates for 30 seconds?
if (hasStartedTasks && updates.Count == 0 && (DateTime.Now - _lastUpdate) > TimeSpan.FromSeconds(30))
{
context.EnumerateTasks(task => updates.Add((task.Description, task.Percentage)));
foreach (var task in context.GetTasks())
{
updates.Add((task.Description, task.Percentage));
}
}
if (updates.Count > 0)

View File

@@ -0,0 +1,60 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Spectre.Console.Rendering;
namespace Spectre.Console.Internal
{
internal sealed class StatusFallbackRenderer : ProgressRenderer
{
private readonly object _lock;
private IRenderable? _renderable;
private string? _lastStatus;
public override TimeSpan RefreshRate => TimeSpan.FromMilliseconds(100);
public StatusFallbackRenderer()
{
_lock = new object();
}
public override void Update(ProgressContext context)
{
lock (_lock)
{
var task = context.GetTasks().SingleOrDefault();
if (task != null)
{
// Not same description?
if (_lastStatus != task.Description)
{
_lastStatus = task.Description;
_renderable = new Markup(task.Description + Environment.NewLine);
return;
}
}
_renderable = null;
return;
}
}
public override IEnumerable<IRenderable> Process(RenderContext context, IEnumerable<IRenderable> renderables)
{
lock (_lock)
{
var result = new List<IRenderable>();
result.AddRange(renderables);
if (_renderable != null)
{
result.Add(_renderable);
}
_renderable = null;
return result;
}
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,27 @@
using System;
using System.Collections.Generic;
namespace Spectre.Console
{
/// <summary>
/// Represents a spinner used in a <see cref="SpinnerColumn"/>.
/// </summary>
public abstract partial class Spinner
{
/// <summary>
/// Gets the update interval for the spinner.
/// </summary>
public abstract TimeSpan Interval { get; }
/// <summary>
/// Gets a value indicating whether or not the spinner
/// uses Unicode characters.
/// </summary>
public abstract bool IsUnicode { get; }
/// <summary>
/// Gets the spinner frames.
/// </summary>
public abstract IReadOnlyList<string> Frames { get; }
}
}

View File

@@ -0,0 +1,89 @@
using System;
using System.Threading.Tasks;
using Spectre.Console.Internal;
namespace Spectre.Console
{
/// <summary>
/// Represents a status display.
/// </summary>
public sealed class Status
{
private readonly IAnsiConsole _console;
/// <summary>
/// Gets or sets the spinner.
/// </summary>
public Spinner? Spinner { get; set; }
/// <summary>
/// Gets or sets the spinner style.
/// </summary>
public Style? SpinnerStyle { get; set; } = new Style(foreground: Color.Yellow);
/// <summary>
/// Gets or sets a value indicating whether or not status
/// should auto refresh. Defaults to <c>true</c>.
/// </summary>
public bool AutoRefresh { get; set; } = true;
/// <summary>
/// Initializes a new instance of the <see cref="Status"/> class.
/// </summary>
/// <param name="console">The console.</param>
public Status(IAnsiConsole console)
{
_console = console ?? throw new ArgumentNullException(nameof(console));
}
/// <summary>
/// Starts a new status display.
/// </summary>
/// <param name="status">The status to display.</param>
/// <param name="action">he action to execute.</param>
public void Start(string status, Action<StatusContext> action)
{
var task = StartAsync(status, ctx =>
{
action(ctx);
return Task.CompletedTask;
});
task.GetAwaiter().GetResult();
}
/// <summary>
/// Starts a new status display.
/// </summary>
/// <param name="status">The status to display.</param>
/// <param name="action">he action to execute.</param>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
public async Task StartAsync(string status, Func<StatusContext, Task> action)
{
// Set the progress columns
var spinnerColumn = new SpinnerColumn(Spinner ?? Spinner.Known.Default)
{
Style = SpinnerStyle ?? Style.Plain,
};
var progress = new Progress(_console)
{
FallbackRenderer = new StatusFallbackRenderer(),
AutoClear = true,
AutoRefresh = AutoRefresh,
};
progress.Columns(new ProgressColumn[]
{
spinnerColumn,
new TaskDescriptionColumn(),
});
await progress.StartAsync(async ctx =>
{
var statusContext = new StatusContext(ctx, ctx.AddTask(status), spinnerColumn);
await action(statusContext).ConfigureAwait(false);
}).ConfigureAwait(false);
}
}
}

View File

@@ -0,0 +1,76 @@
using System;
namespace Spectre.Console
{
/// <summary>
/// Represents a context that can be used to interact with a <see cref="Status"/>.
/// </summary>
public sealed class StatusContext
{
private readonly ProgressContext _context;
private readonly ProgressTask _task;
private readonly SpinnerColumn _spinnerColumn;
/// <summary>
/// Gets or sets the current status.
/// </summary>
public string Status
{
get => _task.Description;
set => SetStatus(value);
}
/// <summary>
/// Gets or sets the current spinner.
/// </summary>
public Spinner Spinner
{
get => _spinnerColumn.Spinner;
set => SetSpinner(value);
}
/// <summary>
/// Gets or sets the current spinner style.
/// </summary>
public Style? SpinnerStyle
{
get => _spinnerColumn.Style;
set => _spinnerColumn.Style = value;
}
internal StatusContext(ProgressContext context, ProgressTask task, SpinnerColumn spinnerColumn)
{
_context = context ?? throw new ArgumentNullException(nameof(context));
_task = task ?? throw new ArgumentNullException(nameof(task));
_spinnerColumn = spinnerColumn ?? throw new ArgumentNullException(nameof(spinnerColumn));
}
/// <summary>
/// Refreshes the status.
/// </summary>
public void Refresh()
{
_context.Refresh();
}
private void SetStatus(string status)
{
if (status is null)
{
throw new ArgumentNullException(nameof(status));
}
_task.Description = status;
}
private void SetSpinner(Spinner spinner)
{
if (spinner is null)
{
throw new ArgumentNullException(nameof(spinner));
}
_spinnerColumn.Spinner = spinner;
}
}
}

View File

@@ -1,7 +1,5 @@
using System.Collections.Generic;
using System.Linq;
using Spectre.Console.Internal;
using Spectre.Console.Rendering;
namespace Spectre.Console.Rendering
{
@@ -9,7 +7,7 @@ namespace Spectre.Console.Rendering
{
private readonly object _lock = new object();
private IRenderable? _renderable;
private int? _height;
private SegmentShape? _shape;
public void SetRenderable(IRenderable renderable)
{
@@ -23,12 +21,12 @@ namespace Spectre.Console.Rendering
{
lock (_lock)
{
if (_height == null)
if (_shape == null)
{
return new ControlSequence(string.Empty);
}
return new ControlSequence("\r" + "\u001b[1A".Repeat(_height.Value - 1));
return new ControlSequence("\r" + "\u001b[1A".Repeat(_shape.Value.Height - 1));
}
}
@@ -36,12 +34,12 @@ namespace Spectre.Console.Rendering
{
lock (_lock)
{
if (_height == null)
if (_shape == null)
{
return new ControlSequence(string.Empty);
}
return new ControlSequence("\r\u001b[2K" + "\u001b[1A\u001b[2K".Repeat(_height.Value - 1));
return new ControlSequence("\r\u001b[2K" + "\u001b[1A\u001b[2K".Repeat(_shape.Value.Height - 1));
}
}
@@ -54,27 +52,27 @@ namespace Spectre.Console.Rendering
var segments = _renderable.Render(context, maxWidth);
var lines = Segment.SplitLines(context, segments);
_height = lines.Count;
var shape = SegmentShape.Calculate(context, lines);
_shape = _shape == null ? shape : _shape.Value.Inflate(shape);
_shape.Value.Apply(context, ref lines);
var result = new List<Segment>();
foreach (var (_, _, last, line) in lines.Enumerate())
{
foreach (var item in line)
{
result.Add(item);
yield return item;
}
if (!last)
{
result.Add(Segment.LineBreak);
yield return Segment.LineBreak;
}
}
return result;
yield break;
}
_height = 0;
return Enumerable.Empty<Segment>();
_shape = null;
}
}
}

View File

@@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Text;
using Spectre.Console.Internal;
namespace Spectre.Console.Rendering
{
@@ -145,6 +146,7 @@ namespace Spectre.Console.Rendering
/// </summary>
/// <param name="offset">The offset where to split the segment.</param>
/// <returns>One or two new segments representing the split.</returns>
[Obsolete("Use Split(RenderContext, Int32) instead")]
public (Segment First, Segment? Second) Split(int offset)
{
if (offset < 0)
@@ -162,6 +164,44 @@ namespace Spectre.Console.Rendering
new Segment(Text.Substring(offset, Text.Length - offset), Style));
}
/// <summary>
/// Splits the segment at the offset.
/// </summary>
/// <param name="context">The render context.</param>
/// <param name="offset">The offset where to split the segment.</param>
/// <returns>One or two new segments representing the split.</returns>
public (Segment First, Segment? Second) Split(RenderContext context, int offset)
{
if (offset < 0)
{
return (this, null);
}
if (offset >= CellCount(context))
{
return (this, null);
}
var index = 0;
if (offset > 0)
{
var accumulated = 0;
foreach (var character in Text)
{
index++;
accumulated += Cell.GetCellLength(context, character);
if (accumulated >= offset)
{
break;
}
}
}
return (
new Segment(Text.Substring(0, index), Style),
new Segment(Text.Substring(index, Text.Length - index), Style));
}
/// <summary>
/// Clones the segment.
/// </summary>
@@ -219,14 +259,16 @@ namespace Spectre.Console.Rendering
while (stack.Count > 0)
{
var segment = stack.Pop();
var segmentLength = segment.CellCount(context);
// Does this segment make the line exceed the max width?
if (line.CellCount(context) + segment.CellCount(context) > maxWidth)
var lineLength = line.CellCount(context);
if (lineLength + segmentLength > maxWidth)
{
var diff = -(maxWidth - (line.Length + segment.Text.Length));
var diff = -(maxWidth - (lineLength + segmentLength));
var offset = segment.Text.Length - diff;
var (first, second) = segment.Split(offset);
var (first, second) = segment.Split(context, offset);
line.Add(first);
lines.Add(line);
@@ -599,6 +641,7 @@ namespace Spectre.Console.Rendering
return stack.ToList();
}
// TODO: Move this to Table
internal static List<List<SegmentLine>> MakeSameHeight(int cellHeight, List<List<SegmentLine>> cells)
{
if (cells is null)

View File

@@ -0,0 +1,68 @@
using System;
using System.Collections.Generic;
using System.Linq;
namespace Spectre.Console.Rendering
{
internal readonly struct SegmentShape
{
public int Width { get; }
public int Height { get; }
public SegmentShape(int width, int height)
{
Width = width;
Height = height;
}
public static SegmentShape Calculate(RenderContext context, List<SegmentLine> lines)
{
if (context is null)
{
throw new ArgumentNullException(nameof(context));
}
if (lines is null)
{
throw new ArgumentNullException(nameof(lines));
}
var height = lines.Count;
var width = lines.Max(l => Segment.CellCount(context, l));
return new SegmentShape(width, height);
}
public SegmentShape Inflate(SegmentShape other)
{
return new SegmentShape(
Math.Max(Width, other.Width),
Math.Max(Height, other.Height));
}
public void Apply(RenderContext context, ref List<SegmentLine> lines)
{
foreach (var line in lines)
{
var length = Segment.CellCount(context, line);
var missing = Width - length;
if (missing > 0)
{
line.Add(new Segment(new string(' ', missing)));
}
}
if (lines.Count < Height && Width > 0)
{
var missing = Height - lines.Count;
for (var i = 0; i < missing; i++)
{
lines.Add(new SegmentLine
{
new Segment(new string(' ', Width)),
});
}
}
}
}
}

View File

@@ -58,6 +58,11 @@ namespace Spectre.Console
var width = childWidth + paddingWidth;
var result = new List<Segment>();
if (width > maxWidth)
{
width = maxWidth;
}
// Top padding
for (var i = 0; i < Padding.GetTopSafe(); i++)
{