commit c6aece9514dd4e9aa873062d1db4257329f6a0aa Author: Patrik Svensson Date: Fri May 21 15:54:54 2021 +0200 Initial commit diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json new file mode 100644 index 0000000..6cf141e --- /dev/null +++ b/.config/dotnet-tools.json @@ -0,0 +1,12 @@ +{ + "version": 1, + "isRoot": true, + "tools": { + "cake.tool": { + "version": "1.1.0", + "commands": [ + "dotnet-cake" + ] + } + } +} \ No newline at end of file diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..6399c11 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,178 @@ +root = true + +[*] +charset = utf-8 +end_of_line = CRLF +indent_style = space +indent_size = 4 +insert_final_newline = false +trim_trailing_whitespace = true + +[*.sln] +indent_style = tab + +[*.{csproj,vbproj,vcxproj,vcxproj.filters}] +indent_size = 2 + +[*.{xml,config,props,targets,nuspec,ruleset}] +indent_size = 2 + +[*.{yml,yaml}] +indent_size = 2 + +[*.json] +indent_size = 2 + +[*.md] +trim_trailing_whitespace = false + +[*.sh] +end_of_line = lf + +[*.cs] +# Sort using and Import directives with System.* appearing first +dotnet_sort_system_directives_first = true +dotnet_separate_import_directive_groups = false + +# Avoid "this." and "Me." if not necessary +dotnet_style_qualification_for_field = false:refactoring +dotnet_style_qualification_for_property = false:refactoring +dotnet_style_qualification_for_method = false:refactoring +dotnet_style_qualification_for_event = false:refactoring + +# Use language keywords instead of framework type names for type references +dotnet_style_predefined_type_for_locals_parameters_members = true:suggestion +dotnet_style_predefined_type_for_member_access = true:suggestion + +# Suggest more modern language features when available +dotnet_style_object_initializer = true:suggestion +dotnet_style_collection_initializer = true:suggestion +dotnet_style_coalesce_expression = true:suggestion +dotnet_style_null_propagation = true:suggestion +dotnet_style_explicit_tuple_names = true:suggestion + +# Non-private static fields are PascalCase +dotnet_naming_rule.non_private_static_fields_should_be_pascal_case.severity = suggestion +dotnet_naming_rule.non_private_static_fields_should_be_pascal_case.symbols = non_private_static_fields +dotnet_naming_rule.non_private_static_fields_should_be_pascal_case.style = non_private_static_field_style +dotnet_naming_symbols.non_private_static_fields.applicable_kinds = field +dotnet_naming_symbols.non_private_static_fields.applicable_accessibilities = public, protected, internal, protected_internal, private_protected +dotnet_naming_symbols.non_private_static_fields.required_modifiers = static +dotnet_naming_style.non_private_static_field_style.capitalization = pascal_case + +# Non-private readonly fields are PascalCase +dotnet_naming_rule.non_private_readonly_fields_should_be_pascal_case.severity = suggestion +dotnet_naming_rule.non_private_readonly_fields_should_be_pascal_case.symbols = non_private_readonly_fields +dotnet_naming_rule.non_private_readonly_fields_should_be_pascal_case.style = non_private_readonly_field_style +dotnet_naming_symbols.non_private_readonly_fields.applicable_kinds = field +dotnet_naming_symbols.non_private_readonly_fields.applicable_accessibilities = public, protected, internal, protected_internal, private_protected +dotnet_naming_symbols.non_private_readonly_fields.required_modifiers = readonly +dotnet_naming_style.non_private_readonly_field_style.capitalization = pascal_case + +# Constants are PascalCase +dotnet_naming_rule.constants_should_be_pascal_case.severity = suggestion +dotnet_naming_rule.constants_should_be_pascal_case.symbols = constants +dotnet_naming_rule.constants_should_be_pascal_case.style = constant_style +dotnet_naming_symbols.constants.applicable_kinds = field, local +dotnet_naming_symbols.constants.required_modifiers = const +dotnet_naming_style.constant_style.capitalization = pascal_case + +# Instance fields are camelCase and start with _ +dotnet_naming_rule.instance_fields_should_be_camel_case.severity = suggestion +dotnet_naming_rule.instance_fields_should_be_camel_case.symbols = instance_fields +dotnet_naming_rule.instance_fields_should_be_camel_case.style = instance_field_style +dotnet_naming_symbols.instance_fields.applicable_kinds = field +dotnet_naming_style.instance_field_style.capitalization = camel_case +dotnet_naming_style.instance_field_style.required_prefix = _ + +# Locals and parameters are camelCase +dotnet_naming_rule.locals_should_be_camel_case.severity = suggestion +dotnet_naming_rule.locals_should_be_camel_case.symbols = locals_and_parameters +dotnet_naming_rule.locals_should_be_camel_case.style = camel_case_style +dotnet_naming_symbols.locals_and_parameters.applicable_kinds = parameter, local +dotnet_naming_style.camel_case_style.capitalization = camel_case + +# Local functions are PascalCase +dotnet_naming_rule.local_functions_should_be_pascal_case.severity = suggestion +dotnet_naming_rule.local_functions_should_be_pascal_case.symbols = local_functions +dotnet_naming_rule.local_functions_should_be_pascal_case.style = local_function_style +dotnet_naming_symbols.local_functions.applicable_kinds = local_function +dotnet_naming_style.local_function_style.capitalization = pascal_case + +# By default, name items with PascalCase +dotnet_naming_rule.members_should_be_pascal_case.severity = suggestion +dotnet_naming_rule.members_should_be_pascal_case.symbols = all_members +dotnet_naming_rule.members_should_be_pascal_case.style = pascal_case_style +dotnet_naming_symbols.all_members.applicable_kinds = * +dotnet_naming_style.pascal_case_style.capitalization = pascal_case + +# Newline settings +csharp_new_line_before_open_brace = all +csharp_new_line_before_else = true +csharp_new_line_before_catch = true +csharp_new_line_before_finally = true +csharp_new_line_before_members_in_object_initializers = true +csharp_new_line_before_members_in_anonymous_types = true +csharp_new_line_between_query_expression_clauses = true + +# Indentation preferences +csharp_indent_block_contents = true +csharp_indent_braces = false +csharp_indent_case_contents = true +csharp_indent_case_contents_when_block = true +csharp_indent_switch_labels = true +csharp_indent_labels = flush_left + +# Prefer "var" everywhere +csharp_style_var_for_built_in_types = true:suggestion +csharp_style_var_when_type_is_apparent = true:suggestion +csharp_style_var_elsewhere = true:suggestion + +# Prefer method-like constructs to have a block body +csharp_style_expression_bodied_methods = false:none +csharp_style_expression_bodied_constructors = false:none +csharp_style_expression_bodied_operators = false:none + +# Prefer property-like constructs to have an expression-body +csharp_style_expression_bodied_properties = true:none +csharp_style_expression_bodied_indexers = true:none +csharp_style_expression_bodied_accessors = true:none + +# Suggest more modern language features when available +csharp_style_pattern_matching_over_is_with_cast_check = true:suggestion +csharp_style_pattern_matching_over_as_with_null_check = true:suggestion +csharp_style_inlined_variable_declaration = true:suggestion +csharp_style_throw_expression = true:suggestion +csharp_style_conditional_delegate_call = true:suggestion + +# Space preferences +csharp_space_after_cast = false +csharp_space_after_colon_in_inheritance_clause = true +csharp_space_after_comma = true +csharp_space_after_dot = false +csharp_space_after_keywords_in_control_flow_statements = true +csharp_space_after_semicolon_in_for_statement = true +csharp_space_around_binary_operators = before_and_after +csharp_space_around_declaration_statements = do_not_ignore +csharp_space_before_colon_in_inheritance_clause = true +csharp_space_before_comma = false +csharp_space_before_dot = false +csharp_space_before_open_square_brackets = false +csharp_space_before_semicolon_in_for_statement = false +csharp_space_between_empty_square_brackets = false +csharp_space_between_method_call_empty_parameter_list_parentheses = false +csharp_space_between_method_call_name_and_opening_parenthesis = false +csharp_space_between_method_call_parameter_list_parentheses = false +csharp_space_between_method_declaration_empty_parameter_list_parentheses = false +csharp_space_between_method_declaration_name_and_open_parenthesis = false +csharp_space_between_method_declaration_parameter_list_parentheses = false +csharp_space_between_parentheses = false +csharp_space_between_square_brackets = false + +# Blocks are allowed +csharp_prefer_braces = true:silent +csharp_preserve_single_line_blocks = true +csharp_preserve_single_line_statements = true + +# warning RS0037: PublicAPI.txt is missing '#nullable enable' +dotnet_diagnostic.RS0037.severity = none \ No newline at end of file diff --git a/.github/funding.yml b/.github/funding.yml new file mode 100644 index 0000000..49ca39c --- /dev/null +++ b/.github/funding.yml @@ -0,0 +1 @@ +github: patriksvensson \ No newline at end of file diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml new file mode 100644 index 0000000..5522d80 --- /dev/null +++ b/.github/workflows/ci.yaml @@ -0,0 +1,49 @@ +name: Continuous Integration +on: pull_request + +env: + # Set the DOTNET_SKIP_FIRST_TIME_EXPERIENCE environment variable to stop wasting time caching packages + DOTNET_SKIP_FIRST_TIME_EXPERIENCE: true + # Disable sending usage data to Microsoft + DOTNET_CLI_TELEMETRY_OPTOUT: true + +jobs: + + ################################################### + # BUILD + ################################################### + + build: + name: Build + if: "!contains(github.event.head_commit.message, 'skip-ci')" + strategy: + matrix: + kind: ['linux', 'windows', 'macOS'] + include: + - kind: linux + os: ubuntu-latest + - kind: windows + os: windows-latest + - kind: macOS + os: macos-latest + runs-on: ${{ matrix.os }} + steps: + - name: Checkout + uses: actions/checkout@v2 + with: + fetch-depth: 0 + + - name: 'Get Git tags' + run: git fetch --tags + shell: bash + + - name: Setup dotnet + uses: actions/setup-dotnet@v1 + with: + dotnet-version: 5.0.202 + + - name: Build + shell: bash + run: | + dotnet tool restore + dotnet cake \ No newline at end of file diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml new file mode 100644 index 0000000..2f0b492 --- /dev/null +++ b/.github/workflows/publish.yaml @@ -0,0 +1,88 @@ +name: Publish + +on: + push: + tags: + - '*' + branches: + - main + paths: + - 'src/**' + +env: + # Set the DOTNET_SKIP_FIRST_TIME_EXPERIENCE environment variable to stop wasting time caching packages + DOTNET_SKIP_FIRST_TIME_EXPERIENCE: true + # Disable sending usage data to Microsoft + DOTNET_CLI_TELEMETRY_OPTOUT: true + +jobs: + + ################################################### + # BUILD + ################################################### + + build: + name: Build + if: "!contains(github.event.head_commit.message, 'skip-ci')" + strategy: + matrix: + kind: ['linux', 'windows', 'macOS'] + include: + - kind: linux + os: ubuntu-latest + - kind: windows + os: windows-latest + - kind: macOS + os: macos-latest + runs-on: ${{ matrix.os }} + steps: + - name: Checkout + uses: actions/checkout@v2 + with: + fetch-depth: 0 + + - name: 'Get Git tags' + run: git fetch --tags + shell: bash + + - name: Setup dotnet + uses: actions/setup-dotnet@v1 + with: + dotnet-version: 5.0.202 + + - name: Build + shell: bash + run: | + dotnet tool restore + dotnet cake + + ################################################### + # PUBLISH + ################################################### + + publish: + name: Publish + needs: [build] + if: "!contains(github.event.head_commit.message, 'skip-ci')" + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v2 + with: + fetch-depth: 0 + + - name: 'Get Git tags' + run: git fetch --tags + shell: bash + + - name: Setup dotnet + uses: actions/setup-dotnet@v1 + with: + dotnet-version: 5.0.202 + + - name: Publish + shell: bash + run: | + dotnet tool restore + dotnet cake --target="publish" \ + --nuget-key="${{secrets.NUGET_API_KEY}}" \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8c56e1c --- /dev/null +++ b/.gitignore @@ -0,0 +1,91 @@ +# Misc folders +[Bb]in/ +[Oo]bj/ +[Tt]emp/ +[Pp]ackages/ +/.artifacts/ +/[Tt]ools/ +.idea +.DS_Store + +# Cakeup +cakeup-x86_64-latest.exe + +# .NET Core CLI +/.dotnet/ +/.packages/ +dotnet-install.sh* +*.lock.json + +# Visual Studio +.vs/ +.vscode/ +launchSettings.json +*.sln.ide/ + +# Rider +src/.idea/**/workspace.xml +src/.idea/**/tasks.xml +src/.idea/dictionaries +src/.idea/**/dataSources/ +src/.idea/**/dataSources.ids +src/.idea/**/dataSources.xml +src/.idea/**/dataSources.local.xml +src/.idea/**/sqlDataSources.xml +src/.idea/**/dynamic.xml +src/.idea/**/uiDesigner.xml + +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. + +# User-specific files +*.suo +*.user +*.sln.docstates +*.userprefs +*.GhostDoc.xml +*StyleCop.Cache + +# Build results +[Dd]ebug/ +[Rr]elease/ +x64/ +*_i.c +*_p.c +*.ilk +*.meta +*.obj +*.pch +*.pdb +*.pgc +*.pgd +*.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.log +*.vspscc +*.vssscc +.builds + +# Visual Studio profiler +*.psess +*.vsp +*.vspx + +# ReSharper is a .NET coding add-in +_ReSharper* + +# NCrunch +.*crunch*.local.xml +_NCrunch_* + +# NuGet Packages Directory +packages + +# Windows +Thumbs.db + +*.received.* \ No newline at end of file diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..2fc5457 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,46 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation. + +## Our Standards + +Examples of behavior that contributes to creating a positive environment include: + +* Using welcoming and inclusive language +* Being respectful of differing viewpoints and experiences +* Gracefully accepting constructive criticism +* Focusing on what is best for the community +* Showing empathy towards other community members + +Examples of unacceptable behavior by participants include: + +* The use of sexualized language or imagery and unwelcome sexual attention or advances +* Trolling, insulting/derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or electronic address, without explicit permission +* Other conduct which could reasonably be considered inappropriate in a professional setting + +## Our Responsibilities + +Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. + +Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. + +## Scope + +This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at hello@spectresystems.se. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. + +Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version] + +[homepage]: http://contributor-covenant.org +[version]: http://contributor-covenant.org/version/1/4/ diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..c5b332d --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2020 Patrik Svensson, Phil Scott + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..2be5866 --- /dev/null +++ b/README.md @@ -0,0 +1,24 @@ +# RadLine + +This is a preview of the RadLine library. +At this point, we will not be accepting pull requests for new functionality. + +# Usage + +See the `RadLine.Sandbox` project for usage examples. + +# Building + +``` +> dotnet tool restore +> dotnet cake +``` + +# Known issues + +* Terminal is not set in raw mode, so some key combinations might not + work in certain terminals. +* Any modifier with `ENTER` key does not register on macOS, due to a bug + in the System.Console.ReadKey implementation. We will be moving away + from using this before release. +* Lines do not update properly when moving past vertical screen buffer boundaries. \ No newline at end of file diff --git a/build.cake b/build.cake new file mode 100644 index 0000000..54bf5bb --- /dev/null +++ b/build.cake @@ -0,0 +1,79 @@ +var target = Argument("target", "Default"); +var configuration = Argument("configuration", "Release"); + +//////////////////////////////////////////////////////////////// +// Tasks + +Task("Build") + .Does(context => +{ + DotNetCoreBuild("./src/RadLine.sln", new DotNetCoreBuildSettings { + Configuration = configuration, + NoIncremental = context.HasArgument("rebuild"), + MSBuildSettings = new DotNetCoreMSBuildSettings() + .TreatAllWarningsAs(MSBuildTreatAllWarningsAs.Error) + }); +}); + +Task("Test") + .IsDependentOn("Build") + .Does(context => +{ + DotNetCoreTest("./src/RadLine.Tests/RadLine.Tests.csproj", new DotNetCoreTestSettings { + Configuration = configuration, + NoRestore = true, + NoBuild = true, + }); +}); + +Task("Package") + .IsDependentOn("Test") + .Does(context => +{ + context.CleanDirectory("./.artifacts"); + + context.DotNetCorePack($"./src/RadLine.sln", new DotNetCorePackSettings { + Configuration = configuration, + NoRestore = true, + NoBuild = true, + OutputDirectory = "./.artifacts", + MSBuildSettings = new DotNetCoreMSBuildSettings() + .TreatAllWarningsAs(MSBuildTreatAllWarningsAs.Error) + }); +}); + +Task("Publish-NuGet") + .WithCriteria(ctx => BuildSystem.IsRunningOnGitHubActions, "Not running on GitHub Actions") + .IsDependentOn("Package") + .Does(context => +{ + var apiKey = Argument("nuget-key", null); + if(string.IsNullOrWhiteSpace(apiKey)) { + throw new CakeException("No NuGet API key was provided."); + } + + // Publish to GitHub Packages + foreach(var file in context.GetFiles("./.artifacts/*.nupkg")) + { + context.Information("Publishing {0}...", file.GetFilename().FullPath); + DotNetCoreNuGetPush(file.FullPath, new DotNetCoreNuGetPushSettings + { + Source = "https://api.nuget.org/v3/index.json", + ApiKey = apiKey, + }); + } +}); + +//////////////////////////////////////////////////////////////// +// Targets + +Task("Publish") + .IsDependentOn("Publish-NuGet"); + +Task("Default") + .IsDependentOn("Package"); + +//////////////////////////////////////////////////////////////// +// Execution + +RunTarget(target) \ No newline at end of file diff --git a/global.json b/global.json new file mode 100644 index 0000000..7953fa6 --- /dev/null +++ b/global.json @@ -0,0 +1,7 @@ +{ + "projects": [ "src" ], + "sdk": { + "version": "5.0.202", + "rollForward": "latestPatch" + } +} \ No newline at end of file diff --git a/resources/gfx/large-logo.png b/resources/gfx/large-logo.png new file mode 100644 index 0000000..1869f2a Binary files /dev/null and b/resources/gfx/large-logo.png differ diff --git a/resources/gfx/medium-logo.png b/resources/gfx/medium-logo.png new file mode 100644 index 0000000..7390c3a Binary files /dev/null and b/resources/gfx/medium-logo.png differ diff --git a/resources/gfx/small-logo.png b/resources/gfx/small-logo.png new file mode 100644 index 0000000..eebe8db Binary files /dev/null and b/resources/gfx/small-logo.png differ diff --git a/src/.editorconfig b/src/.editorconfig new file mode 100644 index 0000000..dcff9ee --- /dev/null +++ b/src/.editorconfig @@ -0,0 +1,113 @@ +root = false + +[*.cs] + +# IDE0055: Fix formatting +dotnet_diagnostic.IDE0055.severity = warning + +# SA1101: Prefix local calls with this +dotnet_diagnostic.SA1101.severity = none + +# SA1633: File should have header +dotnet_diagnostic.SA1633.severity = none + +# SA1201: Elements should appear in the correct order +dotnet_diagnostic.SA1201.severity = none + +# SA1202: Public members should come before private members +dotnet_diagnostic.SA1202.severity = none + +# SA1309: Field names should not begin with underscore +dotnet_diagnostic.SA1309.severity = none + +# SA1404: Code analysis suppressions should have justification +dotnet_diagnostic.SA1404.severity = none + +# SA1516: Elements should be separated by a blank line +dotnet_diagnostic.SA1516.severity = none + +# CA1303: Do not pass literals as localized parameters +dotnet_diagnostic.CA1303.severity = none + +# CSA1204: Static members should appear before non-static members +dotnet_diagnostic.SA1204.severity = none + +# IDE0052: Remove unread private members +dotnet_diagnostic.IDE0052.severity = warning + +# IDE0063: Use simple 'using' statement +csharp_prefer_simple_using_statement = false:suggestion + +# IDE0018: Variable declaration can be inlined +dotnet_diagnostic.IDE0018.severity = warning + +# SA1625: Element documenation should not be copied and pasted +dotnet_diagnostic.SA1625.severity = none + +# IDE0005: Using directive is unnecessary +dotnet_diagnostic.IDE0005.severity = warning + +# SA1117: Parameters should be on same line or separate lines +dotnet_diagnostic.SA1117.severity = none + +# SA1404: Code analysis suppression should have justification +dotnet_diagnostic.SA1404.severity = none + +# SA1101: Prefix local calls with this +dotnet_diagnostic.SA1101.severity = none + +# SA1633: File should have header +dotnet_diagnostic.SA1633.severity = none + +# SA1649: File name should match first type name +dotnet_diagnostic.SA1649.severity = none + +# SA1402: File may only contain a single type +dotnet_diagnostic.SA1402.severity = none + +# CA1814: Prefer jagged arrays over multidimensional +dotnet_diagnostic.CA1814.severity = none + +# RCS1194: Implement exception constructors. +dotnet_diagnostic.RCS1194.severity = none + +# CA1032: Implement standard exception constructors +dotnet_diagnostic.CA1032.severity = none + +# CA1826: Do not use Enumerable methods on indexable collections. Instead use the collection directly +dotnet_diagnostic.CA1826.severity = none + +# RCS1079: Throwing of new NotImplementedException. +dotnet_diagnostic.RCS1079.severity = none # TODO: Change this back + +# RCS1057: Add empty line between declarations. +dotnet_diagnostic.RCS1057.severity = none + +# RCS1057: Validate arguments correctly +dotnet_diagnostic.RCS1227.severity = none + +# IDE0004: Remove Unnecessary Cast +dotnet_diagnostic.IDE0004.severity = warning + +# CA1810: Initialize reference type static fields inline +dotnet_diagnostic.CA1810.severity = none + +# IDE0044: Add readonly modifier +dotnet_diagnostic.IDE0044.severity = warning + +######################################################################## + +# CS1591: Missing XML comment for publicly visible type or member +dotnet_diagnostic.CS1591.severity = silent + +# SA1600: Elements should be documented +dotnet_diagnostic.SA1600.severity = none + +# RCS1102: Make class static. +dotnet_diagnostic.RCS1102.severity = none + +# SA1602: Enumeration items should be documented +dotnet_diagnostic.SA1602.severity = none + +# RCS1079: Throwing of new NotImplementedException. +dotnet_diagnostic.RCS1079.severity = none \ No newline at end of file diff --git a/src/Directory.Build.props b/src/Directory.Build.props new file mode 100644 index 0000000..3709698 --- /dev/null +++ b/src/Directory.Build.props @@ -0,0 +1,47 @@ + + + true + 9.0 + true + embedded + true + true + false + + + + true + + + + A library to read and display keyboard input. This is a preview version only and will be moved into Spectre.Console at some point. + Patrik Svensson, Phil Scott + Patrik Svensson, Phil Scott + git + https://github.com/spectreconsole/radline + small-logo.png + True + https://github.com/spectreconsole/radline + MIT + + + + true + true + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + All + + + All + + + \ No newline at end of file diff --git a/src/Directory.Build.targets b/src/Directory.Build.targets new file mode 100644 index 0000000..4b6ff93 --- /dev/null +++ b/src/Directory.Build.targets @@ -0,0 +1,8 @@ + + + + preview + normal + + + \ No newline at end of file diff --git a/src/RadLine.Sandbox/.editorconfig b/src/RadLine.Sandbox/.editorconfig new file mode 100644 index 0000000..2adf83c --- /dev/null +++ b/src/RadLine.Sandbox/.editorconfig @@ -0,0 +1,3 @@ +root = false + +[*.cs] diff --git a/src/RadLine.Sandbox/Program.cs b/src/RadLine.Sandbox/Program.cs new file mode 100644 index 0000000..ac2a4e0 --- /dev/null +++ b/src/RadLine.Sandbox/Program.cs @@ -0,0 +1,80 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Threading; +using System.Threading.Tasks; +using Spectre.Console; + +namespace RadLine.Sandbox +{ + public static class Program + { + public static async Task Main() + { + if (!Debugger.IsAttached) + { + Debugger.Launch(); + } + + var editor = new LineEditor() + { + MultiLine = true, + Text = "HELLO ABC WORLD DEF GHIJKLMN 🥰 PATRIK WAS HERE", + Prompt = new LineNumberPrompt(), + Completion = new TestCompletion(), + Highlighter = new WordHighlighter() + .AddWord("git", new Style(foreground: Color.Yellow)) + .AddWord("code", new Style(foreground: Color.Yellow)) + .AddWord("vim", new Style(foreground: Color.Yellow)) + .AddWord("init", new Style(foreground: Color.Blue)) + .AddWord("push", new Style(foreground: Color.Red)) + .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)), + }; + + // Add custom commands + editor.KeyBindings.Add(ConsoleKey.I, ConsoleModifiers.Control); + + // Read a line + var result = await editor.ReadLine(CancellationToken.None); + + // Write the buffer + AnsiConsole.WriteLine(); + AnsiConsole.Render(new Panel(result.EscapeMarkup()) + .Header("[yellow]Commit details:[/]") + .RoundedBorder()); + } + } + + public sealed class PrependSmiley : LineEditorCommand + { + public override void Execute(LineEditorContext context) + { + context.Execute(new PreviousWordCommand()); + context.Buffer.Insert(":-)"); + } + } + + public sealed class TestCompletion : ITextCompletion + { + public IEnumerable GetCompletions(string context, string word, string suffix) + { + if (string.IsNullOrWhiteSpace(context)) + { + return new[] { "git", "code", "vim" }; + } + + if (context.Equals("git ", StringComparison.Ordinal)) + { + return new[] { "init", "initialize", "push", "commit", "rebase" }; + } + + return null; + } + } +} \ No newline at end of file diff --git a/src/RadLine.Sandbox/RadLine.Sandbox.csproj b/src/RadLine.Sandbox/RadLine.Sandbox.csproj new file mode 100644 index 0000000..8a6567e --- /dev/null +++ b/src/RadLine.Sandbox/RadLine.Sandbox.csproj @@ -0,0 +1,16 @@ + + + + Exe + net5.0 + + + + + + + + + + + diff --git a/src/RadLine.Tests/Commands/AutoCompleteCommandTests.cs b/src/RadLine.Tests/Commands/AutoCompleteCommandTests.cs new file mode 100644 index 0000000..c25aff6 --- /dev/null +++ b/src/RadLine.Tests/Commands/AutoCompleteCommandTests.cs @@ -0,0 +1,140 @@ +using System; +using System.Collections.Generic; +using RadLine.Tests.Utilities; +using Shouldly; +using Xunit; + +namespace RadLine.Tests.Commands +{ + public sealed class AutoCompleteCommandTests + { + private sealed class Fixture + { + private LineEditorContext? _context; + + public LineBuffer Buffer { get; } + public Func?> AutoComplete { get; set; } = (_, _, _) => null; + + public Fixture(string? text = null) + { + Buffer = new LineBuffer(text); + } + + public void Execute(AutoComplete direction) + { + var provider = new SimpleServiceProvider(); + provider.Register(new DelegateTextCompletion(AutoComplete)); + + _context ??= new LineEditorContext(Buffer, provider); + var command = new AutoCompleteCommand(direction); + command.Execute(_context); + } + } + + public sealed class Next + { + [Fact] + public void Should_Insert_First_AutoComplete_Suggestion() + { + // Given + var fixture = new Fixture(); + fixture.Buffer.Insert("git "); + fixture.Buffer.MoveEnd(); + fixture.AutoComplete = (_, _, _) => new[] { "init" }; + + // When + fixture.Execute(AutoComplete.Next); + + // Then + fixture.Buffer.Content.ShouldBe("git init"); + } + + [Fact] + public void Should_Replace_Previous_AutoComplete_Suggestion() + { + // Given + var fixture = new Fixture(); + fixture.Buffer.Insert("git "); + fixture.Buffer.MoveEnd(); + fixture.AutoComplete = (_, _, _) => new[] { "init", "push" }; + + // When + fixture.Execute(AutoComplete.Next); + fixture.Execute(AutoComplete.Next); + + // Then + fixture.Buffer.Content.ShouldBe("git push"); + } + + [Fact] + public void Should_Replace_Previous_AutoComplete_Suggestion_If_Buffer_Has_Suffix() + { + // Given + var fixture = new Fixture(); + fixture.Buffer.Insert("git -m 'Hello World'"); + fixture.Buffer.Move(4); + fixture.AutoComplete = (_, _, _) => new[] { "init", "push" }; + + // When + fixture.Execute(AutoComplete.Next); + fixture.Execute(AutoComplete.Next); + + // Then + fixture.Buffer.Content.ShouldBe("git push -m 'Hello World'"); + } + } + + public sealed class Previous + { + [Fact] + public void Should_Insert_Last_Autocomplete_Suggestion() + { + // Given + var fixture = new Fixture(); + fixture.Buffer.Insert("git "); + fixture.Buffer.MoveEnd(); + fixture.AutoComplete = (_, _, _) => new[] { "init", "push" }; + + // When + fixture.Execute(AutoComplete.Previous); + + // Then + fixture.Buffer.Content.ShouldBe("git push"); + } + + [Fact] + public void Should_Replace_Previous_AutoComplete_Suggestion() + { + // Given + var fixture = new Fixture(); + fixture.Buffer.Insert("git "); + fixture.Buffer.MoveEnd(); + fixture.AutoComplete = (_, _, _) => new[] { "init", "push" }; + + // When + fixture.Execute(AutoComplete.Previous); + fixture.Execute(AutoComplete.Previous); + + // Then + fixture.Buffer.Content.ShouldBe("git init"); + } + + [Fact] + public void Should_Replace_Previous_AutoComplete_Suggestion_If_Buffer_Has_Suffix() + { + // Given + var fixture = new Fixture(); + fixture.Buffer.Insert("git --quiet"); + fixture.Buffer.Move(4); + fixture.AutoComplete = (_, _, _) => new[] { "init", "push" }; + + // When + fixture.Execute(AutoComplete.Previous); + fixture.Execute(AutoComplete.Previous); + + // Then + fixture.Buffer.Content.ShouldBe("git init --quiet"); + } + } + } +} diff --git a/src/RadLine.Tests/Commands/BackspaceCommandTests.cs b/src/RadLine.Tests/Commands/BackspaceCommandTests.cs new file mode 100644 index 0000000..669a809 --- /dev/null +++ b/src/RadLine.Tests/Commands/BackspaceCommandTests.cs @@ -0,0 +1,42 @@ +using Shouldly; +using Xunit; + +namespace RadLine.Tests +{ + public sealed class BackspaceCommandTests + { + [Fact] + public void Should_Remove_Previous_Character() + { + // Given + var buffer = new LineBuffer("Foo"); + var context = new LineEditorContext(buffer); + var command = new BackspaceCommand(); + + // When + command.Execute(context); + + // Then + buffer.Content.ShouldBe("Fo"); + buffer.Position.ShouldBe(2); + } + + [Fact] + public void Should_Do_Nothing_If_There_Is_Nothing_To_Remove() + { + // Given + var buffer = new LineBuffer("Foo"); + buffer.Move(0); + + var context = new LineEditorContext(buffer); + var command = new BackspaceCommand(); + + // When + command.Execute(context); + + // Then + buffer.Content.ShouldBe("Foo"); + buffer.Position.ShouldBe(0); + } + } +} diff --git a/src/RadLine.Tests/Commands/DeleteCommandTests.cs b/src/RadLine.Tests/Commands/DeleteCommandTests.cs new file mode 100644 index 0000000..4aedbc2 --- /dev/null +++ b/src/RadLine.Tests/Commands/DeleteCommandTests.cs @@ -0,0 +1,42 @@ +using Shouldly; +using Xunit; + +namespace RadLine.Tests +{ + public sealed class DeleteCommandTests + { + [Fact] + public void Should_Delete_Next_Character() + { + // Given + var buffer = new LineBuffer("Foo"); + buffer.Move(0); + + var context = new LineEditorContext(buffer); + var command = new DeleteCommand(); + + // When + command.Execute(context); + + // Then + buffer.Content.ShouldBe("oo"); + buffer.Position.ShouldBe(0); + } + + [Fact] + public void Should_Do_Nothing_If_There_Is_Nothing_To_Remove() + { + // Given + var buffer = new LineBuffer("Foo"); + var context = new LineEditorContext(buffer); + var command = new DeleteCommand(); + + // When + command.Execute(context); + + // Then + buffer.Content.ShouldBe("Foo"); + buffer.Position.ShouldBe(3); + } + } +} diff --git a/src/RadLine.Tests/Commands/InsertCommandTests.cs b/src/RadLine.Tests/Commands/InsertCommandTests.cs new file mode 100644 index 0000000..c417e76 --- /dev/null +++ b/src/RadLine.Tests/Commands/InsertCommandTests.cs @@ -0,0 +1,24 @@ +using Shouldly; +using Xunit; + +namespace RadLine.Tests +{ + public sealed class InsertCommandTests + { + [Fact] + public void Should_Insert_Text_At_Position() + { + // Given + var buffer = new LineBuffer("Foo"); + var context = new LineEditorContext(buffer); + var command = new InsertCommand('l'); + + // When + command.Execute(context); + + // Then + buffer.Content.ShouldBe("Fool"); + buffer.Position.ShouldBe(4); + } + } +} diff --git a/src/RadLine.Tests/KeyBindingsTests.cs b/src/RadLine.Tests/KeyBindingsTests.cs new file mode 100644 index 0000000..7f14808 --- /dev/null +++ b/src/RadLine.Tests/KeyBindingsTests.cs @@ -0,0 +1,109 @@ +using System; +using Shouldly; +using Xunit; + +namespace RadLine.Tests +{ + public sealed class KeyBindingsTests + { + [Fact] + public void Should_Have_No_Bindings_When_Created() + { + // Given + var bindings = new KeyBindings(); + + // When + var result = bindings.Count; + + // Then + result.ShouldBe(0); + } + + public sealed class TheAddMethod + { + [Fact] + public void Should_Add_Command() + { + // Given + var bindings = new KeyBindings(); + bindings.Add(ConsoleKey.Home); + + // When + var result = bindings.Count; + + // Then + result.ShouldBe(1); + } + } + + public sealed class TheRemoveMethod + { + [Fact] + public void Should_Remove_Command() + { + // Given + var bindings = new KeyBindings(); + bindings.Add(ConsoleKey.Home); + bindings.Remove(ConsoleKey.Home); + + // When + var command = bindings.GetCommand(ConsoleKey.Home); + + // Then + bindings.Count.ShouldBe(0); + command.ShouldBeNull(); + } + } + + public sealed class TheGetCommandMethod + { + [Fact] + public void Should_Get_Command_For_KeyBinding_Without_Modifier() + { + // Given + var bindings = new KeyBindings(); + + bindings.Add(ConsoleKey.End); + bindings.Add(ConsoleKey.Home); + + // When + var command = bindings.GetCommand(ConsoleKey.Home, null); + + // Then + command.ShouldBeOfType(); + } + + [Fact] + public void Should_Get_Command_For_KeyBinding_With_Modifier() + { + // Given + var bindings = new KeyBindings(); + + bindings.Add(ConsoleKey.Home); + bindings.Add(ConsoleKey.Home, ConsoleModifiers.Shift); + + // When + var command = bindings.GetCommand(ConsoleKey.Home, ConsoleModifiers.Shift); + + // Then + command.ShouldBeOfType(); + } + + [Fact] + public void Should_Not_Get_Command_For_KeyBinding_With_Modifier_When_No_Modifier_Was_Provided() + { + // Given + var bindings = new KeyBindings(); + + bindings.Add(ConsoleKey.End); + bindings.Add(ConsoleKey.Home, ConsoleModifiers.Shift); + + // When + var command = bindings.GetCommand(ConsoleKey.Home); + + // Then + command.ShouldBeNull(); + } + } + } +} diff --git a/src/RadLine.Tests/LineBufferTests.cs b/src/RadLine.Tests/LineBufferTests.cs new file mode 100644 index 0000000..a6bdc44 --- /dev/null +++ b/src/RadLine.Tests/LineBufferTests.cs @@ -0,0 +1,331 @@ +using Shouldly; +using Xunit; + +namespace RadLine.Tests +{ + public sealed class LineBufferTests + { + public sealed class TheMoveMethod + { + [Fact] + public void Should_Move_To_Position() + { + // Given + var buffer = new LineBuffer("FOO"); + + // When + var result = buffer.Move(1); + + // Then + result.ShouldBe(true); + buffer.Position.ShouldBe(1); + } + + [Fact] + public void Should_Not_Move_If_Already_At_Position() + { + // Given + var buffer = new LineBuffer("FOO"); + buffer.Move(3); + + // When + var result = buffer.Move(3); + + // Then + result.ShouldBe(false); + buffer.Position.ShouldBe(3); + } + } + + public sealed class TheInsertCharacterMethod + { + [Fact] + public void Should_Insert_Character() + { + // Given + var buffer = new LineBuffer(); + + // When + buffer.Insert('A'); + + // Then + buffer.Content.ShouldBe("A"); + } + + [Fact] + public void Should_Not_Move_Caret_Position() + { + // Given + var buffer = new LineBuffer(); + + // When + buffer.Insert('A'); + + // Then + buffer.Position.ShouldBe(0); + } + } + + public sealed class TheMoveEndMethod + { + [Fact] + public void Should_Move_To_End_Of_Buffer() + { + // Given + var buffer = new LineBuffer(); + buffer.Insert("ABC"); + + // When + buffer.MoveEnd(); + + // Then + buffer.Position.ShouldBe(3); + } + } + + public sealed class TheMoveRightMethod + { + [Fact] + public void Should_Move_Right_If_Caret_Is_Not_At_End_Of_Line() + { + // Given + var buffer = new LineBuffer(); + buffer.Insert('A'); + + // When + buffer.MoveRight(); + + // Then + buffer.Position.ShouldBe(1); + } + + [Fact] + public void Should_Move_Past_Grapheme_Cluster() + { + // Given + var buffer = new LineBuffer(); + buffer.Insert("😄A"); + + // When + buffer.MoveRight(); + + // Then + buffer.Position.ShouldBe(2); + } + + [Fact] + public void Should_Not_Move_Right_If_Position_Is_At_End_Of_Line() + { + // Given + var buffer = new LineBuffer(); + + // When + buffer.MoveRight(); + + // Then + buffer.Position.ShouldBe(0); + } + } + + public sealed class TheMoveHomeMethod + { + [Fact] + public void Should_Move_To_Start_Of_Buffer() + { + // Given + var buffer = new LineBuffer(); + buffer.Insert("A"); + buffer.MoveEnd(); + + // When + buffer.MoveHome(); + + // Then + buffer.Position.ShouldBe(0); + } + } + + public sealed class TheMoveToPreviousWordMethod + { + [Fact] + public void Should_Move_To_The_Previous_Word_If_Position_Is_At_End_Of_Line() + { + // Given + var buffer = new LineBuffer("Foo Bar Baz"); + + // When + buffer.MoveToPreviousWord(); + + // Then + buffer.Position.ShouldBe(8); + } + + [Fact] + public void Should_Move_To_Left_Word_Boundary_If_Position_Is_Inside_Word() + { + // Given + var buffer = new LineBuffer("Foo Bar Baz"); + buffer.MoveLeft(); + + // When + buffer.MoveToPreviousWord(); + + // Then + buffer.Position.ShouldBe(8); + } + + [Fact] + public void Should_Move_To_Previous_If_Position_Is_At_Beginning_Of_Word() + { + // Given + var buffer = new LineBuffer("Foo Bar Baz"); + buffer.Move(8); + + // When + buffer.MoveToPreviousWord(); + + // Then + buffer.Position.ShouldBe(4); + } + } + + public sealed class TheMoveToNextWordMethod + { + [Fact] + public void Should_Move_To_The_Beginning_Of_The_Next_Word() + { + // Given + var buffer = new LineBuffer("Foo Bar"); + buffer.MoveHome(); + + // When + buffer.MoveToNextWord(); + + // Then + buffer.Position.ShouldBe(4); + } + + [Fact] + public void Should_Move_To_The_End_Of_The_Last_Word_If_There_Is_No_Next_Word() + { + // Given + var buffer = new LineBuffer("Foo"); + buffer.MoveHome(); + + // When + buffer.MoveToNextWord(); + + // Then + buffer.Position.ShouldBe(3); + } + + [Fact] + public void Should_Move_To_Next_Word_If_Position_Is_Between_Two_Words() + { + // Given + var buffer = new LineBuffer("Foo Bar Baz"); + buffer.Move(3); + + // When + buffer.MoveToNextWord(); + + // Then + buffer.Position.ShouldBe(4); + } + } + + public sealed class TheMoveLeftMethod + { + [Fact] + public void Should_Move_Left_If_Position_Is_Greater_Than_Zero() + { + // Given + var buffer = new LineBuffer(); + buffer.Insert("ABC"); + buffer.MoveEnd(); + + // When + buffer.MoveLeft(); + + // Then + buffer.Position.ShouldBe(2); + } + + [Fact] + public void Should_Move_Past_Grapheme_Cluster() + { + // Given + var buffer = new LineBuffer(); + buffer.Insert("😄"); + buffer.MoveEnd(); + + // When + buffer.MoveLeft(); + + // Then + buffer.Position.ShouldBe(0); + } + + [Fact] + public void Should_Not_Move_Left_If_Position_Is_At_Beginning_Of_Line() + { + // Given + var buffer = new LineBuffer(); + + // When + buffer.MoveLeft(); + + // Then + buffer.Position.ShouldBe(0); + } + } + + public sealed class TheClearMethod + { + [Fact] + public void Should_Delete_Previous_Character() + { + // Given + var buffer = new LineBuffer(); + buffer.Insert("AB"); + + // When + var result = buffer.Clear(1, 1); + + // Then + result.ShouldBe(1); + buffer.Content.ShouldBe("A"); + } + + [Fact] + public void Should_Not_Delete_If_Index_Is_Past_Buffer_Length() + { + // Given + var buffer = new LineBuffer(); + buffer.Insert("AB"); + + // When + var result = buffer.Clear(3, 1); + + // Then + result.ShouldBe(0); + buffer.Content.ShouldBe("AB"); + } + + [Fact] + public void Should_Not_Delete_Past_Buffer() + { + // Given + var buffer = new LineBuffer(); + buffer.Insert("AB"); + + // When + var result = buffer.Clear(1, 3); + + // Then + result.ShouldBe(1); + buffer.Content.ShouldBe("A"); + } + } + } +} diff --git a/src/RadLine.Tests/RadLine.Tests.csproj b/src/RadLine.Tests/RadLine.Tests.csproj new file mode 100644 index 0000000..ad5bab9 --- /dev/null +++ b/src/RadLine.Tests/RadLine.Tests.csproj @@ -0,0 +1,31 @@ + + + + net5.0 + false + enable + + + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + diff --git a/src/RadLine.Tests/Utilities/DelegateTextCompletion.cs b/src/RadLine.Tests/Utilities/DelegateTextCompletion.cs new file mode 100644 index 0000000..7e61e0b --- /dev/null +++ b/src/RadLine.Tests/Utilities/DelegateTextCompletion.cs @@ -0,0 +1,26 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace RadLine.Tests.Utilities +{ + public sealed class DelegateTextCompletion : ITextCompletion + { + private readonly Func?> _callback; + + public DelegateTextCompletion(Func?> callback) + { + _callback = callback ?? throw new ArgumentNullException(nameof(callback)); + } + + public IEnumerable? GetCompletions(string context, string word, string suffix) + { + if (_callback == null) + { + return Enumerable.Empty(); + } + + return _callback(context, word, suffix); + } + } +} diff --git a/src/RadLine.Tests/Utilities/SimpleServiceProvider.cs b/src/RadLine.Tests/Utilities/SimpleServiceProvider.cs new file mode 100644 index 0000000..6a4d0c7 --- /dev/null +++ b/src/RadLine.Tests/Utilities/SimpleServiceProvider.cs @@ -0,0 +1,30 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace RadLine.Tests.Utilities +{ + public sealed class SimpleServiceProvider : IServiceProvider + { + private readonly Dictionary _registrations; + + public SimpleServiceProvider() + { + _registrations = new Dictionary(); + } + + public void Register(TImplementation implementation) + where TImplementation : notnull + { + _registrations[typeof(TService)] = implementation; + } + + public object? GetService(Type serviceType) + { + _registrations.TryGetValue(serviceType, out var result); + return result; + } + } +} diff --git a/src/RadLine.sln b/src/RadLine.sln new file mode 100644 index 0000000..9b51460 --- /dev/null +++ b/src/RadLine.sln @@ -0,0 +1,62 @@ + +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}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "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}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 + Release|Any CPU = Release|Any CPU + 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 + {DFF3DD16-6AE7-4FDB-807F-F2E8A3166691}.Debug|x64.ActiveCfg = Debug|Any CPU + {DFF3DD16-6AE7-4FDB-807F-F2E8A3166691}.Debug|x64.Build.0 = Debug|Any CPU + {DFF3DD16-6AE7-4FDB-807F-F2E8A3166691}.Debug|x86.ActiveCfg = Debug|Any CPU + {DFF3DD16-6AE7-4FDB-807F-F2E8A3166691}.Debug|x86.Build.0 = Debug|Any CPU + {DFF3DD16-6AE7-4FDB-807F-F2E8A3166691}.Release|Any CPU.ActiveCfg = Release|Any CPU + {DFF3DD16-6AE7-4FDB-807F-F2E8A3166691}.Release|Any CPU.Build.0 = Release|Any CPU + {DFF3DD16-6AE7-4FDB-807F-F2E8A3166691}.Release|x64.ActiveCfg = Release|Any CPU + {DFF3DD16-6AE7-4FDB-807F-F2E8A3166691}.Release|x64.Build.0 = Release|Any CPU + {DFF3DD16-6AE7-4FDB-807F-F2E8A3166691}.Release|x86.ActiveCfg = Release|Any CPU + {DFF3DD16-6AE7-4FDB-807F-F2E8A3166691}.Release|x86.Build.0 = Release|Any CPU + {5DFDEEA4-7B61-47FD-AEA0-14FB569EBE62}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5DFDEEA4-7B61-47FD-AEA0-14FB569EBE62}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5DFDEEA4-7B61-47FD-AEA0-14FB569EBE62}.Debug|x64.ActiveCfg = Debug|Any CPU + {5DFDEEA4-7B61-47FD-AEA0-14FB569EBE62}.Debug|x64.Build.0 = Debug|Any CPU + {5DFDEEA4-7B61-47FD-AEA0-14FB569EBE62}.Debug|x86.ActiveCfg = Debug|Any CPU + {5DFDEEA4-7B61-47FD-AEA0-14FB569EBE62}.Debug|x86.Build.0 = Debug|Any CPU + {5DFDEEA4-7B61-47FD-AEA0-14FB569EBE62}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5DFDEEA4-7B61-47FD-AEA0-14FB569EBE62}.Release|Any CPU.Build.0 = Release|Any CPU + {5DFDEEA4-7B61-47FD-AEA0-14FB569EBE62}.Release|x64.ActiveCfg = Release|Any CPU + {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 + EndGlobalSection +EndGlobal diff --git a/src/RadLine.v3.ncrunchsolution b/src/RadLine.v3.ncrunchsolution new file mode 100644 index 0000000..10420ac --- /dev/null +++ b/src/RadLine.v3.ncrunchsolution @@ -0,0 +1,6 @@ + + + True + True + + \ No newline at end of file diff --git a/src/RadLine/AutoComplete.cs b/src/RadLine/AutoComplete.cs new file mode 100644 index 0000000..c0f96b1 --- /dev/null +++ b/src/RadLine/AutoComplete.cs @@ -0,0 +1,8 @@ +namespace RadLine +{ + public enum AutoComplete + { + Next, + Previous, + } +} diff --git a/src/RadLine/Commands/AutoCompleteCommand.cs b/src/RadLine/Commands/AutoCompleteCommand.cs new file mode 100644 index 0000000..d454c30 --- /dev/null +++ b/src/RadLine/Commands/AutoCompleteCommand.cs @@ -0,0 +1,134 @@ +using System; + +namespace RadLine +{ + public sealed class AutoCompleteCommand : LineEditorCommand + { + private const string Position = nameof(Position); + private const string Index = nameof(Index); + + private readonly AutoComplete _kind; + + public AutoCompleteCommand(AutoComplete kind) + { + _kind = kind; + } + + public override void Execute(LineEditorContext context) + { + var completion = context.GetService(); + if (completion == null) + { + return; + } + + var originalPosition = context.Buffer.Position; + + // Get the start position of the word + var start = context.Buffer.Position; + if (context.Buffer.IsAtCharacter) + { + context.Buffer.MoveToBeginningOfWord(); + start = context.Buffer.Position; + } + else if (context.Buffer.IsAtEndOfWord) + { + context.Buffer.MoveToPreviousWord(); + start = context.Buffer.Position; + } + + // Get the end position of the word. + var end = context.Buffer.Position; + if (context.Buffer.IsAtCharacter) + { + context.Buffer.MoveToEndOfWord(); + end = context.Buffer.Position; + } + + // Not the same start position as last time? + if (context.GetState(Position, () => 0) != start) + { + // Reset + var startIndex = _kind == AutoComplete.Next ? 0 : -1; + context.SetState(Index, startIndex); + } + + // Get the prefix and word + var prefix = context.Buffer.Content.Substring(0, start); + var word = context.Buffer.Content.Substring(start, end - start); + var suffix = context.Buffer.Content.Substring(end, context.Buffer.Content.Length - end); + + // Get the completions + if (!completion.TryGetCompletions(prefix, word, suffix, out var completions)) + { + context.Buffer.Move(originalPosition); + return; + } + + // Get the index to insert + var index = GetSuggestionIndex(context, word, completions); + if (index == -1) + { + context.Buffer.Move(originalPosition); + return; + } + + // Remove the current word + if (start != end) + { + context.Buffer.Clear(start, end - start); + context.Buffer.Move(start); + } + + // Insert the completion + context.Buffer.Insert(completions[index]); + + // Move to the end of the word + context.Buffer.MoveToEndOfWord(); + + // Increase the completion index + context.SetState(Position, start); + context.SetState(Index, _kind == AutoComplete.Next ? ++index : --index); + } + + private int GetSuggestionIndex(LineEditorContext context, string word, string[] completions) + { + if (completions is null) + { + throw new ArgumentNullException(nameof(completions)); + } + + if (!string.IsNullOrWhiteSpace(word)) + { + // Try find an exact match + var index = 0; + foreach (var completion in completions) + { + if (completion.Equals(word, StringComparison.Ordinal)) + { + var newIndex = _kind == AutoComplete.Next ? index + 1 : index - 1; + return newIndex.WrapAround(0, completions.Length - 1); + } + + index++; + } + + // Try find a partial match + index = 0; + foreach (var completion in completions) + { + if (completion.StartsWith(word, StringComparison.Ordinal)) + { + return index; + } + + index++; + } + + return -1; + } + + return context.GetState(Index, () => 0).WrapAround(0, completions.Length - 1); + } + } +} diff --git a/src/RadLine/Commands/BackspaceCommand.cs b/src/RadLine/Commands/BackspaceCommand.cs new file mode 100644 index 0000000..d656dfb --- /dev/null +++ b/src/RadLine/Commands/BackspaceCommand.cs @@ -0,0 +1,14 @@ +namespace RadLine +{ + public sealed class BackspaceCommand : LineEditorCommand + { + public override void Execute(LineEditorContext context) + { + var removed = context.Buffer.Clear(context.Buffer.Position - 1, 1); + if (removed == 1) + { + context.Buffer.Move(context.Buffer.Position - 1); + } + } + } +} diff --git a/src/RadLine/Commands/Cursor/MoveDownCommand.cs b/src/RadLine/Commands/Cursor/MoveDownCommand.cs new file mode 100644 index 0000000..d236cbf --- /dev/null +++ b/src/RadLine/Commands/Cursor/MoveDownCommand.cs @@ -0,0 +1,10 @@ +namespace RadLine +{ + public sealed class MoveDownCommand : LineEditorCommand + { + public override void Execute(LineEditorContext context) + { + context.Submit(SubmitAction.MoveDown); + } + } +} diff --git a/src/RadLine/Commands/Cursor/MoveEndCommand.cs b/src/RadLine/Commands/Cursor/MoveEndCommand.cs new file mode 100644 index 0000000..fd999c0 --- /dev/null +++ b/src/RadLine/Commands/Cursor/MoveEndCommand.cs @@ -0,0 +1,10 @@ +namespace RadLine +{ + public sealed class MoveEndCommand : LineEditorCommand + { + public override void Execute(LineEditorContext context) + { + context.Buffer.MoveEnd(); + } + } +} diff --git a/src/RadLine/Commands/Cursor/MoveFirstLineCommand.cs b/src/RadLine/Commands/Cursor/MoveFirstLineCommand.cs new file mode 100644 index 0000000..9b169ab --- /dev/null +++ b/src/RadLine/Commands/Cursor/MoveFirstLineCommand.cs @@ -0,0 +1,10 @@ +namespace RadLine +{ + public sealed class MoveFirstLineCommand : LineEditorCommand + { + public override void Execute(LineEditorContext context) + { + context.Submit(SubmitAction.MoveFirst); + } + } +} diff --git a/src/RadLine/Commands/Cursor/MoveHomeCommand.cs b/src/RadLine/Commands/Cursor/MoveHomeCommand.cs new file mode 100644 index 0000000..61f1c4d --- /dev/null +++ b/src/RadLine/Commands/Cursor/MoveHomeCommand.cs @@ -0,0 +1,10 @@ +namespace RadLine +{ + public sealed class MoveHomeCommand : LineEditorCommand + { + public override void Execute(LineEditorContext context) + { + context.Buffer.MoveHome(); + } + } +} diff --git a/src/RadLine/Commands/Cursor/MoveLastLineCommand.cs b/src/RadLine/Commands/Cursor/MoveLastLineCommand.cs new file mode 100644 index 0000000..333cad4 --- /dev/null +++ b/src/RadLine/Commands/Cursor/MoveLastLineCommand.cs @@ -0,0 +1,10 @@ +namespace RadLine +{ + public sealed class MoveLastLineCommand : LineEditorCommand + { + public override void Execute(LineEditorContext context) + { + context.Submit(SubmitAction.MoveLast); + } + } +} diff --git a/src/RadLine/Commands/Cursor/MoveLeftCommand.cs b/src/RadLine/Commands/Cursor/MoveLeftCommand.cs new file mode 100644 index 0000000..d9d5db6 --- /dev/null +++ b/src/RadLine/Commands/Cursor/MoveLeftCommand.cs @@ -0,0 +1,24 @@ +using System; + +namespace RadLine +{ + public sealed class MoveLeftCommand : LineEditorCommand + { + private readonly int _count; + + public MoveLeftCommand() + { + _count = 1; + } + + public MoveLeftCommand(int count) + { + _count = Math.Max(0, count); + } + + public override void Execute(LineEditorContext context) + { + context.Buffer.MoveLeft(_count); + } + } +} diff --git a/src/RadLine/Commands/Cursor/MoveRightCommand.cs b/src/RadLine/Commands/Cursor/MoveRightCommand.cs new file mode 100644 index 0000000..e4714d3 --- /dev/null +++ b/src/RadLine/Commands/Cursor/MoveRightCommand.cs @@ -0,0 +1,24 @@ +using System; + +namespace RadLine +{ + public sealed class MoveRightCommand : LineEditorCommand + { + private readonly int _count; + + public MoveRightCommand() + { + _count = 1; + } + + public MoveRightCommand(int count) + { + _count = Math.Max(0, count); + } + + public override void Execute(LineEditorContext context) + { + context.Buffer.MoveRight(_count); + } + } +} diff --git a/src/RadLine/Commands/Cursor/MoveUpCommand.cs b/src/RadLine/Commands/Cursor/MoveUpCommand.cs new file mode 100644 index 0000000..b550b26 --- /dev/null +++ b/src/RadLine/Commands/Cursor/MoveUpCommand.cs @@ -0,0 +1,10 @@ +namespace RadLine +{ + public sealed class MoveUpCommand : LineEditorCommand + { + public override void Execute(LineEditorContext context) + { + context.Submit(SubmitAction.MoveUp); + } + } +} diff --git a/src/RadLine/Commands/Cursor/NextWordCommand.cs b/src/RadLine/Commands/Cursor/NextWordCommand.cs new file mode 100644 index 0000000..08bd529 --- /dev/null +++ b/src/RadLine/Commands/Cursor/NextWordCommand.cs @@ -0,0 +1,10 @@ +namespace RadLine +{ + public sealed class NextWordCommand : LineEditorCommand + { + public override void Execute(LineEditorContext context) + { + context.Buffer.MoveToNextWord(); + } + } +} diff --git a/src/RadLine/Commands/Cursor/PreviousWordCommand.cs b/src/RadLine/Commands/Cursor/PreviousWordCommand.cs new file mode 100644 index 0000000..8c391ad --- /dev/null +++ b/src/RadLine/Commands/Cursor/PreviousWordCommand.cs @@ -0,0 +1,10 @@ +namespace RadLine +{ + public sealed class PreviousWordCommand : LineEditorCommand + { + public override void Execute(LineEditorContext context) + { + context.Buffer.MoveToPreviousWord(); + } + } +} diff --git a/src/RadLine/Commands/DeleteCommand.cs b/src/RadLine/Commands/DeleteCommand.cs new file mode 100644 index 0000000..646033a --- /dev/null +++ b/src/RadLine/Commands/DeleteCommand.cs @@ -0,0 +1,11 @@ +namespace RadLine +{ + public sealed class DeleteCommand : LineEditorCommand + { + public override void Execute(LineEditorContext context) + { + var buffer = context.Buffer; + buffer.Clear(buffer.Position, 1); + } + } +} diff --git a/src/RadLine/Commands/InsertCommand.cs b/src/RadLine/Commands/InsertCommand.cs new file mode 100644 index 0000000..3008b04 --- /dev/null +++ b/src/RadLine/Commands/InsertCommand.cs @@ -0,0 +1,36 @@ +namespace RadLine +{ + public sealed class InsertCommand : LineEditorCommand + { + private readonly char? _character; + private readonly string? _text; + + public InsertCommand(char character) + { + _character = character; + _text = null; + } + + public InsertCommand(string text) + { + _text = text ?? string.Empty; + _character = null; + } + + public override void Execute(LineEditorContext context) + { + var buffer = context.Buffer; + + if (_character != null) + { + buffer.Insert(_character.Value); + buffer.Move(buffer.Position + 1); + } + else if (_text != null) + { + buffer.Insert(_text); + buffer.Move(buffer.Position + _text.Length); + } + } + } +} diff --git a/src/RadLine/Commands/NewLineCommand.cs b/src/RadLine/Commands/NewLineCommand.cs new file mode 100644 index 0000000..77165c1 --- /dev/null +++ b/src/RadLine/Commands/NewLineCommand.cs @@ -0,0 +1,10 @@ +namespace RadLine +{ + public sealed class NewLineCommand : LineEditorCommand + { + public override void Execute(LineEditorContext context) + { + context.Submit(SubmitAction.NewLine); + } + } +} diff --git a/src/RadLine/Commands/SubmitCommand.cs b/src/RadLine/Commands/SubmitCommand.cs new file mode 100644 index 0000000..959e5fd --- /dev/null +++ b/src/RadLine/Commands/SubmitCommand.cs @@ -0,0 +1,10 @@ +namespace RadLine +{ + public sealed class SubmitCommand : LineEditorCommand + { + public override void Execute(LineEditorContext context) + { + context.Submit(SubmitAction.Submit); + } + } +} diff --git a/src/RadLine/IHighlighter.cs b/src/RadLine/IHighlighter.cs new file mode 100644 index 0000000..25d7a33 --- /dev/null +++ b/src/RadLine/IHighlighter.cs @@ -0,0 +1,9 @@ +using Spectre.Console; + +namespace RadLine +{ + public interface IHighlighter + { + Style? Highlight(string token); + } +} diff --git a/src/RadLine/IInputSource.cs b/src/RadLine/IInputSource.cs new file mode 100644 index 0000000..15a447a --- /dev/null +++ b/src/RadLine/IInputSource.cs @@ -0,0 +1,11 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace RadLine +{ + public interface IInputSource + { + Task ReadKey(CancellationToken cancellationToken); + } +} diff --git a/src/RadLine/ILineEditorPrompt.cs b/src/RadLine/ILineEditorPrompt.cs new file mode 100644 index 0000000..0a36f59 --- /dev/null +++ b/src/RadLine/ILineEditorPrompt.cs @@ -0,0 +1,9 @@ +using Spectre.Console; + +namespace RadLine +{ + public interface ILineEditorPrompt + { + (Markup Markup, int Margin) GetPrompt(int line); + } +} diff --git a/src/RadLine/ITextCompletion.cs b/src/RadLine/ITextCompletion.cs new file mode 100644 index 0000000..3edd25c --- /dev/null +++ b/src/RadLine/ITextCompletion.cs @@ -0,0 +1,30 @@ +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; + +namespace RadLine +{ + public interface ITextCompletion + { + 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/Internal/DefaultInputSource.cs b/src/RadLine/Internal/DefaultInputSource.cs new file mode 100644 index 0000000..bb1d75f --- /dev/null +++ b/src/RadLine/Internal/DefaultInputSource.cs @@ -0,0 +1,43 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Spectre.Console; + +namespace RadLine +{ + internal sealed class DefaultInputSource : IInputSource + { + private readonly IAnsiConsole _console; + + public DefaultInputSource(IAnsiConsole console) + { + _console = console ?? throw new ArgumentNullException(nameof(console)); + } + + public async Task ReadKey(CancellationToken cancellationToken) + { + if (!_console.Profile.Out.IsTerminal + || !_console.Profile.Capabilities.Interactive) + { + throw new NotSupportedException("Only interactive terminals are supported as input source"); + } + + while (true) + { + if (cancellationToken.IsCancellationRequested) + { + return null; + } + + if (Console.KeyAvailable) + { + break; + } + + await Task.Delay(5, cancellationToken).ConfigureAwait(false); + } + + return Console.ReadKey(true); + } + } +} diff --git a/src/RadLine/Internal/DefaultServiceProvider.cs b/src/RadLine/Internal/DefaultServiceProvider.cs new file mode 100644 index 0000000..670109f --- /dev/null +++ b/src/RadLine/Internal/DefaultServiceProvider.cs @@ -0,0 +1,40 @@ +using System; +using System.Collections.Generic; + +namespace RadLine +{ + internal sealed class DefaultServiceProvider : IServiceProvider + { + private readonly IServiceProvider? _provider; + private readonly Dictionary _registrations; + + public DefaultServiceProvider(IServiceProvider? provider) + { + _provider = provider; + _registrations = new Dictionary(); + } + + public void RegisterOptional(TImplementation? implementation) + { + if (implementation != null) + { + _registrations[typeof(TService)] = implementation; + } + } + + public object? GetService(Type serviceType) + { + if (_provider != null) + { + var result = _provider.GetService(serviceType); + if (result != null) + { + return result; + } + } + + _registrations.TryGetValue(serviceType, out var registration); + return registration; + } + } +} diff --git a/src/RadLine/Internal/Extensions/AnsiConsoleExtensions.cs b/src/RadLine/Internal/Extensions/AnsiConsoleExtensions.cs new file mode 100644 index 0000000..ee4bc42 --- /dev/null +++ b/src/RadLine/Internal/Extensions/AnsiConsoleExtensions.cs @@ -0,0 +1,40 @@ +using System; +using Spectre.Console; + +namespace RadLine +{ + internal static class AnsiConsoleExtensions + { + public static IDisposable HideCursor(this IAnsiConsole console) + { + if (console is null) + { + throw new ArgumentNullException(nameof(console)); + } + + return new CursorHider(console); + } + + private sealed class CursorHider : IDisposable + { + private readonly IAnsiConsole _console; + + public CursorHider(IAnsiConsole console) + { + _console = console ?? throw new ArgumentNullException(nameof(console)); + _console.Cursor.Hide(); + } + + ~CursorHider() + { + throw new InvalidOperationException("CursorHider: Dispose was never called"); + } + + public void Dispose() + { + GC.SuppressFinalize(this); + _console.Cursor.Show(); + } + } + } +} diff --git a/src/RadLine/Internal/Extensions/IServiceProviderExtensions.cs b/src/RadLine/Internal/Extensions/IServiceProviderExtensions.cs new file mode 100644 index 0000000..c8fd6bb --- /dev/null +++ b/src/RadLine/Internal/Extensions/IServiceProviderExtensions.cs @@ -0,0 +1,19 @@ +using System; + +namespace RadLine +{ + internal static class IServiceProviderExtensions + { + public static T? GetService(this IServiceProvider provider) + where T : class + { + if (provider is null) + { + throw new ArgumentNullException(nameof(provider)); + } + + var result = provider.GetService(typeof(T)); + return result as T; + } + } +} diff --git a/src/RadLine/Internal/IHighlighterAccessor.cs b/src/RadLine/Internal/IHighlighterAccessor.cs new file mode 100644 index 0000000..dbb8048 --- /dev/null +++ b/src/RadLine/Internal/IHighlighterAccessor.cs @@ -0,0 +1,7 @@ +namespace RadLine +{ + internal interface IHighlighterAccessor + { + IHighlighter? Highlighter { get; } + } +} diff --git a/src/RadLine/Internal/IntExtensions.cs b/src/RadLine/Internal/IntExtensions.cs new file mode 100644 index 0000000..195ca74 --- /dev/null +++ b/src/RadLine/Internal/IntExtensions.cs @@ -0,0 +1,41 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace RadLine +{ + internal static class IntExtensions + { + public static int Clamp(this int value, int min, int max) + { + if (value < min) + { + return min; + } + + if (value > max) + { + return max; + } + + return value; + } + + public static int WrapAround(this int value, int min, int max) + { + if (value < min) + { + return max; + } + + if (value > max) + { + return min; + } + + return value; + } + } +} diff --git a/src/RadLine/Internal/KeyBinding.cs b/src/RadLine/Internal/KeyBinding.cs new file mode 100644 index 0000000..9651888 --- /dev/null +++ b/src/RadLine/Internal/KeyBinding.cs @@ -0,0 +1,16 @@ +using System; + +namespace RadLine +{ + internal sealed class KeyBinding + { + public ConsoleKey Key { get; } + public ConsoleModifiers? Modifiers { get; } + + public KeyBinding(ConsoleKey key, ConsoleModifiers? modifiers = null) + { + Key = key; + Modifiers = modifiers; + } + } +} diff --git a/src/RadLine/Internal/KeyBindingComparer.cs b/src/RadLine/Internal/KeyBindingComparer.cs new file mode 100644 index 0000000..dd6732a --- /dev/null +++ b/src/RadLine/Internal/KeyBindingComparer.cs @@ -0,0 +1,38 @@ +using System; +using System.Collections.Generic; + +namespace RadLine +{ + internal sealed class KeyBindingComparer : IEqualityComparer + { + public bool Equals(KeyBinding? x, KeyBinding? y) + { + if (x == null && y == null) + { + return true; + } + + if (x == null || y == null) + { + return false; + } + + return x.Key == y.Key && x.Modifiers == y.Modifiers; + } + + public int GetHashCode(KeyBinding obj) + { +#if NET5_0 + return HashCode.Combine(obj.Key, obj.Modifiers); +#else + unchecked + { + int hash = 17; + hash = (hash * 23) + (int)obj.Key; + hash = (hash * 23) + (obj?.Modifiers != null ? (int)obj.Modifiers : 0); + return hash; + } +#endif + } + } +} diff --git a/src/RadLine/Internal/LineBufferRenderer.cs b/src/RadLine/Internal/LineBufferRenderer.cs new file mode 100644 index 0000000..528a9ea --- /dev/null +++ b/src/RadLine/Internal/LineBufferRenderer.cs @@ -0,0 +1,36 @@ +using System; +using Spectre.Console; + +namespace RadLine +{ + internal sealed class LineBufferRenderer + { + private readonly IAnsiConsole _console; + private readonly AnsiRenderingStrategy _ansiRendererer; + private readonly FallbackRenderingStrategy _fallbackRender; + + public LineBufferRenderer(IAnsiConsole console, IHighlighterAccessor accessor) + { + if (accessor is null) + { + throw new ArgumentNullException(nameof(accessor)); + } + + _console = console ?? throw new ArgumentNullException(nameof(console)); + _ansiRendererer = new AnsiRenderingStrategy(console, accessor); + _fallbackRender = new FallbackRenderingStrategy(console, accessor); + } + + public void RenderLine(LineEditorState state, int? cursorPosition = null) + { + if (_console.Profile.Capabilities.Ansi) + { + _ansiRendererer.Render(state, cursorPosition); + } + else + { + _fallbackRender.Render(state, cursorPosition); + } + } + } +} diff --git a/src/RadLine/Internal/LineEditorState.cs b/src/RadLine/Internal/LineEditorState.cs new file mode 100644 index 0000000..35d00f3 --- /dev/null +++ b/src/RadLine/Internal/LineEditorState.cs @@ -0,0 +1,56 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace RadLine +{ + internal sealed class LineEditorState + { + private readonly List _lines; + private readonly ILineEditorPrompt _prompt; + private int _lineIndex; + + public int LineIndex => _lineIndex; + 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)); + _lineIndex = 0; + } + + public bool MoveUp() + { + if (_lineIndex > 0) + { + _lineIndex--; + return true; + } + + return false; + } + + public bool MoveDown() + { + if (_lineIndex < _lines.Count - 1) + { + _lineIndex++; + return true; + } + + return false; + } + + public void AddLine() + { + _lines.Add(new LineBuffer()); + _lineIndex++; + } + } +} diff --git a/src/RadLine/Internal/LineRendererStrategy.cs b/src/RadLine/Internal/LineRendererStrategy.cs new file mode 100644 index 0000000..5862fd7 --- /dev/null +++ b/src/RadLine/Internal/LineRendererStrategy.cs @@ -0,0 +1,71 @@ +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 new file mode 100644 index 0000000..cd3a3ef --- /dev/null +++ b/src/RadLine/Internal/Rendering/AnsiRenderingStrategy.cs @@ -0,0 +1,51 @@ +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 new file mode 100644 index 0000000..2587bff --- /dev/null +++ b/src/RadLine/Internal/Rendering/FallbackRenderingStrategy.cs @@ -0,0 +1,46 @@ +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/Internal/StringTokenizer.cs b/src/RadLine/Internal/StringTokenizer.cs new file mode 100644 index 0000000..34d38e3 --- /dev/null +++ b/src/RadLine/Internal/StringTokenizer.cs @@ -0,0 +1,34 @@ +using System.Collections.Generic; + +namespace RadLine +{ + internal static class StringTokenizer + { + public static IEnumerable Tokenize(string text) + { + var buffer = string.Empty; + foreach (var character in text) + { + if (char.IsLetterOrDigit(character)) + { + buffer += character; + } + else + { + if (buffer.Length > 0) + { + yield return buffer; + buffer = string.Empty; + } + + yield return new string(character, 1); + } + } + + if (buffer.Length > 0) + { + yield return buffer; + } + } + } +} diff --git a/src/RadLine/KeyBindings.cs b/src/RadLine/KeyBindings.cs new file mode 100644 index 0000000..9050274 --- /dev/null +++ b/src/RadLine/KeyBindings.cs @@ -0,0 +1,69 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace RadLine +{ + public sealed class KeyBindings + { + private readonly Dictionary> _bindings; + + public int Count => _bindings.Count; + + public KeyBindings() + { + _bindings = new Dictionary>(new KeyBindingComparer()); + } + + internal void Add(KeyBinding binding, Func command) + { + if (binding is null) + { + throw new ArgumentNullException(nameof(binding)); + } + + _bindings[binding] = command; + } + + internal void Remove(KeyBinding binding) + { + if (binding is null) + { + throw new ArgumentNullException(nameof(binding)); + } + + _bindings.Remove(binding); + } + + public void Clear() + { + _bindings.Clear(); + } + + public LineEditorCommand? GetCommand(ConsoleKey key, ConsoleModifiers? modifiers = null) + { + var candidates = _bindings.Keys as IEnumerable; + + if (modifiers != null && modifiers != 0) + { + candidates = _bindings.Keys.Where(b => b.Modifiers == modifiers); + } + + var result = candidates.FirstOrDefault(x => x.Key == key); + if (result != null) + { + if (modifiers == null && result.Modifiers != null) + { + return null; + } + + if (result != null && _bindings.TryGetValue(result, out var factory)) + { + return factory(); + } + } + + return null; + } + } +} diff --git a/src/RadLine/KeyBindingsExtensions.cs b/src/RadLine/KeyBindingsExtensions.cs new file mode 100644 index 0000000..617a785 --- /dev/null +++ b/src/RadLine/KeyBindingsExtensions.cs @@ -0,0 +1,50 @@ +using System; + +namespace RadLine +{ + public static class KeyBindingsExtensions + { + public static void Add(this KeyBindings bindings, ConsoleKey key, ConsoleModifiers? modifiers = null) + where TCommand : LineEditorCommand, new() + { + if (bindings is null) + { + throw new ArgumentNullException(nameof(bindings)); + } + + bindings.Add(new KeyBinding(key, modifiers), () => new TCommand()); + } + + public static void Add(this KeyBindings bindings, ConsoleKey key, Func func) + where TCommand : LineEditorCommand + { + if (bindings is null) + { + throw new ArgumentNullException(nameof(bindings)); + } + + bindings.Add(new KeyBinding(key), () => func()); + } + + public static void Add(this KeyBindings bindings, ConsoleKey key, ConsoleModifiers modifiers, Func func) + where TCommand : LineEditorCommand + { + if (bindings is null) + { + throw new ArgumentNullException(nameof(bindings)); + } + + bindings.Add(new KeyBinding(key, modifiers), () => func()); + } + + public static void Remove(this KeyBindings bindings, ConsoleKey key, ConsoleModifiers? modifiers = null) + { + if (bindings is null) + { + throw new ArgumentNullException(nameof(bindings)); + } + + bindings.Remove(new KeyBinding(key, modifiers)); + } + } +} diff --git a/src/RadLine/LineBuffer.cs b/src/RadLine/LineBuffer.cs new file mode 100644 index 0000000..8ad034b --- /dev/null +++ b/src/RadLine/LineBuffer.cs @@ -0,0 +1,161 @@ +using System; +using System.Globalization; +using System.Linq; + +namespace RadLine +{ + public sealed class LineBuffer + { + private string _buffer; + private int _position; + + public int Position => _position; + public int Length => _buffer.Length; + public string Content => _buffer; + + public bool AtBeginning => Position == 0; + public bool AtEnd => Position == Content.Length; + + public bool IsAtCharacter + { + get + { + if (Length == 0) + { + return false; + } + + if (AtEnd) + { + return false; + } + + return !char.IsWhiteSpace(_buffer[_position]); + } + } + + public bool IsAtBeginningOfWord + { + get + { + if (Length == 0) + { + return false; + } + + if (_position == 0) + { + return !char.IsWhiteSpace(_buffer[0]); + } + + return char.IsWhiteSpace(_buffer[_position - 1]); + } + } + + public bool IsAtEndOfWord + { + get + { + if (Length == 0) + { + return false; + } + + if (_position == 0) + { + return false; + } + + return !char.IsWhiteSpace(_buffer[_position - 1]); + } + } + + // TODO: Right now, this only returns the position in the line buffer. + // This is OK for western alphabets and most emojis which consist + // of a single surrogate pair, but everything else will be wrong. + public int CursorPosition => _position; + + public LineBuffer(string? content = null) + { + _buffer = content ?? string.Empty; + _position = _buffer.Length; + } + + public bool Move(int position) + { + if (position == _position) + { + return false; + } + + var movingLeft = position < _position; + _position = MoveToPosition(position, movingLeft); + + return true; + } + + public void Insert(char character) + { + _buffer = _buffer.Insert(_position, character.ToString()); + } + + public void Insert(string text) + { + _buffer = _buffer.Insert(_position, text); + } + + public int Clear(int index, int count) + { + if (index < 0) + { + return 0; + } + + if (index > _buffer.Length - 1) + { + return 0; + } + + var length = _buffer.Length; + _buffer = _buffer.Remove(Math.Max(0, index), Math.Min(count, _buffer.Length - index)); + return Math.Max(length - _buffer.Length, 0); + } + + private int MoveToPosition(int position, bool movingLeft) + { + if (position <= 0) + { + return 0; + } + else if (position >= _buffer.Length) + { + return _buffer.Length; + } + + var indices = StringInfo.ParseCombiningCharacters(_buffer); + + if (movingLeft) + { + foreach (var e in indices.Reverse()) + { + if (e <= position) + { + return e; + } + } + } + else + { + foreach (var e in indices) + { + if (e >= position) + { + return e; + } + } + } + + throw new InvalidOperationException("Could not find position in buffer"); + } + } +} diff --git a/src/RadLine/LineBufferExtensions.cs b/src/RadLine/LineBufferExtensions.cs new file mode 100644 index 0000000..11607c1 --- /dev/null +++ b/src/RadLine/LineBufferExtensions.cs @@ -0,0 +1,143 @@ +namespace RadLine +{ + public static class LineBufferExtensions + { + public static bool MoveLeft(this LineBuffer buffer, int count = 1) + { + return buffer.Move(buffer.Position - count); + } + + public static bool MoveRight(this LineBuffer buffer, int count = 1) + { + return buffer.Move(buffer.Position + count); + } + + public static bool MoveHome(this LineBuffer buffer) + { + return buffer.Move(0); + } + + public static bool MoveEnd(this LineBuffer buffer) + { + return buffer.Move(buffer.Length); + } + + public static bool MoveToPreviousWord(this LineBuffer buffer) + { + var position = buffer.Position; + + if (buffer.IsAtCharacter) + { + if (buffer.IsAtBeginningOfWord) + { + // At the beginning of a word. + // Move to the left side of the word. + buffer.MoveLeft(); + + // Move left until we encounter a new word + while (buffer.Position > 0 && !buffer.IsAtCharacter) + { + buffer.MoveLeft(); + } + } + } + else + { + // Move until we encounter a word + while (buffer.Position > 0 && !buffer.IsAtCharacter) + { + buffer.MoveLeft(); + } + } + + // Move to the beginning of the word + buffer.MoveToBeginningOfWord(); + + // Return whether or not we moved + return position != buffer.Position; + } + + public static bool MoveToNextWord(this LineBuffer buffer) + { + var position = buffer.Position; + + if (buffer.IsAtCharacter) + { + // Move to the end of the word + buffer.MoveToEndOfWord(); + + // Move past any space + while (!buffer.AtEnd && !buffer.IsAtCharacter) + { + buffer.MoveRight(); + } + } + else + { + // Move past any space + while (!buffer.AtEnd && !buffer.IsAtCharacter) + { + buffer.MoveRight(); + } + } + + // Return whether or not we moved + return position != buffer.Position; + } + + public static bool MoveToEndOfWord(this LineBuffer buffer) + { + if (buffer.AtEnd) + { + return false; + } + + // Not at a word? Do nothing + if (!buffer.IsAtCharacter) + { + return false; + } + + var position = buffer.Position; + + // Move until we find whitespace + while (!buffer.AtEnd && buffer.IsAtCharacter) + { + buffer.MoveRight(); + } + + // Return whether or not we moved + return position != buffer.Position; + } + + public static bool MoveToBeginningOfWord(this LineBuffer buffer) + { + if (buffer.Position == 0) + { + return false; + } + + // Not at a word? Do nothing + if (!buffer.IsAtCharacter) + { + return false; + } + + var position = buffer.Position; + + // Move until previous character is whitespace + while (buffer.Position > 0) + { + if (char.IsWhiteSpace(buffer.Content[buffer.Position - 1])) + { + break; + } + + buffer.MoveLeft(); + } + + // Return whether or not we moved + return position != buffer.Position; + } + } +} diff --git a/src/RadLine/LineEditor.cs b/src/RadLine/LineEditor.cs new file mode 100644 index 0000000..d5b0c35 --- /dev/null +++ b/src/RadLine/LineEditor.cs @@ -0,0 +1,223 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Spectre.Console; + +namespace RadLine +{ + public sealed class LineEditor : IHighlighterAccessor + { + private readonly IInputSource _source; + private readonly IServiceProvider? _provider; + private readonly IAnsiConsole _console; + private readonly LineBufferRenderer _presenter; + + public KeyBindings KeyBindings { get; } + + public bool MultiLine { get; init; } = false; + public string Text { get; init; } = string.Empty; + + public ILineEditorPrompt Prompt { get; init; } = new LineEditorPrompt("[yellow]>[/]"); + public ITextCompletion? Completion { get; init; } + public IHighlighter? Highlighter { get; init; } + + public LineEditor(IAnsiConsole? terminal = null, IInputSource? source = null, IServiceProvider? provider = null) + { + _console = terminal ?? AnsiConsole.Console; + _source = source ?? new DefaultInputSource(_console); + _provider = provider; + _presenter = 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); + KeyBindings.Add(ConsoleKey.End); + KeyBindings.Add(ConsoleKey.UpArrow); + KeyBindings.Add(ConsoleKey.DownArrow); + KeyBindings.Add(ConsoleKey.PageUp); + KeyBindings.Add(ConsoleKey.PageDown); + KeyBindings.Add(ConsoleKey.LeftArrow); + KeyBindings.Add(ConsoleKey.RightArrow); + KeyBindings.Add(ConsoleKey.LeftArrow, ConsoleModifiers.Control); + KeyBindings.Add(ConsoleKey.RightArrow, ConsoleModifiers.Control); + KeyBindings.Add(ConsoleKey.Enter); + KeyBindings.Add(ConsoleKey.Enter, ConsoleModifiers.Shift); + } + + public async Task ReadLine(CancellationToken cancellationToken) + { + var cancelled = false; + var state = new LineEditorState(Prompt, Text); + + while (true) + { + var result = await ReadLine(state, cancellationToken).ConfigureAwait(false); + + if (result.Result == SubmitAction.Cancel) + { + cancelled = true; + break; + } + else if (result.Result == SubmitAction.Submit) + { + break; + } + else if (result.Result == SubmitAction.NewLine && MultiLine && state.IsLastLine) + { + AddNewLine(state); + } + else if (result.Result == SubmitAction.MoveUp && MultiLine) + { + MoveUp(state); + } + else if (result.Result == SubmitAction.MoveDown && MultiLine) + { + MoveDown(state); + } + else if (result.Result == SubmitAction.MoveFirst && MultiLine) + { + MoveFirst(state); + } + else if (result.Result == SubmitAction.MoveLast && MultiLine) + { + MoveLast(state); + } + } + + _presenter.RenderLine(state, cursorPosition: 0); + + // Move the cursor to the last line + while (state.MoveDown()) + { + _console.Cursor.MoveDown(); + } + + // 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(); + + // Return all the lines + return cancelled ? null : state.Text.TrimEnd('\r', '\n'); + } + + private async Task<(LineBuffer Buffer, SubmitAction Result)> ReadLine( + LineEditorState state, + CancellationToken cancellationToken) + { + var provider = new DefaultServiceProvider(_provider); + provider.RegisterOptional(Completion); + var context = new LineEditorContext(state.Buffer, provider); + + _presenter.RenderLine(state); + + while (true) + { + if (cancellationToken.IsCancellationRequested) + { + return (state.Buffer, SubmitAction.Cancel); + } + + // Get command + var command = default(LineEditorCommand); + var key = await _source.ReadKey(cancellationToken).ConfigureAwait(false); + if (key != null) + { + if (key.Value.KeyChar != 0 && !char.IsControl(key.Value.KeyChar)) + { + command = new InsertCommand(key.Value.KeyChar); + } + else + { + command = KeyBindings.GetCommand(key.Value.Key, key.Value.Modifiers); + } + } + + // Execute command + if (command != null) + { + context.Execute(command); + } + + // Time to exit? + if (context.Result != null) + { + return (state.Buffer, context.Result.Value); + } + + // Render the line + _presenter.RenderLine(state); + } + } + + private void AddNewLine(LineEditorState state) + { + 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(); + } + } + + private void MoveUp(LineEditorState state) + { + Move(state, state => + { + if (state.MoveUp()) + { + _console.Cursor.MoveUp(); + } + }); + } + + private void MoveDown(LineEditorState state) + { + Move(state, state => + { + if (state.MoveDown()) + { + _console.Cursor.MoveDown(); + } + }); + } + + private void MoveFirst(LineEditorState state) + { + Move(state, state => + { + while (state.MoveUp()) + { + _console.Cursor.MoveUp(); + } + }); + } + + private void MoveLast(LineEditorState state) + { + Move(state, state => + { + while (state.MoveDown()) + { + _console.Cursor.MoveDown(); + } + }); + } + + 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); + } + } + } +} diff --git a/src/RadLine/LineEditorCommand.cs b/src/RadLine/LineEditorCommand.cs new file mode 100644 index 0000000..adbc415 --- /dev/null +++ b/src/RadLine/LineEditorCommand.cs @@ -0,0 +1,7 @@ +namespace RadLine +{ + public abstract class LineEditorCommand + { + public abstract void Execute(LineEditorContext context); + } +} diff --git a/src/RadLine/LineEditorContext.cs b/src/RadLine/LineEditorContext.cs new file mode 100644 index 0000000..f0d7c16 --- /dev/null +++ b/src/RadLine/LineEditorContext.cs @@ -0,0 +1,68 @@ +using System; +using System.Collections.Generic; + +namespace RadLine +{ + public sealed class LineEditorContext : IServiceProvider + { + private readonly Dictionary _state; + private readonly IServiceProvider? _provider; + + public LineBuffer Buffer { get; } + internal SubmitAction? Result { get; private set; } + + public LineEditorContext(LineBuffer buffer, IServiceProvider? provider = null) + { + _state = new Dictionary(StringComparer.OrdinalIgnoreCase); + _provider = provider; + + Buffer = buffer ?? throw new ArgumentNullException(nameof(buffer)); + } + + public object? GetService(Type serviceType) + { + if (_provider != null) + { + return _provider.GetService(serviceType); + } + + return null; + } + + public void Execute(LineEditorCommand command) + { + if (Result != null) + { + // Don't execute any command + // if we're suppose to exit the + // current context. + return; + } + + command.Execute(this); + } + + public void SetState(string key, T value) + { + _state[key] = value; + } + + public T GetState(string key, Func defaultValue) + { + if (_state.TryGetValue(key, out var value)) + { + if (value is T typedValue) + { + return typedValue; + } + } + + return defaultValue(); + } + + public void Submit(SubmitAction action) + { + Result = action; + } + } +} diff --git a/src/RadLine/Prompts/LineEditorPrompt.cs b/src/RadLine/Prompts/LineEditorPrompt.cs new file mode 100644 index 0000000..013a069 --- /dev/null +++ b/src/RadLine/Prompts/LineEditorPrompt.cs @@ -0,0 +1,42 @@ +using System; +using Spectre.Console; + +namespace RadLine +{ + public sealed class LineEditorPrompt : ILineEditorPrompt + { + private readonly Markup _prompt; + private readonly Markup? _more; + + public LineEditorPrompt(string prompt, string? more = null) + { + if (prompt is null) + { + throw new ArgumentNullException(nameof(prompt)); + } + + _prompt = new Markup(prompt); + _more = more != null ? new Markup(more) : null; + + if (_prompt.Lines > 1) + { + throw new ArgumentException("Prompt cannot contain line breaks", nameof(prompt)); + } + + if (_more?.Lines > 1) + { + throw new ArgumentException("Prompt cannot contain line breaks", nameof(more)); + } + } + + public (Markup Markup, int Margin) GetPrompt(int line) + { + if (line == 0) + { + return (_prompt, 1); + } + + return (_more ?? _prompt, 1); + } + } +} diff --git a/src/RadLine/Prompts/LineNumberPrompt.cs b/src/RadLine/Prompts/LineNumberPrompt.cs new file mode 100644 index 0000000..783bc89 --- /dev/null +++ b/src/RadLine/Prompts/LineNumberPrompt.cs @@ -0,0 +1,19 @@ +using Spectre.Console; + +namespace RadLine +{ + public sealed class LineNumberPrompt : ILineEditorPrompt + { + private readonly Style _style; + + public LineNumberPrompt(Style? style = null) + { + _style = style ?? new Style(foreground: Color.Yellow, background: Color.Blue); + } + + public (Markup Markup, int Margin) GetPrompt(int line) + { + return (new Markup(line.ToString("D2"), _style), 1); + } + } +} diff --git a/src/RadLine/RadLine.csproj b/src/RadLine/RadLine.csproj new file mode 100644 index 0000000..978581b --- /dev/null +++ b/src/RadLine/RadLine.csproj @@ -0,0 +1,23 @@ + + + + net5.0;netstandard2.0 + enable + true + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + diff --git a/src/RadLine/SubmitAction.cs b/src/RadLine/SubmitAction.cs new file mode 100644 index 0000000..63c4def --- /dev/null +++ b/src/RadLine/SubmitAction.cs @@ -0,0 +1,13 @@ +namespace RadLine +{ + public enum SubmitAction + { + Cancel, + Submit, + NewLine, + MoveDown, + MoveUp, + MoveFirst, + MoveLast, + } +} diff --git a/src/RadLine/WordHighlighter.cs b/src/RadLine/WordHighlighter.cs new file mode 100644 index 0000000..cef5fd5 --- /dev/null +++ b/src/RadLine/WordHighlighter.cs @@ -0,0 +1,28 @@ +using System; +using System.Collections.Generic; +using Spectre.Console; + +namespace RadLine +{ + public sealed class WordHighlighter : IHighlighter + { + private readonly Dictionary _words; + + public WordHighlighter(StringComparer? comparer = null) + { + _words = new Dictionary(comparer ?? StringComparer.OrdinalIgnoreCase); + } + + public WordHighlighter AddWord(string word, Style style) + { + _words[word] = style; + return this; + } + + Style? IHighlighter.Highlight(string token) + { + _words.TryGetValue(token, out var style); + return style; + } + } +} diff --git a/src/stylecop.json b/src/stylecop.json new file mode 100644 index 0000000..4a7219f --- /dev/null +++ b/src/stylecop.json @@ -0,0 +1,26 @@ +{ + "$schema": "https://raw.githubusercontent.com/DotNetAnalyzers/StyleCopAnalyzers/master/StyleCop.Analyzers/StyleCop.Analyzers/Settings/stylecop.schema.json", + "settings": { + "documentationRules": { + "documentExposedElements": true, + "documentInternalElements": false, + "documentPrivateElements": false, + "documentPrivateFields": false + }, + "layoutRules": { + "newlineAtEndOfFile": "allow", + "allowConsecutiveUsings": true + }, + "orderingRules": { + "usingDirectivesPlacement": "outsideNamespace", + "systemUsingDirectivesFirst": true, + "elementOrder": [ + "kind", + "accessibility", + "constant", + "static", + "readonly" + ] + } + } +} \ No newline at end of file