diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index 6cf141e..0345315 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -7,6 +7,12 @@ "commands": [ "dotnet-cake" ] + }, + "dotnet-example": { + "version": "1.3.1", + "commands": [ + "dotnet-example" + ] } } } \ No newline at end of file diff --git a/src/RadLine.Sandbox/.editorconfig b/examples/Demo/.editorconfig similarity index 100% rename from src/RadLine.Sandbox/.editorconfig rename to examples/Demo/.editorconfig diff --git a/src/RadLine.Sandbox/RadLine.Sandbox.csproj b/examples/Demo/Demo.csproj similarity index 74% rename from src/RadLine.Sandbox/RadLine.Sandbox.csproj rename to examples/Demo/Demo.csproj index 8a6567e..e6d257b 100644 --- a/src/RadLine.Sandbox/RadLine.Sandbox.csproj +++ b/examples/Demo/Demo.csproj @@ -3,6 +3,7 @@ Exe net5.0 + false @@ -10,7 +11,7 @@ - + diff --git a/src/RadLine.Sandbox/Program.cs b/examples/Demo/Program.cs similarity index 65% rename from src/RadLine.Sandbox/Program.cs rename to examples/Demo/Program.cs index ac2a4e0..fd61a6d 100644 --- a/src/RadLine.Sandbox/Program.cs +++ b/examples/Demo/Program.cs @@ -1,26 +1,26 @@ using System; using System.Collections.Generic; -using System.Diagnostics; using System.Threading; using System.Threading.Tasks; using Spectre.Console; -namespace RadLine.Sandbox +namespace RadLine.Examples { public static class Program { public static async Task Main() { - if (!Debugger.IsAttached) + if (!LineEditor.IsSupported(AnsiConsole.Console)) { - Debugger.Launch(); + AnsiConsole.MarkupLine("The terminal does not support ANSI codes, or it isn't a terminal."); } + // Create editor var editor = new LineEditor() { MultiLine = true, - Text = "HELLO ABC WORLD DEF GHIJKLMN 🥰 PATRIK WAS HERE", - Prompt = new LineNumberPrompt(), + Text = "Hello, and welcome to RadLine!\nPress SHIFT+ENTER to insert a new line\nPress ENTER to submit", + Prompt = new LineNumberPrompt(new Style(foreground: Color.Yellow)), Completion = new TestCompletion(), Highlighter = new WordHighlighter() .AddWord("git", new Style(foreground: Color.Yellow)) @@ -31,31 +31,29 @@ namespace RadLine.Sandbox .AddWord("commit", new Style(foreground: Color.Blue)) .AddWord("rebase", new Style(foreground: Color.Red)) .AddWord("Hello", new Style(foreground: Color.Blue)) - .AddWord("Goodbye", new Style(foreground: Color.Green)) - .AddWord("World", new Style(foreground: Color.Yellow)) - .AddWord("Syntax", new Style(decoration: Decoration.Strikethrough)) - .AddWord("Highlighting", new Style(decoration: Decoration.SlowBlink)), + .AddWord("SHIFT", new Style(foreground: Color.Grey)) + .AddWord("ENTER", new Style(foreground: Color.Grey)) + .AddWord("RadLine", new Style(foreground: Color.Yellow, decoration: Decoration.SlowBlink)), }; // Add custom commands - editor.KeyBindings.Add(ConsoleKey.I, ConsoleModifiers.Control); + editor.KeyBindings.Add(ConsoleKey.I, ConsoleModifiers.Control); - // Read a line + // Read a line (or many) var result = await editor.ReadLine(CancellationToken.None); // Write the buffer AnsiConsole.WriteLine(); AnsiConsole.Render(new Panel(result.EscapeMarkup()) - .Header("[yellow]Commit details:[/]") + .Header("[yellow]Text:[/]") .RoundedBorder()); } } - public sealed class PrependSmiley : LineEditorCommand + public sealed class InsertSmiley : LineEditorCommand { public override void Execute(LineEditorContext context) { - context.Execute(new PreviousWordCommand()); context.Buffer.Insert(":-)"); } } @@ -71,7 +69,7 @@ namespace RadLine.Sandbox if (context.Equals("git ", StringComparison.Ordinal)) { - return new[] { "init", "initialize", "push", "commit", "rebase" }; + return new[] { "init", "branch", "push", "commit", "rebase" }; } return null; diff --git a/examples/Repl/.editorconfig b/examples/Repl/.editorconfig new file mode 100644 index 0000000..2adf83c --- /dev/null +++ b/examples/Repl/.editorconfig @@ -0,0 +1,3 @@ +root = false + +[*.cs] diff --git a/examples/Repl/Program.cs b/examples/Repl/Program.cs new file mode 100644 index 0000000..c173828 --- /dev/null +++ b/examples/Repl/Program.cs @@ -0,0 +1,57 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Spectre.Console; + +namespace RadLine.Examples +{ + public static class Program + { + public static async Task Main() + { + if (!LineEditor.IsSupported(AnsiConsole.Console)) + { + AnsiConsole.MarkupLine("The terminal does not support ANSI codes, or it isn't a terminal."); + } + + AnsiConsole.Render(new FigletText("BASIC REPL")); + + // Create editor + var editor = new LineEditor() + { + MultiLine = true, + Text = "PRINT \"Hello\"", + Prompt = new LineNumberPrompt(new Style(foreground: Color.Yellow)), + Highlighter = new WordHighlighter() + .AddWord("$", new Style(foreground: Color.Yellow)) + .AddWord("INPUT", new Style(foreground: Color.Blue)) + .AddWord("PRINT", new Style(foreground: Color.Blue)), + }; + + // Add custom commands + editor.KeyBindings.Clear(); + editor.KeyBindings.Add(ConsoleKey.Backspace); + editor.KeyBindings.Add(ConsoleKey.Delete); + editor.KeyBindings.Add(ConsoleKey.Home); + editor.KeyBindings.Add(ConsoleKey.End); + editor.KeyBindings.Add(ConsoleKey.PageUp); + editor.KeyBindings.Add(ConsoleKey.PageDown); + editor.KeyBindings.Add(ConsoleKey.LeftArrow); + editor.KeyBindings.Add(ConsoleKey.RightArrow); + editor.KeyBindings.Add(ConsoleKey.LeftArrow, ConsoleModifiers.Control); + editor.KeyBindings.Add(ConsoleKey.RightArrow, ConsoleModifiers.Control); + editor.KeyBindings.Add(ConsoleKey.Enter); + editor.KeyBindings.Add(ConsoleKey.Enter, ConsoleModifiers.Shift); + + // Read a line (or many) + var result = await editor.ReadLine(CancellationToken.None); + + // Write the buffer + AnsiConsole.WriteLine(); + AnsiConsole.Render(new Panel(result.EscapeMarkup()) + .Header("[yellow]Program:[/]") + .RoundedBorder()); + } + } +} \ No newline at end of file diff --git a/examples/Repl/Repl.csproj b/examples/Repl/Repl.csproj new file mode 100644 index 0000000..e6d257b --- /dev/null +++ b/examples/Repl/Repl.csproj @@ -0,0 +1,17 @@ + + + + Exe + net5.0 + false + + + + + + + + + + + diff --git a/src/RadLine.Tests/Utilities/SimpleServiceProvider.cs b/src/RadLine.Tests/Utilities/SimpleServiceProvider.cs index 6a4d0c7..4c2f4b5 100644 --- a/src/RadLine.Tests/Utilities/SimpleServiceProvider.cs +++ b/src/RadLine.Tests/Utilities/SimpleServiceProvider.cs @@ -1,8 +1,5 @@ using System; using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; namespace RadLine.Tests.Utilities { diff --git a/src/RadLine.sln b/src/RadLine.sln index 9b51460..1b1c5b3 100644 --- a/src/RadLine.sln +++ b/src/RadLine.sln @@ -3,11 +3,15 @@ Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 16 VisualStudioVersion = 16.6.30114.105 MinimumVisualStudioVersion = 10.0.40219.1 -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RadLine", "RadLine\RadLine.csproj", "{DFF3DD16-6AE7-4FDB-807F-F2E8A3166691}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "RadLine", "RadLine\RadLine.csproj", "{DFF3DD16-6AE7-4FDB-807F-F2E8A3166691}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RadLine.Tests", "RadLine.Tests\RadLine.Tests.csproj", "{5DFDEEA4-7B61-47FD-AEA0-14FB569EBE62}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "RadLine.Tests", "RadLine.Tests\RadLine.Tests.csproj", "{5DFDEEA4-7B61-47FD-AEA0-14FB569EBE62}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RadLine.Sandbox", "RadLine.Sandbox\RadLine.Sandbox.csproj", "{0DEC2184-5587-49C0-80FF-A963E6F494A9}" +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Examples", "Examples", "{4BC15770-BCF4-443D-B3BB-2DD0936D9B15}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Demo", "..\examples\Demo\Demo.csproj", "{D4041FF2-FA9D-499C-8523-E5461983A503}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Repl", "..\examples\Repl\Repl.csproj", "{B9E9618E-CB8F-4441-8D36-D96D01F73311}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -18,9 +22,6 @@ Global Release|x64 = Release|x64 Release|x86 = Release|x86 EndGlobalSection - GlobalSection(SolutionProperties) = preSolution - HideSolutionNode = FALSE - EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {DFF3DD16-6AE7-4FDB-807F-F2E8A3166691}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {DFF3DD16-6AE7-4FDB-807F-F2E8A3166691}.Debug|Any CPU.Build.0 = Debug|Any CPU @@ -46,17 +47,39 @@ Global {5DFDEEA4-7B61-47FD-AEA0-14FB569EBE62}.Release|x64.Build.0 = Release|Any CPU {5DFDEEA4-7B61-47FD-AEA0-14FB569EBE62}.Release|x86.ActiveCfg = Release|Any CPU {5DFDEEA4-7B61-47FD-AEA0-14FB569EBE62}.Release|x86.Build.0 = Release|Any CPU - {0DEC2184-5587-49C0-80FF-A963E6F494A9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {0DEC2184-5587-49C0-80FF-A963E6F494A9}.Debug|Any CPU.Build.0 = Debug|Any CPU - {0DEC2184-5587-49C0-80FF-A963E6F494A9}.Debug|x64.ActiveCfg = Debug|Any CPU - {0DEC2184-5587-49C0-80FF-A963E6F494A9}.Debug|x64.Build.0 = Debug|Any CPU - {0DEC2184-5587-49C0-80FF-A963E6F494A9}.Debug|x86.ActiveCfg = Debug|Any CPU - {0DEC2184-5587-49C0-80FF-A963E6F494A9}.Debug|x86.Build.0 = Debug|Any CPU - {0DEC2184-5587-49C0-80FF-A963E6F494A9}.Release|Any CPU.ActiveCfg = Release|Any CPU - {0DEC2184-5587-49C0-80FF-A963E6F494A9}.Release|Any CPU.Build.0 = Release|Any CPU - {0DEC2184-5587-49C0-80FF-A963E6F494A9}.Release|x64.ActiveCfg = Release|Any CPU - {0DEC2184-5587-49C0-80FF-A963E6F494A9}.Release|x64.Build.0 = Release|Any CPU - {0DEC2184-5587-49C0-80FF-A963E6F494A9}.Release|x86.ActiveCfg = Release|Any CPU - {0DEC2184-5587-49C0-80FF-A963E6F494A9}.Release|x86.Build.0 = Release|Any CPU + {D4041FF2-FA9D-499C-8523-E5461983A503}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D4041FF2-FA9D-499C-8523-E5461983A503}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D4041FF2-FA9D-499C-8523-E5461983A503}.Debug|x64.ActiveCfg = Debug|Any CPU + {D4041FF2-FA9D-499C-8523-E5461983A503}.Debug|x64.Build.0 = Debug|Any CPU + {D4041FF2-FA9D-499C-8523-E5461983A503}.Debug|x86.ActiveCfg = Debug|Any CPU + {D4041FF2-FA9D-499C-8523-E5461983A503}.Debug|x86.Build.0 = Debug|Any CPU + {D4041FF2-FA9D-499C-8523-E5461983A503}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D4041FF2-FA9D-499C-8523-E5461983A503}.Release|Any CPU.Build.0 = Release|Any CPU + {D4041FF2-FA9D-499C-8523-E5461983A503}.Release|x64.ActiveCfg = Release|Any CPU + {D4041FF2-FA9D-499C-8523-E5461983A503}.Release|x64.Build.0 = Release|Any CPU + {D4041FF2-FA9D-499C-8523-E5461983A503}.Release|x86.ActiveCfg = Release|Any CPU + {D4041FF2-FA9D-499C-8523-E5461983A503}.Release|x86.Build.0 = Release|Any CPU + {B9E9618E-CB8F-4441-8D36-D96D01F73311}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B9E9618E-CB8F-4441-8D36-D96D01F73311}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B9E9618E-CB8F-4441-8D36-D96D01F73311}.Debug|x64.ActiveCfg = Debug|Any CPU + {B9E9618E-CB8F-4441-8D36-D96D01F73311}.Debug|x64.Build.0 = Debug|Any CPU + {B9E9618E-CB8F-4441-8D36-D96D01F73311}.Debug|x86.ActiveCfg = Debug|Any CPU + {B9E9618E-CB8F-4441-8D36-D96D01F73311}.Debug|x86.Build.0 = Debug|Any CPU + {B9E9618E-CB8F-4441-8D36-D96D01F73311}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B9E9618E-CB8F-4441-8D36-D96D01F73311}.Release|Any CPU.Build.0 = Release|Any CPU + {B9E9618E-CB8F-4441-8D36-D96D01F73311}.Release|x64.ActiveCfg = Release|Any CPU + {B9E9618E-CB8F-4441-8D36-D96D01F73311}.Release|x64.Build.0 = Release|Any CPU + {B9E9618E-CB8F-4441-8D36-D96D01F73311}.Release|x86.ActiveCfg = Release|Any CPU + {B9E9618E-CB8F-4441-8D36-D96D01F73311}.Release|x86.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {D4041FF2-FA9D-499C-8523-E5461983A503} = {4BC15770-BCF4-443D-B3BB-2DD0936D9B15} + {B9E9618E-CB8F-4441-8D36-D96D01F73311} = {4BC15770-BCF4-443D-B3BB-2DD0936D9B15} + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {704BD8C0-DF3C-41CA-84B7-5A2634CB9129} EndGlobalSection EndGlobal diff --git a/src/RadLine/ILineEditorPrompt.cs b/src/RadLine/ILineEditorPrompt.cs index 0a36f59..70146e0 100644 --- a/src/RadLine/ILineEditorPrompt.cs +++ b/src/RadLine/ILineEditorPrompt.cs @@ -4,6 +4,6 @@ namespace RadLine { public interface ILineEditorPrompt { - (Markup Markup, int Margin) GetPrompt(int line); + (Markup Markup, int Margin) GetPrompt(ILineEditorState state, int line); } } diff --git a/src/RadLine/ILineEditorState.cs b/src/RadLine/ILineEditorState.cs new file mode 100644 index 0000000..2ef3f20 --- /dev/null +++ b/src/RadLine/ILineEditorState.cs @@ -0,0 +1,10 @@ +namespace RadLine +{ + public interface ILineEditorState + { + public int LineIndex { get; } + public int LineCount { get; } + public bool IsFirstLine { get; } + public bool IsLastLine { get; } + } +} diff --git a/src/RadLine/ITextCompletion.cs b/src/RadLine/ITextCompletion.cs index 3edd25c..a0722d2 100644 --- a/src/RadLine/ITextCompletion.cs +++ b/src/RadLine/ITextCompletion.cs @@ -1,6 +1,4 @@ using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; -using System.Linq; namespace RadLine { @@ -8,23 +6,4 @@ namespace RadLine { public IEnumerable? GetCompletions(string prefix, string word, string suffix); } - - public static class TextCompletionExtensions - { - public static bool TryGetCompletions( - this ITextCompletion completion, - string prefix, string word, string suffix, - [NotNullWhen(true)] out string[]? result) - { - var completions = completion.GetCompletions(prefix, word, suffix); - if (completions == null || !completions.Any()) - { - result = null; - return false; - } - - result = completions.ToArray(); - return true; - } - } } diff --git a/src/RadLine/ITextCompletionExtensions.cs b/src/RadLine/ITextCompletionExtensions.cs new file mode 100644 index 0000000..3b5cbe9 --- /dev/null +++ b/src/RadLine/ITextCompletionExtensions.cs @@ -0,0 +1,24 @@ +using System.Diagnostics.CodeAnalysis; +using System.Linq; + +namespace RadLine +{ + public static class ITextCompletionExtensions + { + public static bool TryGetCompletions( + this ITextCompletion completion, + string prefix, string word, string suffix, + [NotNullWhen(true)] out string[]? result) + { + var completions = completion.GetCompletions(prefix, word, suffix); + if (completions == null || !completions.Any()) + { + result = null; + return false; + } + + result = completions.ToArray(); + return true; + } + } +} diff --git a/src/RadLine/Internal/Extensions/IntExtensions.cs b/src/RadLine/Internal/Extensions/IntExtensions.cs index 195ca74..0d80ef3 100644 --- a/src/RadLine/Internal/Extensions/IntExtensions.cs +++ b/src/RadLine/Internal/Extensions/IntExtensions.cs @@ -1,9 +1,3 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - namespace RadLine { internal static class IntExtensions diff --git a/src/RadLine/Internal/Extensions/StringExtensions.cs b/src/RadLine/Internal/Extensions/StringExtensions.cs new file mode 100644 index 0000000..ca553c8 --- /dev/null +++ b/src/RadLine/Internal/Extensions/StringExtensions.cs @@ -0,0 +1,12 @@ +namespace RadLine +{ + internal static class StringExtensions + { + public static string NormalizeNewLines(this string? text) + { + text = text?.Replace("\r\n", "\n"); + text ??= string.Empty; + return text; + } + } +} diff --git a/src/RadLine/Internal/LineBufferRenderer.cs b/src/RadLine/Internal/LineBufferRenderer.cs index 528a9ea..a9626fb 100644 --- a/src/RadLine/Internal/LineBufferRenderer.cs +++ b/src/RadLine/Internal/LineBufferRenderer.cs @@ -1,13 +1,17 @@ using System; +using System.Linq; +using System.Text; using Spectre.Console; +using Spectre.Console.Advanced; +using Spectre.Console.Rendering; namespace RadLine { internal sealed class LineBufferRenderer { private readonly IAnsiConsole _console; - private readonly AnsiRenderingStrategy _ansiRendererer; - private readonly FallbackRenderingStrategy _fallbackRender; + private readonly IHighlighterAccessor _accessor; + private bool _initialized; public LineBufferRenderer(IAnsiConsole console, IHighlighterAccessor accessor) { @@ -17,20 +21,213 @@ namespace RadLine } _console = console ?? throw new ArgumentNullException(nameof(console)); - _ansiRendererer = new AnsiRenderingStrategy(console, accessor); - _fallbackRender = new FallbackRenderingStrategy(console, accessor); + _accessor = accessor; + } + + public void Refresh(LineEditorState state) + { + if (!_console.Profile.Capabilities.Ansi) + { + throw new NotSupportedException("Terminal does not support ANSI"); + } + + var builder = new StringBuilder(); + + // First render? + if (!_initialized) + { + Initialize(state, builder); + return; + } + + // Set cursor to home position + builder.Append("\u001b[1;1H"); + + var rowIndex = 0; + var height = _console.Profile.Height; + var lineCount = state.LineCount - 1; + var middleOfList = height / 2; + var offset = (height % 2 == 0) ? 1 : 0; + var pointer = state.LineIndex; + + // Calculate the visible part + var scrollable = lineCount >= height; + if (scrollable) + { + var skip = Math.Max(0, state.LineIndex - middleOfList); + + if (lineCount - state.LineIndex < middleOfList) + { + // Pointer should be below the end of the list + var diff = middleOfList - (lineCount - state.LineIndex); + skip -= diff - offset; + pointer = middleOfList + diff - offset; + } + else + { + // Take skip into account + pointer -= skip; + } + + rowIndex = skip; + } + + // Render all lines + for (var i = 0; i < Math.Min(state.LineCount, height); i++) + { + // Set cursor to beginning of line + builder.Append("\u001b[1G"); + + // Render the line + var (prompt, margin) = state.Prompt.GetPrompt(state, rowIndex); + AppendLine(builder, state.GetBufferAt(rowIndex), prompt, margin, 0); + + // Move cursor down + builder.Append("\u001b[1E"); + rowIndex++; + } + + // Position the cursor at the right line + builder.Append("\u001b[").Append(pointer + 1).Append(";1H"); + + // Flush + _console.WriteAnsi(builder.ToString()); + + // Refresh the current line + RenderLine(state); + } + + private void Initialize(LineEditorState state, StringBuilder builder) + { + _initialized = true; + + // Everything fit inside the terminal? + if (state.LineCount < _console.Profile.Height) + { + _console.Cursor.Hide(); + + for (var i = 0; i < state.LineCount; i++) + { + var (prompt, margin) = state.Prompt.GetPrompt(state, i); + AppendLine(builder, state.GetBufferAt(i), prompt, margin, 0); + + if (i != state.LineCount - 1) + { + builder.Append(Environment.NewLine); + } + } + + _console.WriteAnsi(builder.ToString()); + _console.Cursor.Show(); + + // Move to the last line + state.Move(state.LineCount); + RenderLine(state); + } } public void RenderLine(LineEditorState state, int? cursorPosition = null) { - if (_console.Profile.Capabilities.Ansi) + if (!_console.Profile.Capabilities.Ansi) { - _ansiRendererer.Render(state, cursorPosition); + throw new NotSupportedException("Terminal does not support ANSI"); } - else + + var builder = new StringBuilder(); + + // Prepare + builder.Append("\u001b[?7l"); // Autowrap off + builder.Append("\u001b[2K"); // Clear the current line + builder.Append("\u001b[1G"); // Set cursor to beginning of line + + // Append the whole line + var (prompt, margin) = state.Prompt.GetPrompt(state, state.LineIndex); + var position = AppendLine(builder, state.Buffer, prompt, margin, cursorPosition ?? state.Buffer.CursorPosition); + + // Move the cursor to the right position + var cursorPos = position + prompt.Length + margin + 1; + builder.Append("\u001b[").Append(cursorPos).Append('G'); + + // Flush + _console.WriteAnsi(builder.ToString()); + + // Turn on auto wrap + builder.Append("\u001b[?7h"); + } + + private int? AppendLine(StringBuilder builder, LineBuffer buffer, Markup prompt, int margin, int cursorPosition) + { + // Render the prompt + builder.Append(_console.ToAnsi(prompt)); + builder.Append(new string(' ', margin)); + + // Build the buffer + var width = _console.Profile.Width - prompt.Length - margin - 1; + var (content, position) = BuildLine(buffer, width, cursorPosition); + + var output = _console.ToAnsi(Highlight(content)); + if (output.Length < width) { - _fallbackRender.Render(state, cursorPosition); + output = output.PadRight(width); } + + // Output the buffer + builder.Append(output); + + // Return the position + return position; + } + + private IRenderable Highlight(string text) + { + var highlighter = _accessor.Highlighter; + if (highlighter == null) + { + return new Text(text); + } + + var paragraph = new Paragraph(); + foreach (var token in StringTokenizer.Tokenize(text)) + { + var style = string.IsNullOrWhiteSpace(token) ? null : highlighter.Highlight(token); + paragraph.Append(token, style); + } + + return paragraph; + } + + private static (string Content, int? Cursor) BuildLine(LineBuffer buffer, int width, int position) + { + var middleOfList = width / 2; + + var skip = 0; + var take = buffer.Content.Length; + var pointer = position; + + var scrollable = buffer.Content.Length > width; + if (scrollable) + { + skip = Math.Max(0, position - middleOfList); + take = Math.Min(width, buffer.Content.Length - skip); + + if (buffer.Content.Length - position < middleOfList) + { + // Pointer should be below the end of the list + var diff = middleOfList - (buffer.Content.Length - position); + skip -= diff; + take += diff; + pointer = middleOfList + diff; + } + else + { + // Take skip into account + pointer -= skip; + } + } + + return ( + string.Concat(buffer.Content.Skip(skip).Take(take)), + pointer); } } } diff --git a/src/RadLine/Internal/LineEditorState.cs b/src/RadLine/Internal/LineEditorState.cs index 35d00f3..d0775ae 100644 --- a/src/RadLine/Internal/LineEditorState.cs +++ b/src/RadLine/Internal/LineEditorState.cs @@ -4,25 +4,49 @@ using System.Linq; namespace RadLine { - internal sealed class LineEditorState + internal sealed class LineEditorState : ILineEditorState { private readonly List _lines; - private readonly ILineEditorPrompt _prompt; private int _lineIndex; + public ILineEditorPrompt Prompt { get; } + public int LineIndex => _lineIndex; + public int LineCount => _lines.Count; public bool IsFirstLine => _lineIndex == 0; public bool IsLastLine => _lineIndex == _lines.Count - 1; - public ILineEditorPrompt Prompt => _prompt; public LineBuffer Buffer => _lines[_lineIndex]; public string Text => string.Join(Environment.NewLine, _lines.Select(x => x.Content)); public LineEditorState(ILineEditorPrompt prompt, string text) { - _lines = new List(new[] { new LineBuffer(text) }); - _prompt = prompt ?? throw new ArgumentNullException(nameof(prompt)); + _lines = new List(); _lineIndex = 0; + + Prompt = prompt ?? throw new ArgumentNullException(nameof(prompt)); + + // Add all lines + foreach (var line in text.NormalizeNewLines().Split(new[] { '\n' })) + { + _lines.Add(new LineBuffer(line)); + } + + // No lines? + if (_lines.Count == 0) + { + _lines.Add(new LineBuffer()); + } + } + + public LineBuffer GetBufferAt(int line) + { + return _lines[line]; + } + + public void Move(int line) + { + _lineIndex = Math.Max(0, Math.Min(line, LineCount - 1)); } public bool MoveUp() diff --git a/src/RadLine/Internal/LineRendererStrategy.cs b/src/RadLine/Internal/LineRendererStrategy.cs deleted file mode 100644 index 5862fd7..0000000 --- a/src/RadLine/Internal/LineRendererStrategy.cs +++ /dev/null @@ -1,71 +0,0 @@ -using System; -using System.Linq; -using Spectre.Console; -using Spectre.Console.Rendering; - -namespace RadLine -{ - internal abstract class LineRenderingStrategy - { - private readonly IHighlighterAccessor _accessor; - - protected LineRenderingStrategy(IHighlighterAccessor accessor) - { - _accessor = accessor ?? throw new ArgumentNullException(nameof(accessor)); - } - - public abstract void Render(LineEditorState state, int? cursorPosition); - - protected (string Content, int? Cursor) BuildLine(LineBuffer buffer, int width, int position) - { - var middleOfList = width / 2; - - var skip = 0; - var take = buffer.Content.Length; - var pointer = position; - - var scrollable = buffer.Content.Length > width; - if (scrollable) - { - skip = Math.Max(0, position - middleOfList); - take = Math.Min(width, buffer.Content.Length - skip); - - if (buffer.Content.Length - position < middleOfList) - { - // Pointer should be below the end of the list - var diff = middleOfList - (buffer.Content.Length - position); - skip -= diff; - take += diff; - pointer = middleOfList + diff; - } - else - { - // Take skip into account - pointer -= skip; - } - } - - return ( - string.Concat(buffer.Content.Skip(skip).Take(take)), - pointer); - } - - protected IRenderable Highlight(string text) - { - var highlighter = _accessor.Highlighter; - if (highlighter == null) - { - return new Text(text); - } - - var paragraph = new Paragraph(); - foreach (var token in StringTokenizer.Tokenize(text)) - { - var style = string.IsNullOrWhiteSpace(token) ? null : highlighter.Highlight(token); - paragraph.Append(token, style); - } - - return paragraph; - } - } -} diff --git a/src/RadLine/Internal/Rendering/AnsiRenderingStrategy.cs b/src/RadLine/Internal/Rendering/AnsiRenderingStrategy.cs deleted file mode 100644 index cd3a3ef..0000000 --- a/src/RadLine/Internal/Rendering/AnsiRenderingStrategy.cs +++ /dev/null @@ -1,51 +0,0 @@ -using System; -using System.Text; -using Spectre.Console; -using Spectre.Console.Advanced; - -namespace RadLine -{ - internal sealed class AnsiRenderingStrategy : LineRenderingStrategy - { - private readonly IAnsiConsole _console; - - public AnsiRenderingStrategy(IAnsiConsole console, IHighlighterAccessor accessor) - : base(accessor) - { - _console = console ?? throw new ArgumentNullException(nameof(console)); - } - - public override void Render(LineEditorState state, int? cursorPosition) - { - var builder = new StringBuilder(); - - var (prompt, margin) = state.Prompt.GetPrompt(state.LineIndex); - - // Prepare - builder.Append("\u001b[?7l"); // Autowrap off - builder.Append("\u001b[2K"); // Clear the current line - builder.Append("\u001b[1G"); // Set cursor to beginning of line - - // Render the prompt - builder.Append(_console.ToAnsi(prompt)); - builder.Append(new string(' ', margin)); - - // Build the buffer - var width = _console.Profile.Width - prompt.Length - margin - 1; - var (content, position) = BuildLine(state.Buffer, width, cursorPosition ?? state.Buffer.CursorPosition); - - // Output the buffer - builder.Append(_console.ToAnsi(Highlight(content))); - - // Move the cursor to the right position - var cursorPos = position + prompt.Length + margin + 1; - builder.Append("\u001b[").Append(cursorPos).Append('G'); - - // Flush - _console.WriteAnsi(builder.ToString()); - - // Turn on auto wrap - builder.Append("\u001b[?7h"); - } - } -} diff --git a/src/RadLine/Internal/Rendering/FallbackRenderingStrategy.cs b/src/RadLine/Internal/Rendering/FallbackRenderingStrategy.cs deleted file mode 100644 index 2587bff..0000000 --- a/src/RadLine/Internal/Rendering/FallbackRenderingStrategy.cs +++ /dev/null @@ -1,46 +0,0 @@ -using System; -using Spectre.Console; - -namespace RadLine -{ - internal sealed class FallbackRenderingStrategy : LineRenderingStrategy - { - private readonly IAnsiConsole _console; - - public FallbackRenderingStrategy(IAnsiConsole console, IHighlighterAccessor accessor) - : base(accessor) - { - _console = console ?? throw new ArgumentNullException(nameof(console)); - } - - public override void Render(LineEditorState state, int? cursorPosition) - { - var (prompt, margin) = state.Prompt.GetPrompt(state.LineIndex); - - // Hide the cursor - _console.Cursor.Hide(); - - // Clear the current line - Console.CursorLeft = 0; - Console.Write(new string(' ', _console.Profile.Width)); - Console.CursorLeft = 0; - - // Render the prompt - _console.Write(prompt); - _console.Write(new string(' ', margin)); - - // Build the buffer - var width = _console.Profile.Width - prompt.Length - margin - 1; - var (content, position) = BuildLine(state.Buffer, width, cursorPosition ?? state.Buffer.CursorPosition); - - // Write the buffer - _console.Write(Highlight(content)); - - // Move the cursor to the right position - Console.CursorLeft = (position ?? 0) + prompt.Length + margin; - - // Show the cursor - _console.Cursor.Show(); - } - } -} diff --git a/src/RadLine/LineEditor.cs b/src/RadLine/LineEditor.cs index d5b0c35..68b0459 100644 --- a/src/RadLine/LineEditor.cs +++ b/src/RadLine/LineEditor.cs @@ -10,10 +10,9 @@ namespace RadLine private readonly IInputSource _source; private readonly IServiceProvider? _provider; private readonly IAnsiConsole _console; - private readonly LineBufferRenderer _presenter; + private readonly LineBufferRenderer _renderer; public KeyBindings KeyBindings { get; } - public bool MultiLine { get; init; } = false; public string Text { get; init; } = string.Empty; @@ -26,11 +25,12 @@ namespace RadLine _console = terminal ?? AnsiConsole.Console; _source = source ?? new DefaultInputSource(_console); _provider = provider; - _presenter = new LineBufferRenderer(_console, this); + _renderer = new LineBufferRenderer(_console, this); KeyBindings = new KeyBindings(); KeyBindings.Add(ConsoleKey.Tab, () => new AutoCompleteCommand(AutoComplete.Next)); KeyBindings.Add(ConsoleKey.Tab, ConsoleModifiers.Control, () => new AutoCompleteCommand(AutoComplete.Previous)); + KeyBindings.Add(ConsoleKey.Backspace); KeyBindings.Add(ConsoleKey.Delete); KeyBindings.Add(ConsoleKey.Home); @@ -47,11 +47,26 @@ namespace RadLine KeyBindings.Add(ConsoleKey.Enter, ConsoleModifiers.Shift); } + public static bool IsSupported(IAnsiConsole console) + { + if (console is null) + { + throw new ArgumentNullException(nameof(console)); + } + + return + console.Profile.Out.IsTerminal && + console.Profile.Capabilities.Ansi && + console.Profile.Capabilities.Interactive; + } + public async Task ReadLine(CancellationToken cancellationToken) { var cancelled = false; var state = new LineEditorState(Prompt, Text); + _renderer.Refresh(state); + while (true) { var result = await ReadLine(state, cancellationToken).ConfigureAwait(false); @@ -87,7 +102,7 @@ namespace RadLine } } - _presenter.RenderLine(state, cursorPosition: 0); + _renderer.RenderLine(state, cursorPosition: 0); // Move the cursor to the last line while (state.MoveDown()) @@ -111,8 +126,6 @@ namespace RadLine provider.RegisterOptional(Completion); var context = new LineEditorContext(state.Buffer, provider); - _presenter.RenderLine(state); - while (true) { if (cancellationToken.IsCancellationRequested) @@ -148,7 +161,7 @@ namespace RadLine } // Render the line - _presenter.RenderLine(state); + _renderer.RenderLine(state); } } @@ -156,20 +169,33 @@ namespace RadLine { using (_console.HideCursor()) { - _presenter.RenderLine(state, cursorPosition: 0); state.AddLine(); - // Moving the cursor won't work here if we're at - // the bottom of the screen, so let's insert a new line. - _console.WriteLine(); + if (state.LineCount > _console.Profile.Height) + { + _console.Cursor.MoveDown(); + } + else + { + _console.WriteLine(); + } + + if (state.LineCount > _console.Profile.Height) + { + _renderer.Refresh(state); + } + else + { + _renderer.RenderLine(state, cursorPosition: 0); + } } } private void MoveUp(LineEditorState state) { - Move(state, state => + Move(state, (state, moveCursor) => { - if (state.MoveUp()) + if (state.MoveUp() && moveCursor) { _console.Cursor.MoveUp(); } @@ -178,9 +204,9 @@ namespace RadLine private void MoveDown(LineEditorState state) { - Move(state, state => + Move(state, (state, moveCursor) => { - if (state.MoveDown()) + if (state.MoveDown() && moveCursor) { _console.Cursor.MoveDown(); } @@ -189,34 +215,62 @@ namespace RadLine private void MoveFirst(LineEditorState state) { - Move(state, state => + Move(state, (state, moveCursor) => { while (state.MoveUp()) { - _console.Cursor.MoveUp(); + if (moveCursor) + { + _console.Cursor.MoveUp(); + } } }); } private void MoveLast(LineEditorState state) { - Move(state, state => + Move(state, (state, moveCursor) => { while (state.MoveDown()) { - _console.Cursor.MoveDown(); + if (moveCursor) + { + _console.Cursor.MoveDown(); + } } }); } - private void Move(LineEditorState state, Action action) + private void Move(LineEditorState state, Action action) { using (_console.HideCursor()) { - _presenter.RenderLine(state, cursorPosition: 0); - var position = state.Buffer.Position; - action(state); - state.Buffer.Move(position); + if (state.LineCount > _console.Profile.Height) + { + // Get the current position + var position = state.Buffer.Position; + + // Refresh everything + action(state, true); + _renderer.Refresh(state); + + // Re-render the current line at the correct position + state.Buffer.Move(position); + _renderer.RenderLine(state); + } + else + { + // Get the current position + var position = state.Buffer.Position; + + // Reset the line + _renderer.RenderLine(state, cursorPosition: 0); + action(state, true); + + // Render the current line at the correct position + state.Buffer.Move(position); + _renderer.RenderLine(state); + } } } } diff --git a/src/RadLine/Prompts/LineEditorPrompt.cs b/src/RadLine/Prompts/LineEditorPrompt.cs index 013a069..566be50 100644 --- a/src/RadLine/Prompts/LineEditorPrompt.cs +++ b/src/RadLine/Prompts/LineEditorPrompt.cs @@ -29,7 +29,7 @@ namespace RadLine } } - public (Markup Markup, int Margin) GetPrompt(int line) + public (Markup Markup, int Margin) GetPrompt(ILineEditorState state, int line) { if (line == 0) { diff --git a/src/RadLine/Prompts/LineNumberPrompt.cs b/src/RadLine/Prompts/LineNumberPrompt.cs index 783bc89..4deb98e 100644 --- a/src/RadLine/Prompts/LineNumberPrompt.cs +++ b/src/RadLine/Prompts/LineNumberPrompt.cs @@ -11,9 +11,9 @@ namespace RadLine _style = style ?? new Style(foreground: Color.Yellow, background: Color.Blue); } - public (Markup Markup, int Margin) GetPrompt(int line) + public (Markup Markup, int Margin) GetPrompt(ILineEditorState state, int line) { - return (new Markup(line.ToString("D2"), _style), 1); + return (new Markup((line + 1).ToString("D2"), _style), 1); } } } diff --git a/src/RadLine/RadLine.csproj b/src/RadLine/RadLine.csproj index 82fdba3..defa5f4 100644 --- a/src/RadLine/RadLine.csproj +++ b/src/RadLine/RadLine.csproj @@ -1,4 +1,4 @@ - + net5.0;netstandard2.0 @@ -13,7 +13,7 @@ - + all