mirror of
https://github.com/nsnail/spectre.console.git
synced 2025-04-24 04:02:50 +08:00
Add support for styling segments
This commit is contained in:
parent
921d595269
commit
5e41a2f505
@ -8,13 +8,22 @@ public static class Program
|
|||||||
{
|
{
|
||||||
public static void Main()
|
public static void Main()
|
||||||
{
|
{
|
||||||
AnsiConsole.WriteLine();
|
var windowsPath = @"C:\This is\A\Super Long\Windows\Path\That\Goes\On And On\And\Never\Seems\To\Stop\But\At\Some\Point\It\Must\I\Guess.txt";
|
||||||
AnsiConsole.Write(new TextPath(@"C:\Users\Patrik\Source\github\patriksvensson-forks\spectre.console\examples\Console\Paths"));
|
var unixPath = @"//This is/A/Super Long/Unix/Path/That/Goes/On And On/And/Never/Seems/To/Stop/But/At/Some/Point/It/Must/I/Guess.txt";
|
||||||
AnsiConsole.WriteLine();
|
|
||||||
|
|
||||||
var table = new Table().BorderColor(Color.Grey);
|
var table = new Table().BorderColor(Color.Grey);
|
||||||
table.AddColumns("[grey]Index[/]", "[yellow]Path[/]");
|
table.AddColumns("[grey]OS[/]", "[grey]Path[/]");
|
||||||
table.AddRow(new Text("1"), new TextPath(@"C:\Users\Patrik\Source\github\patriksvensson-forks\spectre.console\examples\Console\Paths"));
|
|
||||||
|
table.AddRow(new Text("Windows"),
|
||||||
|
new TextPath(windowsPath));
|
||||||
|
|
||||||
|
table.AddRow(new Text("Unix"),
|
||||||
|
new TextPath(unixPath)
|
||||||
|
.RootColor(Color.Blue)
|
||||||
|
.SeparatorColor(Color.Yellow)
|
||||||
|
.StemStyle(Color.Red)
|
||||||
|
.LeafStyle(Color.Green));
|
||||||
|
|
||||||
AnsiConsole.Write(table);
|
AnsiConsole.Write(table);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
119
src/Spectre.Console/Extensions/TextPathExtensions.cs
Normal file
119
src/Spectre.Console/Extensions/TextPathExtensions.cs
Normal file
@ -0,0 +1,119 @@
|
|||||||
|
namespace Spectre.Console;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Contains extension methods for <see cref="TextPath"/>.
|
||||||
|
/// </summary>
|
||||||
|
public static class TextPathExtensions
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Sets the separator style.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="obj">The path.</param>
|
||||||
|
/// <param name="style">The separator style to set.</param>
|
||||||
|
/// <returns>The same instance so that multiple calls can be chained.</returns>
|
||||||
|
public static TextPath SeparatorStyle(this TextPath obj, Style style)
|
||||||
|
{
|
||||||
|
if (obj is null)
|
||||||
|
{
|
||||||
|
throw new ArgumentNullException(nameof(obj));
|
||||||
|
}
|
||||||
|
|
||||||
|
obj.SeparatorStyle = style;
|
||||||
|
return obj;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sets the separator color.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="obj">The path.</param>
|
||||||
|
/// <param name="color">The separator color.</param>
|
||||||
|
/// <returns>The same instance so that multiple calls can be chained.</returns>
|
||||||
|
public static TextPath SeparatorColor(this TextPath obj, Color color)
|
||||||
|
{
|
||||||
|
return SeparatorStyle(obj, new Style(foreground: color));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sets the root style.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="obj">The path.</param>
|
||||||
|
/// <param name="style">The root style to set.</param>
|
||||||
|
/// <returns>The same instance so that multiple calls can be chained.</returns>
|
||||||
|
public static TextPath RootStyle(this TextPath obj, Style style)
|
||||||
|
{
|
||||||
|
if (obj is null)
|
||||||
|
{
|
||||||
|
throw new ArgumentNullException(nameof(obj));
|
||||||
|
}
|
||||||
|
|
||||||
|
obj.RootStyle = style;
|
||||||
|
return obj;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sets the root color.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="obj">The path.</param>
|
||||||
|
/// <param name="color">The root color.</param>
|
||||||
|
/// <returns>The same instance so that multiple calls can be chained.</returns>
|
||||||
|
public static TextPath RootColor(this TextPath obj, Color color)
|
||||||
|
{
|
||||||
|
return RootStyle(obj, new Style(foreground: color));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sets the stem style.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="obj">The path.</param>
|
||||||
|
/// <param name="style">The stem style to set.</param>
|
||||||
|
/// <returns>The same instance so that multiple calls can be chained.</returns>
|
||||||
|
public static TextPath StemStyle(this TextPath obj, Style style)
|
||||||
|
{
|
||||||
|
if (obj is null)
|
||||||
|
{
|
||||||
|
throw new ArgumentNullException(nameof(obj));
|
||||||
|
}
|
||||||
|
|
||||||
|
obj.StemStyle = style;
|
||||||
|
return obj;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sets the stem color.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="obj">The path.</param>
|
||||||
|
/// <param name="color">The stem color.</param>
|
||||||
|
/// <returns>The same instance so that multiple calls can be chained.</returns>
|
||||||
|
public static TextPath StemStyle(this TextPath obj, Color color)
|
||||||
|
{
|
||||||
|
return StemStyle(obj, new Style(foreground: color));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sets the leaf style.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="obj">The path.</param>
|
||||||
|
/// <param name="style">The stem leaf to set.</param>
|
||||||
|
/// <returns>The same instance so that multiple calls can be chained.</returns>
|
||||||
|
public static TextPath LeafStyle(this TextPath obj, Style style)
|
||||||
|
{
|
||||||
|
if (obj is null)
|
||||||
|
{
|
||||||
|
throw new ArgumentNullException(nameof(obj));
|
||||||
|
}
|
||||||
|
|
||||||
|
obj.LeafStyle = style;
|
||||||
|
return obj;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sets the leaf color.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="obj">The path.</param>
|
||||||
|
/// <param name="color">The leaf color.</param>
|
||||||
|
/// <returns>The same instance so that multiple calls can be chained.</returns>
|
||||||
|
public static TextPath LeafStyle(this TextPath obj, Color color)
|
||||||
|
{
|
||||||
|
return LeafStyle(obj, new Style(foreground: color));
|
||||||
|
}
|
||||||
|
}
|
@ -45,7 +45,7 @@ internal static class Aligner
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void Align<T>(RenderContext context, T segments, Justify? alignment, int maxWidth)
|
public static void Align<T>(T segments, Justify? alignment, int maxWidth)
|
||||||
where T : List<Segment>
|
where T : List<Segment>
|
||||||
{
|
{
|
||||||
if (alignment == null || alignment == Justify.Left)
|
if (alignment == null || alignment == Justify.Left)
|
||||||
|
@ -151,7 +151,7 @@ public sealed class Paragraph : Renderable, IAlignable, IOverflowable
|
|||||||
{
|
{
|
||||||
foreach (var line in lines)
|
foreach (var line in lines)
|
||||||
{
|
{
|
||||||
Aligner.Align(context, line, justification, maxWidth);
|
Aligner.Align(line, justification, maxWidth);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -115,7 +115,7 @@ internal static class TableRenderer
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Align the row result.
|
// Align the row result.
|
||||||
Aligner.Align(context.Options, rowResult, context.Alignment, context.MaxWidth);
|
Aligner.Align(rowResult, context.Alignment, context.MaxWidth);
|
||||||
|
|
||||||
// Is the row larger than the allowed max width?
|
// Is the row larger than the allowed max width?
|
||||||
if (Segment.CellCount(rowResult) > context.MaxWidth)
|
if (Segment.CellCount(rowResult) > context.MaxWidth)
|
||||||
@ -167,7 +167,7 @@ internal static class TableRenderer
|
|||||||
segments.AddRange(((IRenderable)paragraph).Render(context.Options, context.TableWidth));
|
segments.AddRange(((IRenderable)paragraph).Render(context.Options, context.TableWidth));
|
||||||
|
|
||||||
// Align over the whole buffer area
|
// Align over the whole buffer area
|
||||||
Aligner.Align(context.Options, segments, context.Alignment, context.MaxWidth);
|
Aligner.Align(segments, context.Alignment, context.MaxWidth);
|
||||||
|
|
||||||
segments.Add(Segment.LineBreak);
|
segments.Add(Segment.LineBreak);
|
||||||
return segments;
|
return segments;
|
||||||
|
@ -5,7 +5,32 @@ namespace Spectre.Console;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class TextPath : IRenderable
|
public sealed class TextPath : IRenderable
|
||||||
{
|
{
|
||||||
|
private const string Ellipsis = "...";
|
||||||
|
private const string UnicodeEllipsis = "…";
|
||||||
|
|
||||||
private readonly string[] _parts;
|
private readonly string[] _parts;
|
||||||
|
private readonly bool _rooted;
|
||||||
|
private readonly bool _windows;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the root style.
|
||||||
|
/// </summary>
|
||||||
|
public Style? RootStyle { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the separator style.
|
||||||
|
/// </summary>
|
||||||
|
public Style? SeparatorStyle { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the stem style.
|
||||||
|
/// </summary>
|
||||||
|
public Style? StemStyle { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the leaf style.
|
||||||
|
/// </summary>
|
||||||
|
public Style? LeafStyle { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Initializes a new instance of the <see cref="TextPath"/> class.
|
/// Initializes a new instance of the <see cref="TextPath"/> class.
|
||||||
@ -20,13 +45,27 @@ public sealed class TextPath : IRenderable
|
|||||||
|
|
||||||
// Get the distinct parts
|
// Get the distinct parts
|
||||||
_parts = path.Split(new char[] { '/' }, StringSplitOptions.RemoveEmptyEntries);
|
_parts = path.Split(new char[] { '/' }, StringSplitOptions.RemoveEmptyEntries);
|
||||||
|
|
||||||
|
// Rooted Unix path?
|
||||||
|
if (path.StartsWith("/"))
|
||||||
|
{
|
||||||
|
_rooted = true;
|
||||||
|
_parts = new[] { "/" }.Concat(_parts).ToArray();
|
||||||
|
}
|
||||||
|
else if (_parts.Length > 0 && _parts[0].EndsWith(":"))
|
||||||
|
{
|
||||||
|
// Rooted Windows path
|
||||||
|
_rooted = true;
|
||||||
|
_windows = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
public Measurement Measure(RenderContext context, int maxWidth)
|
public Measurement Measure(RenderContext context, int maxWidth)
|
||||||
{
|
{
|
||||||
var fitted = Fit(context, maxWidth);
|
var fitted = Fit(context, maxWidth);
|
||||||
var length = fitted.Sum(f => f.Length) + fitted.Length - 1;
|
var separatorCount = fitted.Length - 1;
|
||||||
|
var length = fitted.Sum(f => f.Length) + separatorCount;
|
||||||
|
|
||||||
return new Measurement(
|
return new Measurement(
|
||||||
Math.Min(length, maxWidth),
|
Math.Min(length, maxWidth),
|
||||||
@ -36,18 +75,43 @@ public sealed class TextPath : IRenderable
|
|||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
public IEnumerable<Segment> Render(RenderContext context, int maxWidth)
|
public IEnumerable<Segment> Render(RenderContext context, int maxWidth)
|
||||||
{
|
{
|
||||||
|
var rootStyle = RootStyle ?? Style.Plain;
|
||||||
|
var separatorStyle = SeparatorStyle ?? Style.Plain;
|
||||||
|
var stemStyle = StemStyle ?? Style.Plain;
|
||||||
|
var leafStyle = LeafStyle ?? Style.Plain;
|
||||||
|
|
||||||
var fitted = Fit(context, maxWidth);
|
var fitted = Fit(context, maxWidth);
|
||||||
|
|
||||||
var parts = new List<Segment>();
|
var parts = new List<Segment>();
|
||||||
foreach (var (_, _, last, item) in fitted.Enumerate())
|
foreach (var (_, first, last, item) in fitted.Enumerate())
|
||||||
{
|
{
|
||||||
parts.Add(new Segment(item));
|
// Leaf?
|
||||||
|
if (last)
|
||||||
|
{
|
||||||
|
parts.Add(new Segment(item, leafStyle));
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
if (first && _rooted)
|
||||||
|
{
|
||||||
|
// Root
|
||||||
|
parts.Add(new Segment(item, rootStyle));
|
||||||
|
|
||||||
if (!last)
|
if (_windows)
|
||||||
{
|
{
|
||||||
parts.Add(new Segment("/", new Style(Color.Grey)));
|
// Windows root has a slash
|
||||||
|
parts.Add(new Segment("/", separatorStyle));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Normal path segment
|
||||||
|
parts.Add(new Segment(item, stemStyle));
|
||||||
|
parts.Add(new Segment("/", separatorStyle));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
parts.Add(Segment.LineBreak);
|
||||||
|
|
||||||
return parts;
|
return parts;
|
||||||
}
|
}
|
||||||
@ -66,13 +130,17 @@ public sealed class TextPath : IRenderable
|
|||||||
return _parts;
|
return _parts;
|
||||||
}
|
}
|
||||||
|
|
||||||
var ellipsis = context.Unicode ? "…" : "...";
|
var ellipsis = context.Unicode ? UnicodeEllipsis : Ellipsis;
|
||||||
var ellipsisLength = Cell.GetCellLength(ellipsis);
|
var ellipsisLength = Cell.GetCellLength(ellipsis);
|
||||||
|
|
||||||
if (_parts.Length >= 2)
|
if (_parts.Length >= 2)
|
||||||
{
|
{
|
||||||
|
var skip = _rooted ? 1 : 0;
|
||||||
|
var separatorCount = _rooted ? 2 : 1;
|
||||||
|
var rootLength = _rooted ? Cell.GetCellLength(_parts[0]) : 0;
|
||||||
|
|
||||||
// Try popping parts until it fits
|
// Try popping parts until it fits
|
||||||
var queue = new Queue<string>(_parts.Skip(1).Take(_parts.Length - 2));
|
var queue = new Queue<string>(_parts.Skip(skip).Take(_parts.Length - separatorCount));
|
||||||
while (queue.Count > 0)
|
while (queue.Count > 0)
|
||||||
{
|
{
|
||||||
// Remove the first item
|
// Remove the first item
|
||||||
@ -80,20 +148,27 @@ public sealed class TextPath : IRenderable
|
|||||||
|
|
||||||
// Get the current queue width in cells
|
// Get the current queue width in cells
|
||||||
var queueWidth =
|
var queueWidth =
|
||||||
Cell.GetCellLength(_parts[0]) // First
|
rootLength // Root (if rooted)
|
||||||
+ ellipsisLength // Ellipsis
|
+ ellipsisLength // Ellipsis
|
||||||
+ queue.Sum(p => Cell.GetCellLength(p)) // Middle
|
+ queue.Sum(p => Cell.GetCellLength(p)) // Middle
|
||||||
+ Cell.GetCellLength(_parts.Last()) // Last
|
+ Cell.GetCellLength(_parts.Last()) // Last
|
||||||
+ queue.Count + 2; // Separators
|
+ queue.Count + separatorCount; // Separators
|
||||||
|
|
||||||
// Will it fit?
|
// Will it fit?
|
||||||
if (maxWidth >= queueWidth)
|
if (maxWidth >= queueWidth)
|
||||||
{
|
{
|
||||||
var result = new List<string>();
|
var result = new List<string>();
|
||||||
|
|
||||||
|
if (_rooted)
|
||||||
|
{
|
||||||
|
// Add the root
|
||||||
result.Add(_parts[0]);
|
result.Add(_parts[0]);
|
||||||
|
}
|
||||||
|
|
||||||
result.Add(ellipsis);
|
result.Add(ellipsis);
|
||||||
result.AddRange(queue);
|
result.AddRange(queue);
|
||||||
result.Add(_parts.Last());
|
result.Add(_parts.Last());
|
||||||
|
|
||||||
return result.ToArray();
|
return result.ToArray();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -101,7 +176,9 @@ public sealed class TextPath : IRenderable
|
|||||||
|
|
||||||
// Just trim the last part so it fits
|
// Just trim the last part so it fits
|
||||||
var last = _parts.Last();
|
var last = _parts.Last();
|
||||||
var take = Math.Max(0, maxWidth - ellipsisLength);
|
var take = Math.Min(last.Length, Math.Max(0, maxWidth - ellipsisLength));
|
||||||
return new[] { string.Concat(ellipsis, last.Substring(last.Length - take, take)) };
|
var start = Math.Max(0, last.Length - take);
|
||||||
|
|
||||||
|
return new[] { string.Concat(ellipsis, last.Substring(start, take)) };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,7 @@
|
|||||||
|
<ProjectConfiguration>
|
||||||
|
<Settings>
|
||||||
|
<IgnoredTests>
|
||||||
|
<AllTestsSelector />
|
||||||
|
</IgnoredTests>
|
||||||
|
</Settings>
|
||||||
|
</ProjectConfiguration>
|
@ -0,0 +1,7 @@
|
|||||||
|
<ProjectConfiguration>
|
||||||
|
<Settings>
|
||||||
|
<IgnoredTests>
|
||||||
|
<AllTestsSelector />
|
||||||
|
</IgnoredTests>
|
||||||
|
</Settings>
|
||||||
|
</ProjectConfiguration>
|
@ -2,32 +2,6 @@ namespace Spectre.Console.Tests.Unit;
|
|||||||
|
|
||||||
public sealed class TextPathTests
|
public sealed class TextPathTests
|
||||||
{
|
{
|
||||||
[Fact]
|
|
||||||
public void Should_Render_Full_Path_If_Possible()
|
|
||||||
{
|
|
||||||
// Given
|
|
||||||
var console = new TestConsole().Width(40);
|
|
||||||
|
|
||||||
// When
|
|
||||||
console.Write(new TextPath("C:/Foo/Bar/Baz.txt"));
|
|
||||||
|
|
||||||
// Then
|
|
||||||
console.Output.ShouldBe("C:/Foo/Bar/Baz.txt");
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Should_Pop_Segments_From_Left()
|
|
||||||
{
|
|
||||||
// Given
|
|
||||||
var console = new TestConsole().Width(17);
|
|
||||||
|
|
||||||
// When
|
|
||||||
console.Write(new TextPath("C:/My documents/Bar/Baz.txt"));
|
|
||||||
|
|
||||||
// Then
|
|
||||||
console.Output.ShouldBe("C:/…/Bar/Baz.txt");
|
|
||||||
}
|
|
||||||
|
|
||||||
[Theory]
|
[Theory]
|
||||||
[InlineData(8, "1234567890", "…4567890")]
|
[InlineData(8, "1234567890", "…4567890")]
|
||||||
[InlineData(9, "1234567890", "…34567890")]
|
[InlineData(9, "1234567890", "…34567890")]
|
||||||
@ -40,6 +14,56 @@ public sealed class TextPathTests
|
|||||||
console.Write(new TextPath(input));
|
console.Write(new TextPath(input));
|
||||||
|
|
||||||
// Then
|
// Then
|
||||||
console.Output.ShouldBe(expected);
|
console.Output.TrimEnd().ShouldBe(expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("C:/Foo/Bar/Baz.txt", "C:/Foo/Bar/Baz.txt")]
|
||||||
|
[InlineData("/Foo/Bar/Baz.txt", "/Foo/Bar/Baz.txt")]
|
||||||
|
[InlineData("Foo/Bar/Baz.txt", "Foo/Bar/Baz.txt")]
|
||||||
|
public void Should_Render_Full_Path_If_Possible(string input, string expected)
|
||||||
|
{
|
||||||
|
// Given
|
||||||
|
var console = new TestConsole().Width(40);
|
||||||
|
|
||||||
|
// When
|
||||||
|
console.Write(new TextPath(input));
|
||||||
|
|
||||||
|
// Then
|
||||||
|
console.Output.TrimEnd().ShouldBe(expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(17, "C:/My documents/Bar/Baz.txt", "C:/…/Bar/Baz.txt")]
|
||||||
|
[InlineData(15, "/My documents/Bar/Baz.txt", "/…/Bar/Baz.txt")]
|
||||||
|
[InlineData(14, "My documents/Bar/Baz.txt", "…/Bar/Baz.txt")]
|
||||||
|
public void Should_Pop_Segments_From_Left(int width, string input, string expected)
|
||||||
|
{
|
||||||
|
// Given
|
||||||
|
var console = new TestConsole().Width(width);
|
||||||
|
|
||||||
|
// When
|
||||||
|
console.Write(new TextPath(input));
|
||||||
|
|
||||||
|
// Then
|
||||||
|
console.Output.TrimEnd().ShouldBe(expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("C:/My documents/Bar/Baz.txt")]
|
||||||
|
[InlineData("/My documents/Bar/Baz.txt")]
|
||||||
|
[InlineData("My documents/Bar/Baz.txt")]
|
||||||
|
[InlineData("Bar/Baz.txt")]
|
||||||
|
[InlineData("Baz.txt")]
|
||||||
|
public void Should_Insert_Line_Break_At_End_Of_Path(string input)
|
||||||
|
{
|
||||||
|
// Given
|
||||||
|
var console = new TestConsole().Width(80);
|
||||||
|
|
||||||
|
// When
|
||||||
|
console.Write(new TextPath(input));
|
||||||
|
|
||||||
|
// Then
|
||||||
|
console.Output.ShouldEndWith("\n");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user