Compare commits
35
Commits
5ecc46f7a3
...
v0.1.5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
08192d317e | ||
|
|
3bf2018a62 | ||
|
|
cb39198e0a | ||
|
|
a777b71b1a | ||
|
|
71a8d3e6fb | ||
|
|
aee93a4ea3 | ||
|
|
d6886eae7c | ||
|
|
86c0085472 | ||
|
|
8f1b02770c | ||
|
|
af43aec773 | ||
|
|
b2873139ae | ||
|
|
7ecad05e1c | ||
|
|
163b86d239 | ||
|
|
1fc9765a3d | ||
|
|
0c2f0bd618 | ||
|
|
bb19306168 | ||
|
|
81684b55c9 | ||
|
|
1053932cfd | ||
|
|
71ad8879ac | ||
|
|
1a91d837cf | ||
|
|
7e4e569d53 | ||
|
|
778fc1034b | ||
|
|
d5dc228ebe | ||
|
|
6da32504f9 | ||
|
|
4aa2a1e023 | ||
|
|
ef13f4c7ef | ||
|
|
0b121827ed | ||
|
|
9e0b00dd98 | ||
|
|
4a2ebd9a70 | ||
|
|
e99f30fcbc | ||
|
|
7c5675f69f | ||
|
|
253373d47e | ||
|
|
b581d560b0 | ||
|
|
8a19cb6a9c | ||
|
|
2af494d2f6 |
+335
@@ -0,0 +1,335 @@
|
||||
# Unified code style for CursorLang.
|
||||
# The rules are understood by Visual Studio, Rider, VS Code and `dotnet format`.
|
||||
# https://learn.microsoft.com/dotnet/fundamentals/code-analysis/code-style-rule-options
|
||||
|
||||
root = true
|
||||
|
||||
# ==========================================================================
|
||||
# Common to all files
|
||||
# ==========================================================================
|
||||
[*]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
indent_style = space
|
||||
indent_size = 4
|
||||
|
||||
# Build scripts are read by Windows PowerShell 5.1: without a BOM it takes the
|
||||
# file for ANSI and mangles the Russian text in the messages
|
||||
[*.{ps1,psm1,psd1}]
|
||||
charset = utf-8-bom
|
||||
|
||||
# In Markdown two trailing spaces at the end of a line mean a line break
|
||||
[*.{md,markdown}]
|
||||
trim_trailing_whitespace = false
|
||||
|
||||
[*.{json,jsonc,yml,yaml}]
|
||||
indent_size = 2
|
||||
|
||||
# Project files, XAML and other XML keep the same four spaces as the code
|
||||
[*.{csproj,vbproj,props,targets,sln,slnx,xaml,xml,resx,manifest,config,appxmanifest,nuspec}]
|
||||
indent_size = 4
|
||||
|
||||
# Files written by the SDK rather than by a human
|
||||
[{obj,bin,artifacts}/**]
|
||||
generated_code = true
|
||||
|
||||
# ==========================================================================
|
||||
# C#
|
||||
# ==========================================================================
|
||||
[*.cs]
|
||||
|
||||
# A guideline for wrapping lines, not a hard limit
|
||||
max_line_length = 120
|
||||
|
||||
#### using directives ####
|
||||
|
||||
# System comes first and in a group separate from the rest
|
||||
dotnet_sort_system_directives_first = true
|
||||
dotnet_separate_import_directive_groups = false
|
||||
|
||||
# using outside the namespace — more familiar and compatible with file-scoped namespace
|
||||
csharp_using_directive_placement = outside_namespace:warning
|
||||
dotnet_diagnostic.IDE0005.severity = warning
|
||||
|
||||
#### File layout ####
|
||||
|
||||
csharp_style_namespace_declarations = file_scoped:warning
|
||||
|
||||
# The namespace mirrors the file path from the project root
|
||||
dotnet_diagnostic.IDE0130.severity = warning
|
||||
|
||||
#### this. and Me. ####
|
||||
|
||||
dotnet_style_qualification_for_field = false:warning
|
||||
dotnet_style_qualification_for_property = false:warning
|
||||
dotnet_style_qualification_for_method = false:warning
|
||||
dotnet_style_qualification_for_event = false:warning
|
||||
|
||||
#### Language keywords instead of BCL type names ####
|
||||
|
||||
dotnet_style_predefined_type_for_locals_parameters_members = true:warning
|
||||
dotnet_style_predefined_type_for_member_access = true:warning
|
||||
|
||||
#### Modifiers ####
|
||||
|
||||
dotnet_style_require_accessibility_modifiers = for_non_interface_members:warning
|
||||
csharp_preferred_modifier_order = public,private,protected,internal,file,static,extern,new,virtual,abstract,sealed,override,readonly,unsafe,required,volatile,async:suggestion
|
||||
dotnet_style_readonly_field = true:warning
|
||||
csharp_prefer_static_local_function = true:suggestion
|
||||
csharp_prefer_static_anonymous_function = true:suggestion
|
||||
|
||||
#### var and explicit types ####
|
||||
# The type is written explicitly, except where it is already visible on the right of the equals sign
|
||||
|
||||
csharp_style_var_for_built_in_types = false:suggestion
|
||||
csharp_style_var_when_type_is_apparent = true:suggestion
|
||||
csharp_style_var_elsewhere = false:suggestion
|
||||
|
||||
#### Expression-bodied members ####
|
||||
# Allowed but not enforced: for long expressions a block reads better
|
||||
|
||||
csharp_style_expression_bodied_methods = when_on_single_line:silent
|
||||
csharp_style_expression_bodied_constructors = when_on_single_line:silent
|
||||
csharp_style_expression_bodied_operators = when_on_single_line:silent
|
||||
csharp_style_expression_bodied_properties = true:silent
|
||||
csharp_style_expression_bodied_indexers = true:silent
|
||||
csharp_style_expression_bodied_accessors = true:silent
|
||||
csharp_style_expression_bodied_lambdas = true:silent
|
||||
csharp_style_expression_bodied_local_functions = when_on_single_line:silent
|
||||
|
||||
#### Pattern matching and null checks ####
|
||||
|
||||
csharp_style_pattern_matching_over_is_with_cast_check = true:warning
|
||||
csharp_style_pattern_matching_over_as_with_null_check = true:warning
|
||||
csharp_style_prefer_switch_expression = true:suggestion
|
||||
csharp_style_prefer_pattern_matching = true:suggestion
|
||||
csharp_style_prefer_not_pattern = true:warning
|
||||
csharp_style_prefer_extended_property_pattern = true:suggestion
|
||||
csharp_style_conditional_delegate_call = true:warning
|
||||
csharp_style_throw_expression = true:suggestion
|
||||
|
||||
dotnet_style_coalesce_expression = true:warning
|
||||
dotnet_style_null_propagation = true:warning
|
||||
dotnet_style_prefer_is_null_check_over_reference_equality_method = true:warning
|
||||
|
||||
#### Expressions ####
|
||||
|
||||
dotnet_style_object_initializer = true:suggestion
|
||||
dotnet_style_collection_initializer = true:suggestion
|
||||
dotnet_style_prefer_collection_expression = when_types_loosely_match:suggestion
|
||||
dotnet_style_explicit_tuple_names = true:warning
|
||||
dotnet_style_prefer_inferred_tuple_names = true:suggestion
|
||||
dotnet_style_prefer_inferred_anonymous_type_member_names = true:suggestion
|
||||
dotnet_style_prefer_auto_properties = true:silent
|
||||
dotnet_style_prefer_conditional_expression_over_assignment = true:silent
|
||||
dotnet_style_prefer_conditional_expression_over_return = true:silent
|
||||
dotnet_style_prefer_compound_assignment = true:suggestion
|
||||
dotnet_style_prefer_simplified_boolean_expressions = true:suggestion
|
||||
dotnet_style_prefer_simplified_interpolation = true:suggestion
|
||||
dotnet_style_operator_placement_when_wrapping = beginning_of_line
|
||||
csharp_style_prefer_index_operator = true:suggestion
|
||||
csharp_style_prefer_range_operator = true:suggestion
|
||||
csharp_style_implicit_object_creation_when_type_is_apparent = true:suggestion
|
||||
csharp_prefer_simple_default_expression = true:suggestion
|
||||
csharp_style_deconstructed_variable_declaration = true:suggestion
|
||||
csharp_style_inlined_variable_declaration = true:suggestion
|
||||
csharp_style_unused_value_assignment_preference = discard_variable:suggestion
|
||||
csharp_style_unused_value_expression_statement_preference = discard_variable:silent
|
||||
csharp_prefer_simple_using_statement = true:suggestion
|
||||
csharp_style_prefer_utf8_string_literals = true:suggestion
|
||||
csharp_style_prefer_readonly_struct = true:suggestion
|
||||
csharp_style_prefer_readonly_struct_member = true:suggestion
|
||||
csharp_style_prefer_primary_constructors = false:silent
|
||||
|
||||
#### Braces and blocks ####
|
||||
|
||||
# Braces are always used, even around a single line
|
||||
csharp_prefer_braces = true:warning
|
||||
csharp_style_prefer_top_level_statements = false:silent
|
||||
|
||||
# Parentheses that clarify the order of operations are welcome. The hint is
|
||||
# silent: parentheses around operations of the same precedence also count as
|
||||
# redundant, and in the coordinate calculations they are there on purpose
|
||||
dotnet_style_parentheses_in_arithmetic_binary_operators = always_for_clarity:silent
|
||||
dotnet_style_parentheses_in_relational_binary_operators = always_for_clarity:silent
|
||||
dotnet_style_parentheses_in_other_binary_operators = always_for_clarity:silent
|
||||
dotnet_style_parentheses_in_other_operators = never_if_unnecessary:silent
|
||||
|
||||
#### Unused code ####
|
||||
|
||||
dotnet_code_quality_unused_parameters = non_public:suggestion
|
||||
dotnet_remove_unnecessary_suppression_exclusions = none
|
||||
csharp_style_prefer_method_group_conversion = true:silent
|
||||
|
||||
# ==========================================================================
|
||||
# C#: formatting
|
||||
# ==========================================================================
|
||||
|
||||
#### Line breaks before a brace — Allman style ####
|
||||
|
||||
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 ####
|
||||
|
||||
csharp_indent_case_contents = true
|
||||
csharp_indent_case_contents_when_block = false
|
||||
csharp_indent_switch_labels = true
|
||||
csharp_indent_labels = one_less_than_current
|
||||
csharp_indent_block_contents = true
|
||||
csharp_indent_braces = false
|
||||
|
||||
#### Spacing ####
|
||||
|
||||
csharp_space_after_cast = false
|
||||
csharp_space_after_keywords_in_control_flow_statements = true
|
||||
csharp_space_before_colon_in_inheritance_clause = true
|
||||
csharp_space_after_colon_in_inheritance_clause = true
|
||||
csharp_space_around_binary_operators = before_and_after
|
||||
csharp_space_between_method_declaration_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_call_parameter_list_parentheses = false
|
||||
csharp_space_between_method_call_empty_parameter_list_parentheses = false
|
||||
csharp_space_between_method_call_name_and_opening_parenthesis = false
|
||||
csharp_space_after_comma = true
|
||||
csharp_space_before_comma = false
|
||||
csharp_space_after_dot = false
|
||||
csharp_space_before_dot = false
|
||||
csharp_space_after_semicolon_in_for_statement = true
|
||||
csharp_space_before_semicolon_in_for_statement = false
|
||||
csharp_space_around_declaration_statements = false
|
||||
csharp_space_before_open_square_brackets = false
|
||||
csharp_space_between_empty_square_brackets = false
|
||||
csharp_space_between_square_brackets = false
|
||||
|
||||
#### Line wrapping ####
|
||||
|
||||
# Every statement and every member on its own line
|
||||
csharp_preserve_single_line_statements = false
|
||||
csharp_preserve_single_line_blocks = true
|
||||
|
||||
# ==========================================================================
|
||||
# C#: naming
|
||||
# The rules are checked top to bottom, the first matching one wins
|
||||
# ==========================================================================
|
||||
|
||||
#### Naming styles ####
|
||||
|
||||
dotnet_naming_style.pascal_case.capitalization = pascal_case
|
||||
|
||||
dotnet_naming_style.camel_case.capitalization = camel_case
|
||||
|
||||
dotnet_naming_style.underscore_camel_case.capitalization = camel_case
|
||||
dotnet_naming_style.underscore_camel_case.required_prefix = _
|
||||
|
||||
dotnet_naming_style.i_pascal_case.capitalization = pascal_case
|
||||
dotnet_naming_style.i_pascal_case.required_prefix = I
|
||||
|
||||
dotnet_naming_style.t_pascal_case.capitalization = pascal_case
|
||||
dotnet_naming_style.t_pascal_case.required_prefix = T
|
||||
|
||||
#### Interfaces: IStartupService ####
|
||||
|
||||
dotnet_naming_symbols.interfaces.applicable_kinds = interface
|
||||
dotnet_naming_symbols.interfaces.applicable_accessibilities = *
|
||||
|
||||
dotnet_naming_rule.interfaces_are_i_pascal_case.symbols = interfaces
|
||||
dotnet_naming_rule.interfaces_are_i_pascal_case.style = i_pascal_case
|
||||
dotnet_naming_rule.interfaces_are_i_pascal_case.severity = warning
|
||||
|
||||
#### Type parameters: TValue ####
|
||||
|
||||
dotnet_naming_symbols.type_parameters.applicable_kinds = type_parameter
|
||||
dotnet_naming_symbols.type_parameters.applicable_accessibilities = *
|
||||
|
||||
dotnet_naming_rule.type_parameters_are_t_pascal_case.symbols = type_parameters
|
||||
dotnet_naming_rule.type_parameters_are_t_pascal_case.style = t_pascal_case
|
||||
dotnet_naming_rule.type_parameters_are_t_pascal_case.severity = warning
|
||||
|
||||
#### Types and members: PascalCase ####
|
||||
|
||||
dotnet_naming_symbols.types.applicable_kinds = class,struct,enum,delegate
|
||||
dotnet_naming_symbols.types.applicable_accessibilities = *
|
||||
|
||||
dotnet_naming_rule.types_are_pascal_case.symbols = types
|
||||
dotnet_naming_rule.types_are_pascal_case.style = pascal_case
|
||||
dotnet_naming_rule.types_are_pascal_case.severity = warning
|
||||
|
||||
dotnet_naming_symbols.non_field_members.applicable_kinds = method,property,event,local_function
|
||||
dotnet_naming_symbols.non_field_members.applicable_accessibilities = *
|
||||
|
||||
dotnet_naming_rule.non_field_members_are_pascal_case.symbols = non_field_members
|
||||
dotnet_naming_rule.non_field_members_are_pascal_case.style = pascal_case
|
||||
dotnet_naming_rule.non_field_members_are_pascal_case.severity = warning
|
||||
|
||||
#### Constants and static fields: PascalCase ####
|
||||
# Comes before the rule about private fields, otherwise they would fall under _camelCase
|
||||
|
||||
dotnet_naming_symbols.constants.applicable_kinds = field
|
||||
dotnet_naming_symbols.constants.applicable_accessibilities = *
|
||||
dotnet_naming_symbols.constants.required_modifiers = const
|
||||
|
||||
dotnet_naming_rule.constants_are_pascal_case.symbols = constants
|
||||
dotnet_naming_rule.constants_are_pascal_case.style = pascal_case
|
||||
dotnet_naming_rule.constants_are_pascal_case.severity = suggestion
|
||||
|
||||
dotnet_naming_symbols.static_readonly_fields.applicable_kinds = field
|
||||
dotnet_naming_symbols.static_readonly_fields.applicable_accessibilities = *
|
||||
dotnet_naming_symbols.static_readonly_fields.required_modifiers = static,readonly
|
||||
|
||||
dotnet_naming_rule.static_readonly_fields_are_pascal_case.symbols = static_readonly_fields
|
||||
dotnet_naming_rule.static_readonly_fields_are_pascal_case.style = pascal_case
|
||||
dotnet_naming_rule.static_readonly_fields_are_pascal_case.severity = suggestion
|
||||
|
||||
#### Public fields: PascalCase ####
|
||||
|
||||
dotnet_naming_symbols.public_fields.applicable_kinds = field
|
||||
dotnet_naming_symbols.public_fields.applicable_accessibilities = public,internal,protected,protected_internal
|
||||
|
||||
dotnet_naming_rule.public_fields_are_pascal_case.symbols = public_fields
|
||||
dotnet_naming_rule.public_fields_are_pascal_case.style = pascal_case
|
||||
dotnet_naming_rule.public_fields_are_pascal_case.severity = warning
|
||||
|
||||
#### Private fields: _camelCase ####
|
||||
|
||||
dotnet_naming_symbols.private_fields.applicable_kinds = field
|
||||
dotnet_naming_symbols.private_fields.applicable_accessibilities = private,private_protected
|
||||
|
||||
dotnet_naming_rule.private_fields_are_underscore_camel_case.symbols = private_fields
|
||||
dotnet_naming_rule.private_fields_are_underscore_camel_case.style = underscore_camel_case
|
||||
dotnet_naming_rule.private_fields_are_underscore_camel_case.severity = suggestion
|
||||
|
||||
#### Parameters and local variables: camelCase ####
|
||||
|
||||
dotnet_naming_symbols.parameters_and_locals.applicable_kinds = parameter,local
|
||||
dotnet_naming_symbols.parameters_and_locals.applicable_accessibilities = *
|
||||
|
||||
dotnet_naming_rule.parameters_and_locals_are_camel_case.symbols = parameters_and_locals
|
||||
dotnet_naming_rule.parameters_and_locals_are_camel_case.style = camel_case
|
||||
dotnet_naming_rule.parameters_and_locals_are_camel_case.severity = suggestion
|
||||
|
||||
# ==========================================================================
|
||||
# Tests
|
||||
# ==========================================================================
|
||||
[CursorLang.Tests/**/*.cs]
|
||||
|
||||
# A test name is a sentence with underscores instead of spaces:
|
||||
# in the run report it is read by a human, not called by other code
|
||||
dotnet_diagnostic.IDE1006.severity = none
|
||||
|
||||
# ==========================================================================
|
||||
# Win32 and WinRT wrappers
|
||||
# ==========================================================================
|
||||
[CursorLang/Interop/*.cs]
|
||||
|
||||
# Function, constant and field names repeat the Windows documentation: they are
|
||||
# easier to search for in the code when written the same way as in the API description
|
||||
dotnet_diagnostic.IDE1006.severity = none
|
||||
@@ -0,0 +1,65 @@
|
||||
# Text files are stored and checked out with LF line endings — the same as
|
||||
# .editorconfig describes them. Otherwise some files arrive with CRLF and edits
|
||||
# in them look like the whole file was rewritten
|
||||
* text=auto eol=lf
|
||||
|
||||
# ==========================================================================
|
||||
# Git LFS
|
||||
# ==========================================================================
|
||||
# Git stores binary files in full for every version: a redrawn icon settles in
|
||||
# the history together with all its previous shapes. LFS keeps only a pointer
|
||||
# in the history and fetches the files themselves on checkout.
|
||||
#
|
||||
# Working with the repository requires git-lfs installed and a one-time
|
||||
# `git lfs install`. Without it the working copy will contain text pointer
|
||||
# files instead of the images.
|
||||
#
|
||||
# The -text flag is mandatory here: it forbids Git from touching the line
|
||||
# endings that are allowed for everything else above.
|
||||
|
||||
# Application icon and MSIX package logos. They are drawn by
|
||||
# Packaging\New-Assets.ps1, and every run of the script produces whole new files
|
||||
*.png filter=lfs diff=lfs merge=lfs -text
|
||||
*.ico filter=lfs diff=lfs merge=lfs -text
|
||||
*.jpg filter=lfs diff=lfs merge=lfs -text
|
||||
*.jpeg filter=lfs diff=lfs merge=lfs -text
|
||||
*.gif filter=lfs diff=lfs merge=lfs -text
|
||||
*.bmp filter=lfs diff=lfs merge=lfs -text
|
||||
*.tif filter=lfs diff=lfs merge=lfs -text
|
||||
*.tiff filter=lfs diff=lfs merge=lfs -text
|
||||
*.webp filter=lfs diff=lfs merge=lfs -text
|
||||
|
||||
# Windows packages and installers. The build puts them into artifacts\, where
|
||||
# Git does not pick them up, but a released version sometimes has to be attached
|
||||
# to a tag — they run into tens of megabytes, and their only place is LFS
|
||||
*.msix filter=lfs diff=lfs merge=lfs -text
|
||||
*.msixbundle filter=lfs diff=lfs merge=lfs -text
|
||||
*.appx filter=lfs diff=lfs merge=lfs -text
|
||||
*.appxbundle filter=lfs diff=lfs merge=lfs -text
|
||||
*.appxupload filter=lfs diff=lfs merge=lfs -text
|
||||
*.msi filter=lfs diff=lfs merge=lfs -text
|
||||
*.msp filter=lfs diff=lfs merge=lfs -text
|
||||
*.cab filter=lfs diff=lfs merge=lfs -text
|
||||
|
||||
# Prebuilt binaries, in case one has to be attached to the repository
|
||||
*.dll filter=lfs diff=lfs merge=lfs -text
|
||||
*.exe filter=lfs diff=lfs merge=lfs -text
|
||||
|
||||
# Fonts and archives
|
||||
*.ttf filter=lfs diff=lfs merge=lfs -text
|
||||
*.otf filter=lfs diff=lfs merge=lfs -text
|
||||
*.woff filter=lfs diff=lfs merge=lfs -text
|
||||
*.woff2 filter=lfs diff=lfs merge=lfs -text
|
||||
*.zip filter=lfs diff=lfs merge=lfs -text
|
||||
*.7z filter=lfs diff=lfs merge=lfs -text
|
||||
|
||||
# ==========================================================================
|
||||
# Binary, but not in LFS
|
||||
# ==========================================================================
|
||||
# Signing keys never reach the repository — .gitignore keeps them out. The mark
|
||||
# is left in case such a file still ends up in the working copy: there is no
|
||||
# point in showing its contents in a diff
|
||||
*.pfx binary
|
||||
*.snk binary
|
||||
*.cer binary
|
||||
*.p12 binary
|
||||
@@ -0,0 +1,82 @@
|
||||
# The check every pull request goes through: the solution builds and the tests pass.
|
||||
#
|
||||
# The runner has to be a Windows one with the .NET 10 SDK: the application is
|
||||
# WPF, so neither the build nor the tests happen anywhere else. The tests raise
|
||||
# real windows and ask Windows for the foreground one, so the runner has to work
|
||||
# in an interactive desktop session — as a service in session 0 the end-to-end
|
||||
# checks have no window to wait for.
|
||||
name: Pull request
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- master
|
||||
|
||||
# A new push into the branch makes the previous run pointless
|
||||
concurrency:
|
||||
group: pull-request-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: pwsh
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: windows-x64
|
||||
|
||||
steps:
|
||||
- name: Check out the sources
|
||||
uses: actions/checkout@v4
|
||||
|
||||
# The exe icon lives in Git LFS, and without it the checkout leaves a text
|
||||
# pointer that the build cannot read as an icon.
|
||||
#
|
||||
# The objects are fetched here rather than by `lfs: true` on the checkout:
|
||||
# that way they arrive over a request the LFS endpoint accepts. See the
|
||||
# comment on the header below
|
||||
- name: Fetch the LFS objects
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
# actions/checkout leaves its own token in the config as an
|
||||
# http.<server>/.extraheader, and git-lfs sends that header on to the
|
||||
# LFS endpoint, which turns down the token of a workflow: every object
|
||||
# comes back 401 and the fetch gives up. The repository is public and
|
||||
# its LFS objects are readable without a token at all, so the header
|
||||
# simply goes. A private repository would need credentials of its own
|
||||
# in lfs.url instead
|
||||
$keys = git config --local --list --name-only | Where-Object { $_ -like '*.extraheader' }
|
||||
foreach ($key in $keys) { git config --local --unset-all $key }
|
||||
|
||||
git lfs pull
|
||||
if ($LASTEXITCODE -ne 0) { throw "git lfs pull ended with exit code $LASTEXITCODE." }
|
||||
|
||||
# A pointer left in place of a file shows itself much later and in a
|
||||
# way that is hard to read back: the build breaks on the icon
|
||||
$pointers = git lfs ls-files --name-only |
|
||||
Where-Object { (Get-Content $_ -TotalCount 1) -like 'version https://git-lfs*' }
|
||||
|
||||
if ($pointers) {
|
||||
throw "Git LFS left pointers instead of files: $($pointers -join ', ')."
|
||||
}
|
||||
|
||||
- name: Show the toolchain
|
||||
run: dotnet --info
|
||||
|
||||
- name: Restore
|
||||
run: dotnet restore CursorLang.sln --nologo
|
||||
|
||||
- name: Build
|
||||
run: dotnet build CursorLang.sln --configuration Release --no-restore --nologo
|
||||
|
||||
# The tests take the application from bin\Release, which is why the whole
|
||||
# run is a Release one: in Debug they would find no executable and skip
|
||||
# themselves — a green run that checked nothing
|
||||
- name: Test
|
||||
run: >
|
||||
dotnet test CursorLang.sln
|
||||
--configuration Release
|
||||
--no-build
|
||||
--nologo
|
||||
--settings CursorLang.Tests/coverage.runsettings
|
||||
@@ -0,0 +1,166 @@
|
||||
# The release: a tag of the form v1.2.3 builds the solution, runs the tests and
|
||||
# packs the MSIX with the version taken from the tag — three numbers of the tag
|
||||
# and a zero the Store keeps for itself.
|
||||
#
|
||||
# The same requirements to the runner as in pull-request.yml apply: Windows, the
|
||||
# .NET 10 SDK and an interactive desktop session for the tests. makeappx comes
|
||||
# with a NuGet package (Packaging\Tools\SdkTools.csproj), so the Windows SDK does
|
||||
# not have to be installed.
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: pwsh
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: windows-x64
|
||||
|
||||
steps:
|
||||
- name: Check out the sources
|
||||
uses: actions/checkout@v4
|
||||
|
||||
# The exe icon and the MSIX logos live in Git LFS, and without them the
|
||||
# checkout leaves text pointers in their place — the build fails on the
|
||||
# icon and the package would carry broken logos.
|
||||
#
|
||||
# They are fetched here rather than by `lfs: true` on the checkout: that
|
||||
# way the objects arrive over a request the LFS endpoint accepts. See the
|
||||
# comment on the header below
|
||||
- name: Fetch the LFS objects
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
# actions/checkout leaves its own token in the config as an
|
||||
# http.<server>/.extraheader, and git-lfs sends that header on to the
|
||||
# LFS endpoint, which turns down the token of a workflow: every object
|
||||
# comes back 401 and the fetch gives up. The repository is public and
|
||||
# its LFS objects are readable without a token at all, so the header
|
||||
# simply goes. A private repository would need credentials of its own
|
||||
# in lfs.url instead
|
||||
$keys = git config --local --list --name-only | Where-Object { $_ -like '*.extraheader' }
|
||||
foreach ($key in $keys) { git config --local --unset-all $key }
|
||||
|
||||
git lfs pull
|
||||
if ($LASTEXITCODE -ne 0) { throw "git lfs pull ended with exit code $LASTEXITCODE." }
|
||||
|
||||
# A pointer left in place of a file shows itself much later and in a
|
||||
# way that is hard to read back: the icon breaks the build, and a logo
|
||||
# quietly ends up broken inside the package
|
||||
$pointers = git lfs ls-files --name-only |
|
||||
Where-Object { (Get-Content $_ -TotalCount 1) -like 'version https://git-lfs*' }
|
||||
|
||||
if ($pointers) {
|
||||
throw "Git LFS left pointers instead of files: $($pointers -join ', ')."
|
||||
}
|
||||
|
||||
# The tag is the only place the version comes from, and it is a plain
|
||||
# version of three numbers — the same shape the application itself looks
|
||||
# for in the releases when it checks for an update. A tag of any other
|
||||
# shape is stopped here rather than halfway through the packaging
|
||||
- name: Read the version from the tag
|
||||
id: version
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$tag = '${{ github.ref_name }}'
|
||||
|
||||
if ($tag -notmatch '^v\d+\.\d+\.\d+$') {
|
||||
throw "The tag '$tag' does not fit: a release is tagged as v1.2.3 — three numbers. A fourth one does not belong in the tag: the Store keeps the revision for itself, and the package always gets a zero there."
|
||||
}
|
||||
|
||||
# The package takes four numbers with a zero at the end: the Store
|
||||
# reserves the last one, so it carries nothing the tag could tell
|
||||
"version=$($tag.Substring(1)).0" | Out-File $env:GITHUB_OUTPUT -Append -Encoding utf8
|
||||
|
||||
- name: Show the toolchain
|
||||
run: dotnet --info
|
||||
|
||||
- name: Restore
|
||||
run: dotnet restore CursorLang.sln --nologo
|
||||
|
||||
- name: Build
|
||||
run: dotnet build CursorLang.sln --configuration Release --no-restore --nologo
|
||||
|
||||
- name: Test
|
||||
run: >
|
||||
dotnet test CursorLang.sln
|
||||
--configuration Release
|
||||
--no-build
|
||||
--nologo
|
||||
--settings CursorLang.Tests/coverage.runsettings
|
||||
|
||||
# The package comes out as Partner Center wants it — the Store puts its own
|
||||
# signature on it. The identity comes from repository variables and falls
|
||||
# back to the defaults of the script when a variable is not set.
|
||||
- name: Pack the MSIX
|
||||
env:
|
||||
IDENTITY_NAME: ${{ vars.MSIX_IDENTITY_NAME }}
|
||||
PUBLISHER: ${{ vars.MSIX_PUBLISHER }}
|
||||
PUBLISHER_DISPLAY_NAME: ${{ vars.MSIX_PUBLISHER_DISPLAY_NAME }}
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$arguments = @{ Version = '${{ steps.version.outputs.version }}' }
|
||||
|
||||
# An empty variable is left out rather than passed on: the script has
|
||||
# defaults of its own, and an empty string would wipe them
|
||||
$variables = @{
|
||||
IdentityName = $env:IDENTITY_NAME
|
||||
Publisher = $env:PUBLISHER
|
||||
PublisherDisplayName = $env:PUBLISHER_DISPLAY_NAME
|
||||
}
|
||||
|
||||
foreach ($name in $variables.Keys) {
|
||||
if ($variables[$name]) { $arguments[$name] = $variables[$name] }
|
||||
}
|
||||
|
||||
./Packaging/build-msix.ps1 @arguments
|
||||
|
||||
- name: Keep the packages
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: msix-${{ steps.version.outputs.version }}
|
||||
path: artifacts/packages/
|
||||
if-no-files-found: error
|
||||
|
||||
# Gitea creates a release of its own for a pushed tag, so the release is
|
||||
# looked up first and only made when it is not there
|
||||
- name: Publish the release
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
TAG: ${{ github.ref_name }}
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
# The GITHUB_ names are what Gitea itself hands to the workflow — its
|
||||
# actions repeat those of GitHub, and the addresses in them point at
|
||||
# this Gitea instance. GITHUB_API_URL used not to reach the steps at
|
||||
# all, so the address is put together from the server one when empty
|
||||
$root = if ($env:GITHUB_API_URL) { $env:GITHUB_API_URL } else { "$env:GITHUB_SERVER_URL/api/v1" }
|
||||
$api = "$root/repos/$env:GITHUB_REPOSITORY/releases"
|
||||
$headers = @{ Authorization = "token $env:GITEA_TOKEN" }
|
||||
|
||||
$release = $null
|
||||
try { $release = Invoke-RestMethod "$api/tags/$env:TAG" -Headers $headers } catch { }
|
||||
|
||||
if (-not $release) {
|
||||
$body = @{ tag_name = $env:TAG; name = $env:TAG; draft = $false; prerelease = $false } | ConvertTo-Json
|
||||
$release = Invoke-RestMethod $api -Method Post -Headers $headers -ContentType 'application/json' -Body $body
|
||||
}
|
||||
|
||||
foreach ($file in Get-ChildItem artifacts/packages -File) {
|
||||
# A tag can be pushed again after it was deleted; the old file of
|
||||
# the same name is dropped, otherwise the upload is refused
|
||||
$existing = $release.assets | Where-Object { $_.name -eq $file.Name }
|
||||
foreach ($asset in $existing) {
|
||||
Invoke-RestMethod "$api/$($release.id)/assets/$($asset.id)" -Method Delete -Headers $headers | Out-Null
|
||||
}
|
||||
|
||||
Write-Host "Uploading $($file.Name)"
|
||||
Invoke-RestMethod "$api/$($release.id)/assets?name=$($file.Name)" -Method Post -Headers $headers -Form @{ attachment = $file } | Out-Null
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
using CursorLang.Models;
|
||||
using CursorLang.Services;
|
||||
using CursorLang.ViewModels;
|
||||
using CursorLang.Views;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace CursorLang.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// What the container is made of. The tests cannot build the application whole
|
||||
/// — it would raise windows and take the place of the single instance — but
|
||||
/// checking that everything needed is declared and resolvable works without that.
|
||||
/// </summary>
|
||||
public sealed class AppTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(typeof(SettingsService))]
|
||||
[InlineData(typeof(ThemeService))]
|
||||
[InlineData(typeof(IThemeService))]
|
||||
[InlineData(typeof(MainWindowPlacement))]
|
||||
[InlineData(typeof(ILocalizationService))]
|
||||
[InlineData(typeof(IStartupService))]
|
||||
[InlineData(typeof(IUpdateService))]
|
||||
[InlineData(typeof(UpdateOptions))]
|
||||
[InlineData(typeof(IKeyboardLayoutService))]
|
||||
[InlineData(typeof(ILayoutPopupService))]
|
||||
[InlineData(typeof(ICapsLockHotkeyService))]
|
||||
[InlineData(typeof(ILayoutPopupWindow))]
|
||||
[InlineData(typeof(LayoutNotificationCoordinator))]
|
||||
[InlineData(typeof(CapsLockSwitchCoordinator))]
|
||||
[InlineData(typeof(LayoutPopupViewModel))]
|
||||
[InlineData(typeof(SettingsViewModel))]
|
||||
[InlineData(typeof(UpdateViewModel))]
|
||||
[InlineData(typeof(LayoutPopupWindow))]
|
||||
[InlineData(typeof(MainWindow))]
|
||||
[InlineData(typeof(KeyboardLayoutOptions))]
|
||||
[InlineData(typeof(AppSettings))]
|
||||
public void Everything_the_app_needs_is_declared_in_the_container(Type service)
|
||||
{
|
||||
Assert.Contains(Describe(), descriptor => descriptor.ServiceType == service);
|
||||
}
|
||||
|
||||
// The settings, the tooltip and the layout watch have to be shared by the
|
||||
// whole application: a second copy of them would mean a second
|
||||
// tooltip or lost settings
|
||||
[Fact]
|
||||
public void Everything_in_the_container_is_declared_as_a_single_copy()
|
||||
{
|
||||
Assert.All(Describe(), descriptor =>
|
||||
Assert.Equal(ServiceLifetime.Singleton, descriptor.Lifetime));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Every_service_is_declared_once()
|
||||
{
|
||||
var types = Describe().Select(descriptor => descriptor.ServiceType).ToList();
|
||||
|
||||
Assert.Equal(types.Count, types.Distinct().Count());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The dependencies of every service have to be resolvable. The check runs
|
||||
/// while the container is built and creates no services itself.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void The_service_dependencies_come_together_with_nothing_missing()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
App.ConfigureServices(services);
|
||||
|
||||
ServiceProvider provider = services.BuildServiceProvider(new ServiceProviderOptions
|
||||
{
|
||||
ValidateOnBuild = true,
|
||||
ValidateScopes = true,
|
||||
});
|
||||
|
||||
// Ensure the provider was built successfully — this serves as an assertion
|
||||
// so the test framework recognizes this as a meaningful test.
|
||||
Assert.NotNull(provider);
|
||||
|
||||
provider.Dispose();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_settings_come_from_the_settings_service()
|
||||
{
|
||||
ServiceDescriptor settings = Describe()
|
||||
.Single(descriptor => descriptor.ServiceType == typeof(AppSettings));
|
||||
|
||||
// The settings are not created anew but read from disk by the service
|
||||
Assert.NotNull(settings.ImplementationFactory);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_tooltip_window_and_its_interface_are_one_window()
|
||||
{
|
||||
ServiceDescriptor window = Describe()
|
||||
.Single(descriptor => descriptor.ServiceType == typeof(ILayoutPopupWindow));
|
||||
|
||||
Assert.NotNull(window.ImplementationFactory);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_theme_and_its_interface_are_one_service()
|
||||
{
|
||||
ServiceDescriptor theme = Describe()
|
||||
.Single(descriptor => descriptor.ServiceType == typeof(IThemeService));
|
||||
|
||||
Assert.NotNull(theme.ImplementationFactory);
|
||||
}
|
||||
|
||||
private static ServiceCollection Describe()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
App.ConfigureServices(services);
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0-windows10.0.19041.0</TargetFramework>
|
||||
<SupportedOSPlatformVersion>10.0.17763.0</SupportedOSPlatformVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<UseWPF>true</UseWPF>
|
||||
<OutputType>Exe</OutputType>
|
||||
<PlatformTarget>AnyCPU</PlatformTarget>
|
||||
<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
<NoWarn>$(NoWarn);CS1591</NoWarn>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
<NoWarn>$(NoWarn);IDE0130</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="xunit.v3" Version="3.2.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.8.1" />
|
||||
<PackageReference Include="coverlet.collector" Version="10.0.1" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\CursorLang\CursorLang.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<AssemblyAttribute Include="System.Reflection.AssemblyMetadataAttribute">
|
||||
<_Parameter1>CursorLangExecutable</_Parameter1>
|
||||
<_Parameter2>$(MSBuildThisFileDirectory)..\CursorLang\bin\$(Configuration)\$(TargetFramework)\CursorLang.exe</_Parameter2>
|
||||
</AssemblyAttribute>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Content Include="xunit.runner.json" CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<MainWindowMarkup>$(MSBuildThisFileDirectory)..\CursorLang\Views\MainWindow.xaml</MainWindowMarkup>
|
||||
</PropertyGroup>
|
||||
|
||||
<Target Name="EmbedMainWindowMarkup" BeforeTargets="PrepareForBuild">
|
||||
<PropertyGroup>
|
||||
<MainWindowMarkupCopy>$(IntermediateOutputPath)MainWindow.xaml.txt</MainWindowMarkupCopy>
|
||||
</PropertyGroup>
|
||||
|
||||
<Copy SourceFiles="$(MainWindowMarkup)"
|
||||
DestinationFiles="$(MainWindowMarkupCopy)"
|
||||
SkipUnchangedFiles="true" />
|
||||
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Include="$(MainWindowMarkupCopy)"
|
||||
LogicalName="MainWindow.xaml" />
|
||||
<FileWrites Include="$(MainWindowMarkupCopy)" />
|
||||
</ItemGroup>
|
||||
</Target>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,192 @@
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Reflection;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
|
||||
namespace CursorLang.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The application as a whole: the start, the single instance and the exit.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The application cannot be built inside the tests — it raises windows and
|
||||
/// takes the place of the single instance for the whole session. It is
|
||||
/// therefore started the way the user starts it: as a separate process.
|
||||
///
|
||||
/// If the application is already running in this session, the checks skip
|
||||
/// themselves: meddling with someone else's running instance is not their business.
|
||||
///
|
||||
/// They skip themselves where there is no desktop to show a window on either — on a
|
||||
/// build agent living as a Windows service, for one.
|
||||
/// </remarks>
|
||||
public sealed class EndToEndTests
|
||||
{
|
||||
private static readonly TimeSpan StartTimeout = TimeSpan.FromSeconds(30);
|
||||
private static readonly TimeSpan ExitTimeout = TimeSpan.FromSeconds(15);
|
||||
|
||||
[Fact]
|
||||
public void The_application_starts_and_shows_the_settings_window()
|
||||
{
|
||||
using Launch launch = Launch.Start();
|
||||
|
||||
Assert.NotEqual(IntPtr.Zero, launch.WaitForWindow());
|
||||
Assert.False(launch.Process.HasExited);
|
||||
}
|
||||
|
||||
// A second run raises no second window but shows the window of the running one
|
||||
[Fact]
|
||||
public void The_second_run_ends_by_itself()
|
||||
{
|
||||
using Launch launch = Launch.Start();
|
||||
launch.WaitForWindow();
|
||||
|
||||
using Process second = Launch.StartProcess();
|
||||
|
||||
Assert.True(second.WaitForExit(ExitTimeout), "the second run did not end by itself");
|
||||
Assert.Equal(0, second.ExitCode);
|
||||
|
||||
// And the first one keeps running
|
||||
Assert.False(launch.Process.HasExited);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Closing_the_window_ends_the_application()
|
||||
{
|
||||
using Launch launch = Launch.Start();
|
||||
launch.WaitForWindow();
|
||||
|
||||
Assert.True(launch.Process.CloseMainWindow(), "the window did not accept the request to close");
|
||||
Assert.True(launch.Process.WaitForExit(ExitTimeout), "the application did not end after the window closed");
|
||||
Assert.Equal(0, launch.Process.ExitCode);
|
||||
}
|
||||
|
||||
/// <summary>A started application that shuts down together with the check.</summary>
|
||||
private sealed class Launch : IDisposable
|
||||
{
|
||||
private const int UOI_NAME = 2;
|
||||
private const string InteractiveWindowStation = "WinSta0";
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
private static extern IntPtr GetProcessWindowStation();
|
||||
|
||||
[DllImport("user32.dll", CharSet = CharSet.Unicode)]
|
||||
private static extern bool GetUserObjectInformation(IntPtr hObj, int nIndex,
|
||||
StringBuilder pvInfo, int nLength, out int lpnLengthNeeded);
|
||||
|
||||
private Launch(Process process) => Process = process;
|
||||
|
||||
internal Process Process { get; }
|
||||
|
||||
/// <summary>Starts the application first — making sure the place is free.</summary>
|
||||
internal static Launch Start()
|
||||
{
|
||||
if (!HasInteractiveDesktop())
|
||||
{
|
||||
Assert.Skip("There is no interactive desktop here — the application has nowhere to show its window");
|
||||
}
|
||||
|
||||
if (Process.GetProcessesByName("CursorLang").Length > 0)
|
||||
{
|
||||
Assert.Skip("The application is already running — this check keeps out of someone else's run");
|
||||
}
|
||||
|
||||
return new Launch(StartProcess());
|
||||
}
|
||||
|
||||
/// <summary>Starts the application the way the user does.</summary>
|
||||
internal static Process StartProcess()
|
||||
{
|
||||
string path = ExecutablePath();
|
||||
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
Assert.Skip($"The application is not built: {path}");
|
||||
}
|
||||
|
||||
return Process.Start(new ProcessStartInfo(path) { UseShellExecute = true })!;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Waits for the settings window: by the time it appears the application
|
||||
/// has raised its whole cast.
|
||||
/// </summary>
|
||||
internal IntPtr WaitForWindow()
|
||||
{
|
||||
DateTime deadline = DateTime.UtcNow + StartTimeout;
|
||||
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
Process.Refresh();
|
||||
|
||||
if (Process.HasExited)
|
||||
{
|
||||
Assert.Fail($"The application exited while starting with code {Process.ExitCode}");
|
||||
}
|
||||
|
||||
if (Process.MainWindowHandle != IntPtr.Zero)
|
||||
{
|
||||
return Process.MainWindowHandle;
|
||||
}
|
||||
|
||||
Thread.Sleep(100);
|
||||
}
|
||||
|
||||
Assert.Fail("The settings window never appeared");
|
||||
return IntPtr.Zero;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Whether there is a desktop here to show a window on. A service gets a
|
||||
/// window station of its own — "Service-0x0-3e7$" and the like: a window can
|
||||
/// be created there, yet nothing shows it. Only "WinSta0" is the interactive one.
|
||||
/// </summary>
|
||||
private static bool HasInteractiveDesktop()
|
||||
{
|
||||
IntPtr station = GetProcessWindowStation();
|
||||
if (station == IntPtr.Zero)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var name = new StringBuilder(256);
|
||||
|
||||
return GetUserObjectInformation(station, UOI_NAME, name, name.Capacity * sizeof(char), out _)
|
||||
&& name.ToString().Equals(InteractiveWindowStation, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static string ExecutablePath()
|
||||
{
|
||||
string configured = Assembly.GetExecutingAssembly()
|
||||
.GetCustomAttributes<AssemblyMetadataAttribute>()
|
||||
.Single(attribute => attribute.Key == "CursorLangExecutable")
|
||||
.Value!;
|
||||
|
||||
return Path.GetFullPath(configured);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!Process.HasExited)
|
||||
{
|
||||
// The polite way first — that way the app gets to save its settings
|
||||
if (!Process.CloseMainWindow() || !Process.WaitForExit(ExitTimeout))
|
||||
{
|
||||
Process.Kill(entireProcessTree: true);
|
||||
Process.WaitForExit(ExitTimeout);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
// The process has already ended on its own
|
||||
}
|
||||
finally
|
||||
{
|
||||
Process.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
global using Xunit;
|
||||
@@ -0,0 +1,41 @@
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Text;
|
||||
|
||||
namespace CursorLang.Tests.Infrastructure;
|
||||
|
||||
/// <summary>
|
||||
/// The network as the test writes it: the answer is decided here rather than
|
||||
/// by a repository somewhere.
|
||||
/// </summary>
|
||||
internal sealed class FakeHttpHandler : HttpMessageHandler
|
||||
{
|
||||
private readonly Func<HttpRequestMessage, HttpResponseMessage> _reply;
|
||||
|
||||
internal FakeHttpHandler(Func<HttpRequestMessage, HttpResponseMessage> reply) => _reply = reply;
|
||||
|
||||
internal List<HttpRequestMessage> Requests { get; } = [];
|
||||
|
||||
internal static FakeHttpHandler Json(string json) => new(_ => new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(json, Encoding.UTF8, "application/json"),
|
||||
});
|
||||
|
||||
internal static FakeHttpHandler Status(HttpStatusCode status) =>
|
||||
new(_ => new HttpResponseMessage(status));
|
||||
|
||||
internal static FakeHttpHandler Bytes(byte[] content) => new(_ => new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new ByteArrayContent(content),
|
||||
});
|
||||
|
||||
internal HttpClient CreateClient() => new(this);
|
||||
|
||||
protected override Task<HttpResponseMessage> SendAsync(
|
||||
HttpRequestMessage request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Requests.Add(request);
|
||||
return Task.FromResult(_reply(request));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
using System.ComponentModel;
|
||||
using System.Net.Http;
|
||||
using CursorLang.Models;
|
||||
using CursorLang.Services;
|
||||
using CursorLang.ViewModels;
|
||||
|
||||
namespace CursorLang.Tests.Infrastructure;
|
||||
|
||||
/// <summary>
|
||||
/// Parts every test needs but few tests care about.
|
||||
/// </summary>
|
||||
internal static class Fake
|
||||
{
|
||||
internal static UpdateViewModel Updates() =>
|
||||
new(new FakeUpdateService(), new FakeLocalizationService(), new AppSettings(), new UpdateOptions());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The keyboard layout, with the test in charge of it.
|
||||
/// </summary>
|
||||
internal sealed class FakeKeyboardLayoutService : IKeyboardLayoutService
|
||||
{
|
||||
internal int StartCalls { get; private set; }
|
||||
|
||||
internal int StopCalls { get; private set; }
|
||||
|
||||
internal int SwitchCalls { get; private set; }
|
||||
|
||||
internal KeyboardLayout CurrentLayout { get; set; } = KeyboardLayout.FromLocaleId(0x0409);
|
||||
|
||||
public KeyboardLayout Current => CurrentLayout;
|
||||
|
||||
public event EventHandler<LayoutChangedEventArgs>? LayoutChanged;
|
||||
|
||||
public void Start() => StartCalls++;
|
||||
|
||||
public void Stop() => StopCalls++;
|
||||
|
||||
public void SwitchToNext() => SwitchCalls++;
|
||||
|
||||
internal void RaiseLayoutChanged(KeyboardLayout layout, LayoutChangeReason reason) =>
|
||||
LayoutChanged?.Invoke(this, new LayoutChangedEventArgs(layout, reason));
|
||||
|
||||
internal bool HasSubscribers => LayoutChanged is not null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A tooltip that pops up nowhere and merely remembers what it was asked for.
|
||||
/// </summary>
|
||||
internal sealed class FakeLayoutPopupService : ILayoutPopupService
|
||||
{
|
||||
internal List<KeyboardLayout> Shown { get; } = [];
|
||||
|
||||
internal List<KeyboardLayout> ShownUntilHidden { get; } = [];
|
||||
|
||||
internal int HideCalls { get; private set; }
|
||||
|
||||
public void Show(KeyboardLayout layout) => Shown.Add(layout);
|
||||
|
||||
public void ShowUntilHidden(KeyboardLayout layout) => ShownUntilHidden.Add(layout);
|
||||
|
||||
public void Hide() => HideCalls++;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The Caps Lock interception without intercepting anything: the test supplies the presses.
|
||||
/// </summary>
|
||||
internal sealed class FakeCapsLockHotkeyService : ICapsLockHotkeyService
|
||||
{
|
||||
public event EventHandler? Tapped;
|
||||
|
||||
public event EventHandler? HoldStarted;
|
||||
|
||||
public event EventHandler? HoldEnded;
|
||||
|
||||
public bool IsRunning { get; private set; }
|
||||
|
||||
internal int StartCalls { get; private set; }
|
||||
|
||||
internal int StopCalls { get; private set; }
|
||||
|
||||
public void Start()
|
||||
{
|
||||
StartCalls++;
|
||||
IsRunning = true;
|
||||
}
|
||||
|
||||
public void Stop()
|
||||
{
|
||||
StopCalls++;
|
||||
IsRunning = false;
|
||||
}
|
||||
|
||||
internal void RaiseTapped() => Tapped?.Invoke(this, EventArgs.Empty);
|
||||
|
||||
internal void RaiseHoldStarted() => HoldStarted?.Invoke(this, EventArgs.Empty);
|
||||
|
||||
internal void RaiseHoldEnded() => HoldEnded?.Invoke(this, EventArgs.Empty);
|
||||
|
||||
internal bool HasSubscribers => Tapped is not null || HoldStarted is not null || HoldEnded is not null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Startup whose state the test assigns.
|
||||
/// </summary>
|
||||
internal sealed class FakeStartupService : IStartupService
|
||||
{
|
||||
internal StartupState State { get; set; } = StartupState.Disabled;
|
||||
|
||||
internal StartupState? AnswerOnEnable { get; set; }
|
||||
|
||||
internal List<bool> Requests { get; } = [];
|
||||
|
||||
internal int GetStateCalls { get; private set; }
|
||||
|
||||
public Task<StartupState> GetStateAsync()
|
||||
{
|
||||
GetStateCalls++;
|
||||
return Task.FromResult(State);
|
||||
}
|
||||
|
||||
public Task<StartupState> SetEnabledAsync(bool enabled)
|
||||
{
|
||||
Requests.Add(enabled);
|
||||
|
||||
State = enabled
|
||||
? AnswerOnEnable ?? StartupState.Enabled
|
||||
: StartupState.Disabled;
|
||||
|
||||
return Task.FromResult(State);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Releases the test writes itself, with no repository behind them.
|
||||
/// </summary>
|
||||
internal sealed class FakeUpdateService : IUpdateService
|
||||
{
|
||||
internal ReleaseInfo? Release { get; set; }
|
||||
|
||||
internal Exception? Failure { get; set; }
|
||||
|
||||
internal TaskCompletionSource? DownloadGate { get; set; }
|
||||
|
||||
internal string PackagePath { get; set; } = string.Empty;
|
||||
|
||||
internal int CheckCalls { get; private set; }
|
||||
|
||||
internal List<string> Installed { get; } = [];
|
||||
|
||||
public bool IsSupported { get; set; } = true;
|
||||
|
||||
public Version CurrentVersion { get; set; } = new(1, 0, 0, 0);
|
||||
|
||||
public Task<ReleaseInfo?> CheckAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
CheckCalls++;
|
||||
|
||||
return Failure is null
|
||||
? Task.FromResult(Release)
|
||||
: Task.FromException<ReleaseInfo?>(Failure);
|
||||
}
|
||||
|
||||
public async Task<string> DownloadAsync(
|
||||
ReleaseInfo release,
|
||||
IProgress<double>? progress,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (DownloadGate is not null)
|
||||
{
|
||||
await DownloadGate.Task.WaitAsync(cancellationToken);
|
||||
}
|
||||
|
||||
if (Failure is not null)
|
||||
{
|
||||
throw Failure;
|
||||
}
|
||||
|
||||
progress?.Report(0.5);
|
||||
return PackagePath;
|
||||
}
|
||||
|
||||
public void Install(string packagePath) => Installed.Add(packagePath);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A release list the test fills in, with no repository behind it.
|
||||
/// </summary>
|
||||
internal sealed class FakeReleaseFeed : IReleaseFeed
|
||||
{
|
||||
internal ReleaseInfo? Release { get; set; }
|
||||
|
||||
internal Exception? Failure { get; set; }
|
||||
|
||||
internal List<HttpRequestMessage> Authorized { get; } = [];
|
||||
|
||||
public Task<ReleaseInfo?> GetLatestAsync(CancellationToken cancellationToken) =>
|
||||
Failure is null ? Task.FromResult(Release) : Task.FromException<ReleaseInfo?>(Failure);
|
||||
|
||||
public void Authorize(HttpRequestMessage request) => Authorized.Add(request);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interface strings without resources: the key comes back as is, tagged with the language.
|
||||
/// </summary>
|
||||
internal sealed class FakeLocalizationService : ILocalizationService
|
||||
{
|
||||
private string _currentLanguage = "en";
|
||||
|
||||
public event PropertyChangedEventHandler? PropertyChanged;
|
||||
|
||||
internal List<string> RequestedKeys { get; } = [];
|
||||
|
||||
public string this[string key]
|
||||
{
|
||||
get
|
||||
{
|
||||
RequestedKeys.Add(key);
|
||||
return $"{_currentLanguage}:{key}";
|
||||
}
|
||||
}
|
||||
|
||||
public IReadOnlyList<LanguageOption> AvailableLanguages { get; } =
|
||||
[
|
||||
new("en", "English"),
|
||||
new("ru", "Русский"),
|
||||
];
|
||||
|
||||
public string CurrentLanguage
|
||||
{
|
||||
get => _currentLanguage;
|
||||
set
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value) || value == _currentLanguage)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_currentLanguage = value;
|
||||
|
||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(CurrentLanguage)));
|
||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(System.Windows.Data.Binding.IndexerName));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A theme that paints nothing and only remembers the windows attached to it.
|
||||
/// </summary>
|
||||
internal sealed class FakeThemeService : IThemeService
|
||||
{
|
||||
public AppTheme CurrentTheme { get; set; } = AppTheme.Light;
|
||||
|
||||
internal List<System.Windows.Window> Registered { get; } = [];
|
||||
|
||||
public void Register(System.Windows.Window window) => Registered.Add(window);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A tooltip window that shows nothing.
|
||||
/// </summary>
|
||||
internal sealed class FakeLayoutPopupWindow : ILayoutPopupWindow
|
||||
{
|
||||
internal int ShowCalls { get; private set; }
|
||||
|
||||
internal int HideCalls { get; private set; }
|
||||
|
||||
internal int CloseCalls { get; private set; }
|
||||
|
||||
internal List<string> Calls { get; } = [];
|
||||
|
||||
public void ShowPopup()
|
||||
{
|
||||
ShowCalls++;
|
||||
Calls.Add("show");
|
||||
}
|
||||
|
||||
public void Hide()
|
||||
{
|
||||
HideCalls++;
|
||||
Calls.Add("hide");
|
||||
}
|
||||
|
||||
public void Close()
|
||||
{
|
||||
CloseCalls++;
|
||||
Calls.Add("close");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
using System.Windows;
|
||||
using System.Windows.Threading;
|
||||
|
||||
namespace CursorLang.Tests.Infrastructure;
|
||||
|
||||
/// <summary>
|
||||
/// The user interface thread for the tests.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Half of the application — windows, the dispatcher and the timers on it —
|
||||
/// only works on an STA thread with a message queue, while tests run on a pool
|
||||
/// thread. The thread is therefore started once for the whole run: a process
|
||||
/// may hold only one <see cref="Application"/>, and recreating it between tests
|
||||
/// is not possible.
|
||||
///
|
||||
/// The queue on that thread is pumped for real, so timers fire on their own:
|
||||
/// the test only has to await the consequences through <see cref="WaitFor"/>.
|
||||
/// </remarks>
|
||||
internal static class Sta
|
||||
{
|
||||
private static readonly Lock Gate = new();
|
||||
|
||||
private static readonly TimeSpan DefaultTimeout = TimeSpan.FromSeconds(5);
|
||||
|
||||
internal static Dispatcher Dispatcher
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (Gate)
|
||||
{
|
||||
return field ??= Start();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Runs an action on the interface thread and waits for it to finish.</summary>
|
||||
internal static void Run(Action action) => Dispatcher.Invoke(action);
|
||||
|
||||
/// <summary>The same for an action that returns a result.</summary>
|
||||
internal static TResult Run<TResult>(Func<TResult> action) => Dispatcher.Invoke(action);
|
||||
|
||||
/// <summary>
|
||||
/// Runs an action on a separate STA thread and waits for it to finish.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Needed where the foreignness of the thread is the point: kernel objects
|
||||
/// such as a mutex let their own owner in again, so a "second instance" of
|
||||
/// the application on the same thread does not count as second.
|
||||
/// </remarks>
|
||||
internal static void RunApart(Action action)
|
||||
{
|
||||
Exception? failure = null;
|
||||
|
||||
var thread = new Thread(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
action();
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
failure = e;
|
||||
}
|
||||
})
|
||||
{
|
||||
IsBackground = true,
|
||||
Name = "CursorLang.Tests apart",
|
||||
};
|
||||
|
||||
thread.SetApartmentState(ApartmentState.STA);
|
||||
thread.Start();
|
||||
thread.Join();
|
||||
|
||||
if (failure is not null)
|
||||
{
|
||||
throw new InvalidOperationException("The action on the separate thread failed", failure);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Waits until the dispatcher queue drains: calls deferred through
|
||||
/// <c>BeginInvoke</c> have run by that time.
|
||||
/// </summary>
|
||||
internal static void Drain() =>
|
||||
Dispatcher.Invoke(static () => { }, DispatcherPriority.ApplicationIdle);
|
||||
|
||||
/// <summary>
|
||||
/// Waits for a condition without getting in the way of the timers.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Waiting is allowed from any thread, including the interface thread
|
||||
/// itself: there a plain wait would stop the message queue, and with it
|
||||
/// everything being waited for. On the interface thread the queue therefore
|
||||
/// keeps being pumped by a nested loop.
|
||||
/// </remarks>
|
||||
internal static void WaitFor(Func<bool> condition, string because, TimeSpan? timeout = null)
|
||||
{
|
||||
DateTime deadline = DateTime.UtcNow + (timeout ?? DefaultTimeout);
|
||||
|
||||
while (!condition())
|
||||
{
|
||||
Assert.True(DateTime.UtcNow < deadline, $"Waited in vain: {because}");
|
||||
Idle(TimeSpan.FromMilliseconds(5));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Waits for the given time while still pumping the queue: that is how
|
||||
/// "nothing happened during this time" is verified.
|
||||
/// </summary>
|
||||
internal static void Pause(TimeSpan duration)
|
||||
{
|
||||
Idle(duration);
|
||||
Drain();
|
||||
}
|
||||
|
||||
// A wait during which the dispatcher queue gets its chance to run
|
||||
private static void Idle(TimeSpan duration)
|
||||
{
|
||||
if (Dispatcher.CheckAccess())
|
||||
{
|
||||
var frame = new DispatcherFrame();
|
||||
var timer = new DispatcherTimer(
|
||||
duration,
|
||||
DispatcherPriority.Background,
|
||||
(_, _) => frame.Continue = false,
|
||||
Dispatcher);
|
||||
|
||||
try
|
||||
{
|
||||
Dispatcher.PushFrame(frame);
|
||||
}
|
||||
finally
|
||||
{
|
||||
timer.Stop();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
Thread.Sleep(duration);
|
||||
}
|
||||
|
||||
private static Dispatcher Start()
|
||||
{
|
||||
var ready = new TaskCompletionSource<Dispatcher>();
|
||||
|
||||
var thread = new Thread(() =>
|
||||
{
|
||||
Dispatcher dispatcher = Dispatcher.CurrentDispatcher;
|
||||
|
||||
// The application has no reason to shut down after its windows:
|
||||
// tests open and close them by the dozen
|
||||
var application = new Application { ShutdownMode = ShutdownMode.OnExplicitShutdown };
|
||||
|
||||
// The shared style dictionary is merged by App.xaml, which the tests
|
||||
// do not have. Without it the settings window still builds, but not
|
||||
// the way the user will see it
|
||||
application.Resources.MergedDictionaries.Add(new ResourceDictionary
|
||||
{
|
||||
Source = new Uri(
|
||||
"pack://application:,,,/CursorLang;component/Themes/Controls.xaml",
|
||||
UriKind.Absolute),
|
||||
});
|
||||
|
||||
ready.SetResult(dispatcher);
|
||||
Dispatcher.Run();
|
||||
})
|
||||
{
|
||||
IsBackground = true,
|
||||
Name = "CursorLang.Tests UI",
|
||||
};
|
||||
|
||||
thread.SetApartmentState(ApartmentState.STA);
|
||||
thread.Start();
|
||||
|
||||
return ready.Task.GetAwaiter().GetResult();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
using System.IO;
|
||||
|
||||
namespace CursorLang.Tests.Infrastructure;
|
||||
|
||||
/// <summary>
|
||||
/// An empty folder for the lifetime of one test. Settings live in a file, and
|
||||
/// working with that file has to be verified where nothing is worth losing.
|
||||
/// </summary>
|
||||
internal sealed class TempFolder : IDisposable
|
||||
{
|
||||
internal TempFolder()
|
||||
{
|
||||
Path = System.IO.Path.Combine(
|
||||
System.IO.Path.GetTempPath(),
|
||||
"CursorLang.Tests",
|
||||
Guid.NewGuid().ToString("N"));
|
||||
|
||||
Directory.CreateDirectory(Path);
|
||||
}
|
||||
|
||||
internal string Path { get; }
|
||||
|
||||
internal string File(string name) => System.IO.Path.Combine(Path, name);
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (Directory.Exists(Path))
|
||||
{
|
||||
Directory.Delete(Path, recursive: true);
|
||||
}
|
||||
}
|
||||
catch (Exception e) when (e is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
// Litter in the temp folder is no reason to fail a test that passed
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
using Microsoft.Win32;
|
||||
|
||||
namespace CursorLang.Tests.Infrastructure;
|
||||
|
||||
/// <summary>
|
||||
/// A registry key of one test's own. Startup outside a package lives in the
|
||||
/// registry, and the real startup list of the user is no place to experiment.
|
||||
/// </summary>
|
||||
internal sealed class TempRegistryKey : IDisposable
|
||||
{
|
||||
private const string Parent = @"Software\CursorLang.Tests";
|
||||
|
||||
private readonly string _path;
|
||||
|
||||
internal TempRegistryKey()
|
||||
{
|
||||
_path = $@"{Parent}\{Guid.NewGuid():N}";
|
||||
Key = Registry.CurrentUser.CreateSubKey(_path);
|
||||
}
|
||||
|
||||
internal RegistryKey Key { get; }
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Key.Dispose();
|
||||
|
||||
try
|
||||
{
|
||||
Registry.CurrentUser.DeleteSubKeyTree(_path, throwOnMissingSubKey: false);
|
||||
}
|
||||
catch (Exception e) when (e is System.Security.SecurityException or UnauthorizedAccessException)
|
||||
{
|
||||
// A leftover key is no reason to fail a test that passed
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
using CursorLang.Interop;
|
||||
using CursorLang.Tests.Infrastructure;
|
||||
|
||||
namespace CursorLang.Tests.Interop;
|
||||
|
||||
/// <summary>
|
||||
/// Vetting the caret position. Some applications report it in their own
|
||||
/// coordinate system, and such answers have to be sifted out by the bounds
|
||||
/// of the input window.
|
||||
/// </summary>
|
||||
public sealed class CaretNativeTests
|
||||
{
|
||||
private static readonly PopupWindowNative.Rect Window = new()
|
||||
{
|
||||
Left = 100,
|
||||
Top = 100,
|
||||
Right = 900,
|
||||
Bottom = 700,
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void A_caret_inside_the_window_is_taken_as_is()
|
||||
{
|
||||
var caret = new PopupWindowNative.Rect { Left = 200, Top = 300, Right = 202, Bottom = 320 };
|
||||
|
||||
Assert.Equal(caret, CaretNative.Validate(caret, Window, scale: 1.5));
|
||||
}
|
||||
|
||||
// The application reported the coordinates without the screen scale: on
|
||||
// their own they sit above and to the left of the input window, and after
|
||||
// being brought to the scale they land inside it
|
||||
[Fact]
|
||||
public void An_unscaled_caret_is_brought_to_the_screen_scale()
|
||||
{
|
||||
var caret = new PopupWindowNative.Rect { Left = 80, Top = 80, Right = 81, Bottom = 90 };
|
||||
|
||||
PopupWindowNative.Rect? validated = CaretNative.Validate(caret, Window, scale: 1.5);
|
||||
|
||||
Assert.NotNull(validated);
|
||||
Assert.Equal(120, validated.Value.Left);
|
||||
Assert.Equal(120, validated.Value.Top);
|
||||
Assert.Equal(121, validated.Value.Right);
|
||||
Assert.Equal(135, validated.Value.Bottom);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_caret_far_from_the_window_is_discarded()
|
||||
{
|
||||
var caret = new PopupWindowNative.Rect { Left = 5000, Top = 5000, Right = 5002, Bottom = 5020 };
|
||||
|
||||
Assert.Null(CaretNative.Validate(caret, Window, scale: 1.5));
|
||||
}
|
||||
|
||||
// At the ordinary scale there is nothing to fix: wrong coordinates stay wrong
|
||||
[Fact]
|
||||
public void At_scale_one_a_caret_outside_the_window_is_discarded()
|
||||
{
|
||||
var caret = new PopupWindowNative.Rect { Left = 10, Top = 10, Right = 12, Bottom = 30 };
|
||||
|
||||
Assert.Null(CaretNative.Validate(caret, Window, scale: 1.0));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(100, 100, 900, 700, true)]
|
||||
[InlineData(99, 100, 900, 700, false)]
|
||||
[InlineData(100, 99, 900, 700, false)]
|
||||
[InlineData(100, 100, 901, 700, false)]
|
||||
[InlineData(100, 100, 900, 701, false)]
|
||||
[InlineData(400, 400, 402, 420, true)]
|
||||
public void Inside_the_window_means_entirely_inside(
|
||||
int left, int top, int right, int bottom, bool expected)
|
||||
{
|
||||
var caret = new PopupWindowNative.Rect { Left = left, Top = top, Right = right, Bottom = bottom };
|
||||
|
||||
Assert.Equal(expected, CaretNative.IsInside(caret, Window));
|
||||
}
|
||||
|
||||
// When there is no caret, its rectangle comes back with zero height. Zero
|
||||
// coordinates, on the other hand, are the ordinary start of an empty field
|
||||
[Theory]
|
||||
[InlineData(0, 0, 0, 0, true)]
|
||||
[InlineData(0, 0, 2, 0, true)]
|
||||
[InlineData(0, 10, 2, 5, true)]
|
||||
[InlineData(0, 0, 0, 1, false)]
|
||||
[InlineData(0, 0, 0, 20, false)]
|
||||
public void A_caret_without_height_counts_as_empty(
|
||||
int left, int top, int right, int bottom, bool expected)
|
||||
{
|
||||
var rect = new PopupWindowNative.Rect { Left = left, Top = top, Right = right, Bottom = bottom };
|
||||
|
||||
Assert.Equal(expected, CaretNative.IsEmpty(rect));
|
||||
}
|
||||
|
||||
// The answer depends on what is on screen right now, but it has no right to
|
||||
// throw: the tooltip is shown whatever the answer
|
||||
[Fact]
|
||||
public void Asking_the_system_for_the_caret_goes_without_errors()
|
||||
{
|
||||
PopupWindowNative.Rect? caret = Sta.Run(CaretNative.TryGetCaretRect);
|
||||
|
||||
if (caret is not null)
|
||||
{
|
||||
Assert.True(caret.Value.Bottom > caret.Value.Top);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Interop;
|
||||
using CursorLang.Interop;
|
||||
using CursorLang.Models;
|
||||
using CursorLang.Tests.Infrastructure;
|
||||
using CursorLang.ViewModels;
|
||||
using CursorLang.Views;
|
||||
|
||||
namespace CursorLang.Tests.Interop;
|
||||
|
||||
/// <summary>
|
||||
/// Checks that need a real foreground window with an input field: the caret
|
||||
/// and the layout switch live exactly there.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Windows does not always allow a window to come forward — when the screen is
|
||||
/// locked, say, or when the run happens in a session without a desktop. In
|
||||
/// those cases the check reports itself as skipped rather than failed: there
|
||||
/// would be nothing to verify.
|
||||
/// </remarks>
|
||||
public sealed class ForegroundWindowTests
|
||||
{
|
||||
[Fact]
|
||||
public void The_caret_in_an_input_field_is_found()
|
||||
{
|
||||
Sta.Run(() =>
|
||||
{
|
||||
using var input = new InputWindow();
|
||||
input.RequireForeground();
|
||||
|
||||
PopupWindowNative.Rect? caret = CaretNative.TryGetCaretRect();
|
||||
|
||||
if (caret is null)
|
||||
{
|
||||
Assert.Skip("The input field did not report the caret position");
|
||||
}
|
||||
|
||||
// The caret has to sit inside the input window and to have a height
|
||||
PopupWindowNative.Rect bounds = WindowPlacementNative.TryGetBounds(input.Handle)!.Value;
|
||||
|
||||
Assert.True(caret.Value.Bottom > caret.Value.Top);
|
||||
Assert.InRange(caret.Value.Left, bounds.Left, bounds.Right);
|
||||
Assert.InRange(caret.Value.Top, bounds.Top, bounds.Bottom);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_tooltip_at_the_caret_lands_next_to_it()
|
||||
{
|
||||
Sta.Run(() =>
|
||||
{
|
||||
using var input = new InputWindow();
|
||||
input.RequireForeground();
|
||||
|
||||
PopupWindowNative.Rect? caret = CaretNative.TryGetCaretRect();
|
||||
if (caret is null)
|
||||
{
|
||||
Assert.Skip("The input field did not report the caret position");
|
||||
}
|
||||
|
||||
var settings = new AppSettings
|
||||
{
|
||||
PlacementMode = PopupPlacementMode.AtCaret,
|
||||
CaretSide = AnchorSide.BottomRight,
|
||||
CaretOffset = 8,
|
||||
};
|
||||
|
||||
var viewModel = new LayoutPopupViewModel(settings) { ShortName = "RU" };
|
||||
var popup = new LayoutPopupWindow(viewModel, settings);
|
||||
|
||||
try
|
||||
{
|
||||
popup.ShowPopup();
|
||||
|
||||
PopupWindowNative.Rect bounds =
|
||||
WindowPlacementNative.TryGetBounds(new WindowInteropHelper(popup).Handle)!.Value;
|
||||
|
||||
// The tooltip landed to the right of and below the caret — as asked
|
||||
Assert.True(bounds.Left >= caret.Value.Right);
|
||||
Assert.True(bounds.Top >= caret.Value.Bottom);
|
||||
}
|
||||
finally
|
||||
{
|
||||
popup.Close();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// The request to switch the layout goes to the window holding the input
|
||||
// focus, so it only concerns the test window itself
|
||||
[Fact]
|
||||
public void The_request_to_change_the_layout_reaches_its_own_window()
|
||||
{
|
||||
Sta.Run(() =>
|
||||
{
|
||||
using var input = new InputWindow();
|
||||
input.RequireForeground();
|
||||
|
||||
int before = KeyboardLayoutNative.GetActiveLocaleId();
|
||||
|
||||
KeyboardLayoutNative.RequestNextLayout();
|
||||
Sta.Pause(TimeSpan.FromMilliseconds(150));
|
||||
|
||||
int after = KeyboardLayoutNative.GetActiveLocaleId();
|
||||
|
||||
Assert.InRange(after, 1, 0xFFFF);
|
||||
|
||||
if (after == before)
|
||||
{
|
||||
// The system may hold a single layout — there is nothing to switch to
|
||||
return;
|
||||
}
|
||||
|
||||
// Bring the layout back around the circle to where it was
|
||||
for (int i = 0; i < 8 && KeyboardLayoutNative.GetActiveLocaleId() != before; i++)
|
||||
{
|
||||
KeyboardLayoutNative.RequestNextLayout();
|
||||
Sta.Pause(TimeSpan.FromMilliseconds(150));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>A window with an input field brought to the foreground.</summary>
|
||||
private sealed class InputWindow : IDisposable
|
||||
{
|
||||
private readonly Window _window;
|
||||
|
||||
internal InputWindow()
|
||||
{
|
||||
var box = new TextBox { Text = "check", FontSize = 20 };
|
||||
|
||||
_window = new Window
|
||||
{
|
||||
Width = 400,
|
||||
Height = 200,
|
||||
ShowInTaskbar = false,
|
||||
WindowStartupLocation = WindowStartupLocation.Manual,
|
||||
Left = 100,
|
||||
Top = 100,
|
||||
Topmost = true,
|
||||
Content = box,
|
||||
};
|
||||
|
||||
_window.Show();
|
||||
Handle = new WindowInteropHelper(_window).Handle;
|
||||
|
||||
// Windows grants the right to bring a window forward neither to
|
||||
// everyone nor at once, so it takes a few attempts
|
||||
for (int attempt = 0; attempt < 10; attempt++)
|
||||
{
|
||||
_window.Activate();
|
||||
box.Focus();
|
||||
box.CaretIndex = box.Text.Length;
|
||||
|
||||
Sta.Pause(TimeSpan.FromMilliseconds(50));
|
||||
|
||||
if (KeyboardLayoutNative.GetForegroundWindow() == Handle)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// The caret does not appear at the same instant as the focus
|
||||
Sta.Pause(TimeSpan.FromMilliseconds(100));
|
||||
}
|
||||
|
||||
internal IntPtr Handle { get; }
|
||||
|
||||
/// <summary>Skips the check if the window never became the foreground one.</summary>
|
||||
internal void RequireForeground()
|
||||
{
|
||||
if (KeyboardLayoutNative.GetForegroundWindow() != Handle)
|
||||
{
|
||||
Assert.Skip("The window could not be brought to the foreground");
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose() => _window.Close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
using System.Reflection;
|
||||
using System.Runtime.InteropServices;
|
||||
using CursorLang.Interop;
|
||||
using CursorLang.Tests.Infrastructure;
|
||||
|
||||
namespace CursorLang.Tests.Interop;
|
||||
|
||||
/// <summary>
|
||||
/// Making sense of the events of the system keyboard hook.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The events are fed straight into the handler the way Windows sends them:
|
||||
/// the tests have no right to press keys for real — the interception is shared
|
||||
/// by the whole system, and a real press would land in someone else's window.
|
||||
/// </remarks>
|
||||
public sealed class LowLevelKeyboardHookTests
|
||||
{
|
||||
private const int HcAction = 0;
|
||||
private const int WmKeyDown = 0x0100;
|
||||
private const int WmKeyUp = 0x0101;
|
||||
private const int WmSysKeyDown = 0x0104;
|
||||
private const int WmSysKeyUp = 0x0105;
|
||||
private const int WmMouseMove = 0x0200;
|
||||
|
||||
private const uint Injected = 0x10;
|
||||
private const int CapsLock = 0x14;
|
||||
|
||||
[Theory]
|
||||
[InlineData(WmKeyDown, true)]
|
||||
[InlineData(WmSysKeyDown, true)]
|
||||
[InlineData(WmKeyUp, false)]
|
||||
[InlineData(WmSysKeyUp, false)]
|
||||
public void Presses_and_releases_reach_the_handler(int message, bool expectedKeyDown)
|
||||
{
|
||||
List<(int Key, bool IsDown)> events = [];
|
||||
var hook = new LowLevelKeyboardHook((key, isDown) =>
|
||||
{
|
||||
events.Add((key, isDown));
|
||||
return false;
|
||||
});
|
||||
|
||||
using (hook)
|
||||
{
|
||||
Send(hook, HcAction, message, CapsLock, flags: 0);
|
||||
}
|
||||
|
||||
Assert.Equal([(CapsLock, expectedKeyDown)], events);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_swallowed_event_goes_no_further()
|
||||
{
|
||||
using var hook = new LowLevelKeyboardHook(static (_, _) => true);
|
||||
|
||||
IntPtr result = Send(hook, HcAction, WmKeyDown, CapsLock, flags: 0);
|
||||
|
||||
// A non-zero answer breaks the chain: neither the application nor the
|
||||
// case handler in Windows will see the event
|
||||
Assert.Equal(new IntPtr(1), result);
|
||||
}
|
||||
|
||||
// Synthetic input comes from on-screen keyboards and automation tools
|
||||
[Fact]
|
||||
public void Synthetic_input_is_not_intercepted()
|
||||
{
|
||||
List<int> keys = [];
|
||||
using var hook = new LowLevelKeyboardHook((key, _) =>
|
||||
{
|
||||
keys.Add(key);
|
||||
return true;
|
||||
});
|
||||
|
||||
IntPtr result = Send(hook, HcAction, WmKeyDown, CapsLock, Injected);
|
||||
|
||||
Assert.Empty(keys);
|
||||
Assert.NotEqual(new IntPtr(1), result);
|
||||
}
|
||||
|
||||
// Windows asks for events below zero not to be inspected but simply passed on
|
||||
[Fact]
|
||||
public void Events_not_meant_for_inspection_are_passed_on()
|
||||
{
|
||||
List<int> keys = [];
|
||||
using var hook = new LowLevelKeyboardHook((key, _) =>
|
||||
{
|
||||
keys.Add(key);
|
||||
return true;
|
||||
});
|
||||
|
||||
Send(hook, code: -1, WmKeyDown, CapsLock, flags: 0);
|
||||
|
||||
Assert.Empty(keys);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_other_messages_are_not_shown_to_the_handler()
|
||||
{
|
||||
List<int> keys = [];
|
||||
using var hook = new LowLevelKeyboardHook((key, _) =>
|
||||
{
|
||||
keys.Add(key);
|
||||
return true;
|
||||
});
|
||||
|
||||
Send(hook, HcAction, WmMouseMove, CapsLock, flags: 0);
|
||||
|
||||
Assert.Empty(keys);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_handler_sees_the_code_of_the_pressed_key()
|
||||
{
|
||||
List<int> keys = [];
|
||||
using var hook = new LowLevelKeyboardHook((key, _) =>
|
||||
{
|
||||
keys.Add(key);
|
||||
return false;
|
||||
});
|
||||
|
||||
Send(hook, HcAction, WmKeyDown, virtualKey: 0x41, flags: 0);
|
||||
Send(hook, HcAction, WmKeyDown, virtualKey: 0x1B, flags: 0);
|
||||
|
||||
Assert.Equal([0x41, 0x1B], keys);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_interception_is_installed_and_removed()
|
||||
{
|
||||
Sta.Run(() =>
|
||||
{
|
||||
using var hook = new LowLevelKeyboardHook(static (_, _) => false);
|
||||
|
||||
Assert.False(hook.IsInstalled);
|
||||
|
||||
Assert.True(hook.Install());
|
||||
Assert.True(hook.IsInstalled);
|
||||
|
||||
hook.Uninstall();
|
||||
Assert.False(hook.IsInstalled);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Installing_again_changes_nothing()
|
||||
{
|
||||
Sta.Run(() =>
|
||||
{
|
||||
using var hook = new LowLevelKeyboardHook(static (_, _) => false);
|
||||
|
||||
Assert.True(hook.Install());
|
||||
Assert.True(hook.Install());
|
||||
Assert.True(hook.IsInstalled);
|
||||
|
||||
hook.Uninstall();
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Removing_without_installing_passes_silently()
|
||||
{
|
||||
var hook = new LowLevelKeyboardHook(static (_, _) => false);
|
||||
|
||||
hook.Uninstall();
|
||||
hook.Uninstall();
|
||||
|
||||
Assert.False(hook.IsInstalled);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Closing_removes_the_interception()
|
||||
{
|
||||
Sta.Run(() =>
|
||||
{
|
||||
var hook = new LowLevelKeyboardHook(static (_, _) => false);
|
||||
hook.Install();
|
||||
|
||||
hook.Dispose();
|
||||
|
||||
Assert.False(hook.IsInstalled);
|
||||
});
|
||||
}
|
||||
|
||||
// The event arrives from Windows as a structure in unmanaged memory
|
||||
private static IntPtr Send(
|
||||
LowLevelKeyboardHook hook, int code, int message, int virtualKey, uint flags)
|
||||
{
|
||||
// vkCode, scanCode, flags and time take four bytes each, then a pointer
|
||||
const int Size = 24;
|
||||
IntPtr data = Marshal.AllocHGlobal(Size);
|
||||
|
||||
try
|
||||
{
|
||||
Marshal.WriteInt32(data, 0, virtualKey);
|
||||
Marshal.WriteInt32(data, 4, 0);
|
||||
Marshal.WriteInt32(data, 8, (int)flags);
|
||||
Marshal.WriteInt32(data, 12, 0);
|
||||
Marshal.WriteIntPtr(data, 16, IntPtr.Zero);
|
||||
|
||||
MethodInfo handler = typeof(LowLevelKeyboardHook)
|
||||
.GetMethod("OnHookEvent", BindingFlags.Instance | BindingFlags.NonPublic)!;
|
||||
|
||||
return (IntPtr)handler.Invoke(hook, [code, new IntPtr(message), data])!;
|
||||
}
|
||||
finally
|
||||
{
|
||||
Marshal.FreeHGlobal(data);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
using System.Windows;
|
||||
using System.Windows.Interop;
|
||||
using CursorLang.Interop;
|
||||
using CursorLang.Tests.Infrastructure;
|
||||
|
||||
namespace CursorLang.Tests.Interop;
|
||||
|
||||
/// <summary>
|
||||
/// The Win32 wrappers: what is checked is that the calls are put together
|
||||
/// right — structures of the expected size, flags in place, and the answers
|
||||
/// of the system read correctly.
|
||||
/// </summary>
|
||||
public sealed class NativeWrappersTests
|
||||
{
|
||||
[Fact]
|
||||
public void The_cursor_position_is_read()
|
||||
{
|
||||
PopupWindowNative.Point cursor = PopupWindowNative.GetCursorPosition();
|
||||
|
||||
// The virtual screen may run into negative coordinates, but not beyond
|
||||
// reason: a misread structure would give garbage
|
||||
Assert.InRange(cursor.X, -32_000, 32_000);
|
||||
Assert.InRange(cursor.Y, -32_000, 32_000);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_scale_of_the_monitor_under_the_cursor_is_positive()
|
||||
{
|
||||
double scale = PopupWindowNative.GetScaleAt(PopupWindowNative.GetCursorPosition());
|
||||
|
||||
Assert.InRange(scale, 0.5, 8.0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_work_area_of_the_active_monitor_is_not_empty()
|
||||
{
|
||||
(PopupWindowNative.Rect work, double scale) = PopupWindowNative.GetActiveMonitorWorkArea();
|
||||
|
||||
Assert.True(work.Right > work.Left);
|
||||
Assert.True(work.Bottom > work.Top);
|
||||
Assert.InRange(scale, 0.5, 8.0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_window_bounds_are_read_from_the_system()
|
||||
{
|
||||
Sta.Run(() =>
|
||||
{
|
||||
using var window = new HandleWindow();
|
||||
|
||||
PopupWindowNative.Rect? bounds = WindowPlacementNative.TryGetBounds(window.Handle);
|
||||
|
||||
Assert.NotNull(bounds);
|
||||
Assert.True(bounds.Value.Right > bounds.Value.Left);
|
||||
Assert.True(bounds.Value.Bottom > bounds.Value.Top);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_window_that_does_not_exist_has_no_bounds()
|
||||
{
|
||||
Assert.Null(WindowPlacementNative.TryGetBounds(IntPtr.Zero));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_window_is_moved_to_the_given_point()
|
||||
{
|
||||
Sta.Run(() =>
|
||||
{
|
||||
using var window = new HandleWindow();
|
||||
|
||||
PopupWindowNative.MoveTo(window.Handle, 120, 90);
|
||||
|
||||
PopupWindowNative.Rect bounds = WindowPlacementNative.TryGetBounds(window.Handle)!.Value;
|
||||
Assert.Equal(120, bounds.Left);
|
||||
Assert.Equal(90, bounds.Top);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Moving_does_not_change_the_window_size()
|
||||
{
|
||||
Sta.Run(() =>
|
||||
{
|
||||
using var window = new HandleWindow();
|
||||
|
||||
PopupWindowNative.Rect before = WindowPlacementNative.TryGetBounds(window.Handle)!.Value;
|
||||
PopupWindowNative.MoveTo(window.Handle, 200, 150);
|
||||
PopupWindowNative.Rect after = WindowPlacementNative.TryGetBounds(window.Handle)!.Value;
|
||||
|
||||
Assert.Equal(before.Right - before.Left, after.Right - after.Left);
|
||||
Assert.Equal(before.Bottom - before.Top, after.Bottom - after.Top);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_window_bounds_are_set_as_a_whole()
|
||||
{
|
||||
Sta.Run(() =>
|
||||
{
|
||||
using var window = new HandleWindow();
|
||||
|
||||
PopupWindowNative.SetBounds(window.Handle, 60, 70, 320, 240);
|
||||
|
||||
PopupWindowNative.Rect bounds = WindowPlacementNative.TryGetBounds(window.Handle)!.Value;
|
||||
Assert.Equal(60, bounds.Left);
|
||||
Assert.Equal(70, bounds.Top);
|
||||
Assert.Equal(380, bounds.Right);
|
||||
Assert.Equal(310, bounds.Bottom);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_work_area_is_found_by_the_rectangle_of_the_window()
|
||||
{
|
||||
Sta.Run(() =>
|
||||
{
|
||||
using var window = new HandleWindow();
|
||||
PopupWindowNative.Rect bounds = WindowPlacementNative.TryGetBounds(window.Handle)!.Value;
|
||||
|
||||
PopupWindowNative.Rect? work = WindowPlacementNative.TryGetWorkAreaNear(bounds);
|
||||
|
||||
Assert.NotNull(work);
|
||||
Assert.True(work.Value.Right > work.Value.Left);
|
||||
Assert.True(work.Value.Bottom > work.Value.Top);
|
||||
});
|
||||
}
|
||||
|
||||
// The nearest monitor is picked, so an area is found even for a point far off screen
|
||||
[Fact]
|
||||
public void For_a_rectangle_off_every_screen_the_nearest_monitor_is_taken()
|
||||
{
|
||||
var far = new PopupWindowNative.Rect { Left = 30_000, Top = 30_000, Right = 30_100, Bottom = 30_100 };
|
||||
|
||||
PopupWindowNative.Rect? work = WindowPlacementNative.TryGetWorkAreaNear(far);
|
||||
|
||||
Assert.NotNull(work);
|
||||
Assert.True(work.Value.Right > work.Value.Left);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_window_becomes_invisible_to_the_focus_and_the_switcher()
|
||||
{
|
||||
Sta.Run(() =>
|
||||
{
|
||||
using var window = new HandleWindow();
|
||||
|
||||
PopupWindowNative.MakePassive(window.Handle);
|
||||
|
||||
// Checked through the same wrapper: the style has to stick and not
|
||||
// be reset by a repeated call
|
||||
PopupWindowNative.MakePassive(window.Handle);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_layout_of_the_foreground_window_is_read()
|
||||
{
|
||||
int localeId = KeyboardLayoutNative.GetActiveLocaleId();
|
||||
|
||||
// The low word of the HKL is the locale identifier, and it is never zero
|
||||
Assert.NotEqual(0, localeId);
|
||||
Assert.InRange(localeId, 1, 0xFFFF);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_layout_is_read_for_any_window()
|
||||
{
|
||||
int localeId = KeyboardLayoutNative.GetLocaleIdOf(KeyboardLayoutNative.GetForegroundWindow());
|
||||
|
||||
Assert.InRange(localeId, 0, 0xFFFF);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_input_state_of_the_foreground_is_read()
|
||||
{
|
||||
bool received = ForegroundInputNative.TryGetInfo(out ForegroundInputNative.GuiThreadInfo info);
|
||||
|
||||
if (received)
|
||||
{
|
||||
// The structure size is filled in by the wrapper itself, and it has
|
||||
// to match what Windows expects
|
||||
Assert.Equal(System.Runtime.InteropServices.Marshal.SizeOf<ForegroundInputNative.GuiThreadInfo>(),
|
||||
info.cbSize);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_right_to_show_a_window_is_given_away_without_errors()
|
||||
{
|
||||
ForegroundPermissionNative.GrantToAnyProcess();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_window_title_bar_is_repainted()
|
||||
{
|
||||
Sta.Run(() =>
|
||||
{
|
||||
using var window = new HandleWindow();
|
||||
|
||||
WindowThemeNative.SetDarkTitleBar(window.Handle, isDark: true);
|
||||
WindowThemeNative.SetDarkTitleBar(window.Handle, isDark: false);
|
||||
});
|
||||
}
|
||||
|
||||
// The window may be gone by the time of the repaint
|
||||
[Fact]
|
||||
public void Repainting_a_window_that_does_not_exist_passes_silently()
|
||||
{
|
||||
WindowThemeNative.SetDarkTitleBar(IntPtr.Zero, isDark: true);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_package_flag_is_computed_once_and_does_not_change()
|
||||
{
|
||||
bool first = PackageIdentityNative.IsPackaged;
|
||||
|
||||
Assert.Equal(first, PackageIdentityNative.IsPackaged);
|
||||
}
|
||||
|
||||
/// <summary>A window with a created handle that never appears on screen.</summary>
|
||||
private sealed class HandleWindow : IDisposable
|
||||
{
|
||||
private readonly Window _window;
|
||||
|
||||
internal HandleWindow()
|
||||
{
|
||||
_window = new Window
|
||||
{
|
||||
Width = 300,
|
||||
Height = 200,
|
||||
ShowInTaskbar = false,
|
||||
WindowStartupLocation = WindowStartupLocation.Manual,
|
||||
};
|
||||
|
||||
Handle = new WindowInteropHelper(_window).EnsureHandle();
|
||||
}
|
||||
|
||||
internal IntPtr Handle { get; }
|
||||
|
||||
public void Dispose() => _window.Close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
using System.ComponentModel;
|
||||
using System.Reflection;
|
||||
using System.Text.Json;
|
||||
using System.Windows.Media;
|
||||
using CursorLang.Models;
|
||||
|
||||
namespace CursorLang.Tests.Models;
|
||||
|
||||
public sealed class AppSettingsTests
|
||||
{
|
||||
[Fact]
|
||||
public void Default_values_describe_a_tooltip_at_the_cursor()
|
||||
{
|
||||
var settings = new AppSettings();
|
||||
|
||||
Assert.Equal("en", settings.Language);
|
||||
Assert.Equal(AppTheme.System, settings.Theme);
|
||||
Assert.Equal(PopupPlacementMode.AtCursor, settings.PlacementMode);
|
||||
Assert.Equal(AnchorSide.BottomRight, settings.CursorSide);
|
||||
Assert.Equal(16, settings.CursorOffset);
|
||||
Assert.Equal(AnchorSide.BottomRight, settings.CaretSide);
|
||||
Assert.Equal(16, settings.CaretOffset);
|
||||
Assert.Equal(ScreenPosition.BottomRight, settings.ScreenPosition);
|
||||
Assert.Equal(24, settings.ScreenMargin);
|
||||
Assert.Equal(20, settings.FontSize);
|
||||
Assert.Equal(0.9, settings.Opacity);
|
||||
Assert.Equal(500, settings.DurationMilliseconds);
|
||||
Assert.Equal(300, settings.CapsLockHoldMilliseconds);
|
||||
Assert.Equal(Color.FromRgb(0x20, 0x20, 0x20), settings.BackgroundColor);
|
||||
Assert.Equal(Color.FromRgb(0xFF, 0xFF, 0xFF), settings.ForegroundColor);
|
||||
}
|
||||
|
||||
// The app must not change how the system behaves until it is asked to
|
||||
[Fact]
|
||||
public void Caps_Lock_interception_is_off_by_default()
|
||||
{
|
||||
Assert.False(new AppSettings().UseCapsLockHotkey);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_time_on_screen_is_derived_from_milliseconds()
|
||||
{
|
||||
var settings = new AppSettings { DurationMilliseconds = 1250 };
|
||||
|
||||
Assert.Equal(TimeSpan.FromMilliseconds(1250), settings.Duration);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_hold_threshold_is_derived_from_milliseconds()
|
||||
{
|
||||
var settings = new AppSettings { CapsLockHoldMilliseconds = 400 };
|
||||
|
||||
Assert.Equal(TimeSpan.FromMilliseconds(400), settings.CapsLockHoldDelay);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(WritableProperties))]
|
||||
public void A_changed_setting_is_announced_to_subscribers(string propertyName)
|
||||
{
|
||||
var settings = new AppSettings();
|
||||
List<string?> changed = [];
|
||||
settings.PropertyChanged += (_, e) => changed.Add(e.PropertyName);
|
||||
|
||||
SetDifferentValue(settings, propertyName);
|
||||
|
||||
Assert.Contains(propertyName, changed);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(WritableProperties))]
|
||||
public void Writing_the_same_value_leaves_subscribers_alone(string propertyName)
|
||||
{
|
||||
var settings = new AppSettings();
|
||||
PropertyInfo property = typeof(AppSettings).GetProperty(propertyName)!;
|
||||
object? value = property.GetValue(settings);
|
||||
|
||||
List<string?> changed = [];
|
||||
settings.PropertyChanged += (_, e) => changed.Add(e.PropertyName);
|
||||
|
||||
property.SetValue(settings, value);
|
||||
|
||||
Assert.Empty(changed);
|
||||
}
|
||||
|
||||
// Duration and CapsLockHoldDelay are derived from other settings and have
|
||||
// no business being in the file
|
||||
[Fact]
|
||||
public void Derived_values_stay_out_of_the_file()
|
||||
{
|
||||
using JsonDocument document = JsonSerializer.SerializeToDocument(new AppSettings());
|
||||
|
||||
List<string> names = [.. document.RootElement.EnumerateObject().Select(property => property.Name)];
|
||||
|
||||
Assert.DoesNotContain(nameof(AppSettings.Duration), names);
|
||||
Assert.DoesNotContain(nameof(AppSettings.CapsLockHoldDelay), names);
|
||||
|
||||
// What they are derived from, on the other hand, has to be stored
|
||||
Assert.Contains(nameof(AppSettings.DurationMilliseconds), names);
|
||||
Assert.Contains(nameof(AppSettings.CapsLockHoldMilliseconds), names);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Settings_report_changes_as_INotifyPropertyChanged()
|
||||
{
|
||||
Assert.IsAssignableFrom<INotifyPropertyChanged>(new AppSettings());
|
||||
}
|
||||
|
||||
public static TheoryData<string> WritableProperties()
|
||||
{
|
||||
var data = new TheoryData<string>();
|
||||
|
||||
foreach (string name in WritablePropertyNames())
|
||||
{
|
||||
data.Add(name);
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
/// <summary>Names of the settings the user is able to change.</summary>
|
||||
internal static IEnumerable<string> WritablePropertyNames() =>
|
||||
typeof(AppSettings).GetProperties()
|
||||
.Where(property => property.CanWrite)
|
||||
.Select(property => property.Name);
|
||||
|
||||
// A value guaranteed to differ from the current one: each kind of setting
|
||||
// has its own way of differing
|
||||
private static void SetDifferentValue(AppSettings settings, string propertyName)
|
||||
{
|
||||
PropertyInfo property = typeof(AppSettings).GetProperty(propertyName)!;
|
||||
object? current = property.GetValue(settings);
|
||||
|
||||
object next = current switch
|
||||
{
|
||||
string text => text + "-other",
|
||||
double number => number + 1,
|
||||
bool flag => !flag,
|
||||
Color color => Color.FromRgb((byte)(color.R + 1), color.G, color.B),
|
||||
Enum value => NextEnumValue(value),
|
||||
DateTimeOffset moment => moment.AddDays(1),
|
||||
|
||||
// A setting never set yet: the app has not checked for updates once
|
||||
null when property.PropertyType == typeof(DateTimeOffset?) => DateTimeOffset.UnixEpoch,
|
||||
|
||||
_ => throw new NotSupportedException($"Unknown kind of setting: {property.PropertyType}"),
|
||||
};
|
||||
|
||||
property.SetValue(settings, next);
|
||||
}
|
||||
|
||||
private static object NextEnumValue(Enum current)
|
||||
{
|
||||
Array values = Enum.GetValues(current.GetType());
|
||||
|
||||
foreach (object? value in values)
|
||||
{
|
||||
if (!Equals(value, current))
|
||||
{
|
||||
return value!;
|
||||
}
|
||||
}
|
||||
|
||||
throw new NotSupportedException($"{current.GetType()} has a single value");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
using System.Globalization;
|
||||
using CursorLang.Models;
|
||||
|
||||
namespace CursorLang.Tests.Models;
|
||||
|
||||
public sealed class KeyboardLayoutTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(0x0409, "EN")]
|
||||
[InlineData(0x0419, "RU")]
|
||||
[InlineData(0x040C, "FR")]
|
||||
[InlineData(0x0407, "DE")]
|
||||
public void The_short_name_comes_from_the_language_code(int localeId, string expected)
|
||||
{
|
||||
KeyboardLayout layout = KeyboardLayout.FromLocaleId(localeId);
|
||||
|
||||
Assert.Equal(expected, layout.ShortName);
|
||||
Assert.Equal(localeId, layout.LocaleId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_full_name_joins_the_short_name_and_the_native_language_name()
|
||||
{
|
||||
KeyboardLayout layout = KeyboardLayout.FromLocaleId(0x0419);
|
||||
|
||||
Assert.Equal($"RU — {new CultureInfo(0x0419).NativeName}", layout.DisplayName);
|
||||
}
|
||||
|
||||
// A layout may belong to a language the system does not know — that is not an error
|
||||
[Fact]
|
||||
public void An_unknown_locale_is_shown_by_its_own_code()
|
||||
{
|
||||
int unknown = FindUnknownLocaleId();
|
||||
|
||||
KeyboardLayout layout = KeyboardLayout.FromLocaleId(unknown);
|
||||
|
||||
string expected = $"0x{unknown:X4}";
|
||||
Assert.Equal(expected, layout.ShortName);
|
||||
Assert.Equal(expected, layout.DisplayName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Layouts_of_the_same_locale_are_equal()
|
||||
{
|
||||
Assert.Equal(KeyboardLayout.FromLocaleId(0x0409), KeyboardLayout.FromLocaleId(0x0409));
|
||||
Assert.NotEqual(KeyboardLayout.FromLocaleId(0x0409), KeyboardLayout.FromLocaleId(0x0419));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_layout_can_also_be_built_directly()
|
||||
{
|
||||
var layout = new KeyboardLayout(1, "XX", "XX — language");
|
||||
|
||||
Assert.Equal(1, layout.LocaleId);
|
||||
Assert.Equal("XX", layout.ShortName);
|
||||
Assert.Equal("XX — language", layout.DisplayName);
|
||||
}
|
||||
|
||||
// An identifier with no culture behind it in Windows
|
||||
private static int FindUnknownLocaleId()
|
||||
{
|
||||
for (int candidate = 0x1000; candidate <= 0xFFFF; candidate++)
|
||||
{
|
||||
try
|
||||
{
|
||||
_ = new CultureInfo(candidate);
|
||||
}
|
||||
catch (CultureNotFoundException)
|
||||
{
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
throw new InvalidOperationException("The system knows every locale identifier");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
using CursorLang.Models;
|
||||
|
||||
namespace CursorLang.Tests.Models;
|
||||
|
||||
public sealed class LayoutChangedEventArgsTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(LayoutChangeReason.UserSwitched)]
|
||||
[InlineData(LayoutChangeReason.ApplicationSwitched)]
|
||||
public void The_event_carries_the_layout_and_the_reason(LayoutChangeReason reason)
|
||||
{
|
||||
KeyboardLayout layout = KeyboardLayout.FromLocaleId(0x0419);
|
||||
|
||||
var args = new LayoutChangedEventArgs(layout, reason);
|
||||
|
||||
Assert.Same(layout, args.Layout);
|
||||
Assert.Equal(reason, args.Reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_event_stays_an_ordinary_dotnet_event()
|
||||
{
|
||||
var args = new LayoutChangedEventArgs(KeyboardLayout.FromLocaleId(0x0409), LayoutChangeReason.UserSwitched);
|
||||
|
||||
Assert.IsAssignableFrom<EventArgs>(args);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
using System.Collections;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Reflection;
|
||||
using System.Resources;
|
||||
using System.Text.RegularExpressions;
|
||||
using CursorLang.Models;
|
||||
|
||||
namespace CursorLang.Tests.Resources;
|
||||
|
||||
/// <summary>
|
||||
/// Checks of the resources themselves: they carry every caption in the settings
|
||||
/// window, and a missing key only shows on a live window.
|
||||
/// </summary>
|
||||
public sealed partial class StringsTests
|
||||
{
|
||||
private static readonly ResourceManager Resources =
|
||||
new("CursorLang.Resources.Strings", typeof(App).Assembly);
|
||||
|
||||
private static readonly CultureInfo English = CultureInfo.GetCultureInfo("en");
|
||||
private static readonly CultureInfo Russian = CultureInfo.GetCultureInfo("ru");
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(EnumKeys))]
|
||||
public void Every_list_value_has_an_English_caption(string key)
|
||||
{
|
||||
Assert.False(string.IsNullOrWhiteSpace(Resources.GetString(key, English)));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(EnumKeys))]
|
||||
public void Every_list_value_has_a_Russian_caption(string key)
|
||||
{
|
||||
Assert.False(string.IsNullOrWhiteSpace(Resources.GetString(key, Russian)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_Russian_translation_covers_every_string()
|
||||
{
|
||||
List<string> missing = [];
|
||||
|
||||
foreach (string key in NeutralKeys())
|
||||
{
|
||||
// An untranslated resource falls back to English, so the Russian set
|
||||
// is asked directly rather than through the string with a fallback
|
||||
if (RussianSet().GetString(key) is null)
|
||||
{
|
||||
missing.Add(key);
|
||||
}
|
||||
}
|
||||
|
||||
Assert.Empty(missing);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_Russian_translation_has_no_extra_strings()
|
||||
{
|
||||
HashSet<string> neutral = [.. NeutralKeys()];
|
||||
List<string> extra = [];
|
||||
|
||||
foreach (DictionaryEntry entry in RussianSet())
|
||||
{
|
||||
var key = (string)entry.Key;
|
||||
if (!neutral.Contains(key))
|
||||
{
|
||||
extra.Add(key);
|
||||
}
|
||||
}
|
||||
|
||||
Assert.Empty(extra);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void There_are_no_empty_strings_in_the_resources()
|
||||
{
|
||||
foreach (string key in NeutralKeys())
|
||||
{
|
||||
Assert.False(string.IsNullOrWhiteSpace(Resources.GetString(key, English)), key);
|
||||
Assert.False(string.IsNullOrWhiteSpace(Resources.GetString(key, Russian)), key);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Every key the settings window markup asks for has to exist in the
|
||||
/// resources: otherwise the user sees the key itself in its place.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Every_key_from_the_settings_window_markup_exists_in_the_resources()
|
||||
{
|
||||
HashSet<string> known = [.. NeutralKeys()];
|
||||
List<string> missing = [];
|
||||
|
||||
foreach (Match match in LocalizationBinding().Matches(ReadSettingsWindowMarkup()))
|
||||
{
|
||||
string key = match.Groups["key"].Value;
|
||||
if (!known.Contains(key))
|
||||
{
|
||||
missing.Add(key);
|
||||
}
|
||||
}
|
||||
|
||||
Assert.Empty(missing);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The version of an update is put into the string by the app, so the place
|
||||
/// for it has to be there in both languages.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void The_string_about_an_available_update_has_room_for_the_version()
|
||||
{
|
||||
Assert.Contains("{0}", Resources.GetString("UpdateAvailable", English), StringComparison.Ordinal);
|
||||
Assert.Contains("{0}", Resources.GetString("UpdateAvailable", Russian), StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
/// <summary>The markup does ask for strings — otherwise the check above means nothing.</summary>
|
||||
[Fact]
|
||||
public void The_settings_window_markup_asks_for_resource_strings()
|
||||
{
|
||||
Assert.NotEmpty(LocalizationBinding().Matches(ReadSettingsWindowMarkup()));
|
||||
}
|
||||
|
||||
public static TheoryData<string> EnumKeys()
|
||||
{
|
||||
var data = new TheoryData<string>();
|
||||
|
||||
foreach (string key in EnumKeysOf<AppTheme>())
|
||||
{
|
||||
data.Add(key);
|
||||
}
|
||||
|
||||
foreach (string key in EnumKeysOf<PopupPlacementMode>())
|
||||
{
|
||||
data.Add(key);
|
||||
}
|
||||
|
||||
foreach (string key in EnumKeysOf<AnchorSide>())
|
||||
{
|
||||
data.Add(key);
|
||||
}
|
||||
|
||||
foreach (string key in EnumKeysOf<ScreenPosition>())
|
||||
{
|
||||
data.Add(key);
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
// A caption key is built from the type name and the value: PopupPlacementMode_AtCursor
|
||||
private static IEnumerable<string> EnumKeysOf<TEnum>() where TEnum : struct, Enum =>
|
||||
Enum.GetValues<TEnum>().Select(value => $"{typeof(TEnum).Name}_{value}");
|
||||
|
||||
private static IEnumerable<string> NeutralKeys()
|
||||
{
|
||||
ResourceSet set = Resources.GetResourceSet(CultureInfo.InvariantCulture, true, true)!;
|
||||
|
||||
foreach (DictionaryEntry entry in set)
|
||||
{
|
||||
yield return (string)entry.Key;
|
||||
}
|
||||
}
|
||||
|
||||
private static ResourceSet RussianSet() =>
|
||||
Resources.GetResourceSet(Russian, createIfNotExists: true, tryParents: false)!;
|
||||
|
||||
private static string ReadSettingsWindowMarkup()
|
||||
{
|
||||
using Stream stream = Assembly.GetExecutingAssembly().GetManifestResourceStream("MainWindow.xaml")
|
||||
?? throw new InvalidOperationException("The settings window markup is not embedded in the test assembly");
|
||||
|
||||
using var reader = new StreamReader(stream);
|
||||
return reader.ReadToEnd();
|
||||
}
|
||||
|
||||
[GeneratedRegex(@"Localization\[(?<key>\w+)\]")]
|
||||
private static partial Regex LocalizationBinding();
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
using System.Collections.Concurrent;
|
||||
using CursorLang.Models;
|
||||
using CursorLang.Services;
|
||||
using CursorLang.Tests.Infrastructure;
|
||||
|
||||
namespace CursorLang.Tests.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Making sense of Caps Lock presses: a short one differs from a long one only
|
||||
/// by when the key was released.
|
||||
/// </summary>
|
||||
public sealed class CapsLockHotkeyServiceTests
|
||||
{
|
||||
private const int CapsLock = 0x14;
|
||||
private const int LetterA = 0x41;
|
||||
|
||||
[Fact]
|
||||
public void A_Caps_Lock_press_is_not_passed_on()
|
||||
{
|
||||
using var harness = Harness.Create(holdMilliseconds: 10_000);
|
||||
|
||||
Assert.True(Sta.Run(() => harness.Service.HandleKeyEvent(CapsLock, isKeyDown: true)));
|
||||
Assert.True(Sta.Run(() => harness.Service.HandleKeyEvent(CapsLock, isKeyDown: false)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_other_keys_go_through_as_usual()
|
||||
{
|
||||
using var harness = Harness.Create(holdMilliseconds: 10_000);
|
||||
|
||||
Assert.False(Sta.Run(() => harness.Service.HandleKeyEvent(LetterA, isKeyDown: true)));
|
||||
Assert.False(Sta.Run(() => harness.Service.HandleKeyEvent(LetterA, isKeyDown: false)));
|
||||
Assert.Empty(harness.Events);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_short_press_yields_a_single_event()
|
||||
{
|
||||
using var harness = Harness.Create(holdMilliseconds: 10_000);
|
||||
|
||||
harness.Press();
|
||||
harness.Release();
|
||||
|
||||
Sta.WaitFor(() => harness.Events.Count == 1, "the short press event arrived");
|
||||
Assert.Equal(["tap"], harness.Events);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_hold_is_announced_once_the_threshold_is_past()
|
||||
{
|
||||
using var harness = Harness.Create(holdMilliseconds: 20);
|
||||
|
||||
harness.Press();
|
||||
|
||||
Sta.WaitFor(() => harness.Events.Contains("hold-start"), "the hold threshold was passed");
|
||||
Assert.Equal(["hold-start"], harness.Events);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_release_after_a_hold_yields_an_end_rather_than_a_press()
|
||||
{
|
||||
using var harness = Harness.Create(holdMilliseconds: 20);
|
||||
|
||||
harness.Press();
|
||||
Sta.WaitFor(() => harness.Events.Contains("hold-start"), "the hold threshold was passed");
|
||||
|
||||
harness.Release();
|
||||
|
||||
Sta.WaitFor(() => harness.Events.Count == 2, "the end of the hold arrived");
|
||||
Assert.Equal(["hold-start", "hold-end"], harness.Events);
|
||||
}
|
||||
|
||||
// While the key is held down Windows repeats the press: the count runs from the first one
|
||||
[Fact]
|
||||
public void Auto_repeat_does_not_reset_the_countdown()
|
||||
{
|
||||
using var harness = Harness.Create(holdMilliseconds: 60);
|
||||
|
||||
harness.Press();
|
||||
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
Sta.Pause(TimeSpan.FromMilliseconds(10));
|
||||
harness.Press();
|
||||
}
|
||||
|
||||
Sta.WaitFor(() => harness.Events.Contains("hold-start"), "the hold was announced despite the repeats");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_quick_press_does_not_count_as_a_hold()
|
||||
{
|
||||
using var harness = Harness.Create(holdMilliseconds: 300);
|
||||
|
||||
harness.Press();
|
||||
harness.Release();
|
||||
|
||||
Sta.Pause(TimeSpan.FromMilliseconds(400));
|
||||
|
||||
Assert.Equal(["tap"], harness.Events);
|
||||
}
|
||||
|
||||
// The interception may be removed with the key still down — by unticking
|
||||
// the setting, for one. The tooltip has to go away in that case
|
||||
[Fact]
|
||||
public void Removing_the_interception_during_a_hold_announces_its_end()
|
||||
{
|
||||
using var harness = Harness.Create(holdMilliseconds: 20);
|
||||
|
||||
harness.Press();
|
||||
Sta.WaitFor(() => harness.Events.Contains("hold-start"), "the hold threshold was passed");
|
||||
|
||||
Sta.Run(harness.Service.Stop);
|
||||
|
||||
Sta.WaitFor(() => harness.Events.Count == 2, "the end of the hold was announced");
|
||||
Assert.Equal(["hold-start", "hold-end"], harness.Events);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Removing_the_interception_without_a_hold_yields_no_events()
|
||||
{
|
||||
using var harness = Harness.Create(holdMilliseconds: 10_000);
|
||||
|
||||
harness.Press();
|
||||
Sta.Run(harness.Service.Stop);
|
||||
Sta.Pause(TimeSpan.FromMilliseconds(50));
|
||||
|
||||
Assert.Empty(harness.Events);
|
||||
}
|
||||
|
||||
// After the interception is removed the hold countdown must not keep running
|
||||
[Fact]
|
||||
public void Removing_the_interception_stops_the_countdown()
|
||||
{
|
||||
using var harness = Harness.Create(holdMilliseconds: 40);
|
||||
|
||||
harness.Press();
|
||||
Sta.Run(harness.Service.Stop);
|
||||
|
||||
Sta.Pause(TimeSpan.FromMilliseconds(120));
|
||||
|
||||
Assert.Empty(harness.Events);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void After_the_interception_is_removed_presses_count_from_scratch()
|
||||
{
|
||||
using var harness = Harness.Create(holdMilliseconds: 10_000);
|
||||
|
||||
harness.Press();
|
||||
Sta.Run(harness.Service.Stop);
|
||||
|
||||
harness.Press();
|
||||
harness.Release();
|
||||
|
||||
Sta.WaitFor(() => harness.Events.Count == 1, "a short press after the restart");
|
||||
Assert.Equal(["tap"], harness.Events);
|
||||
}
|
||||
|
||||
// When the application is shutting down nobody is waiting for events anymore
|
||||
[Fact]
|
||||
public void Closing_the_service_sends_out_no_events()
|
||||
{
|
||||
var harness = Harness.Create(holdMilliseconds: 20);
|
||||
|
||||
harness.Press();
|
||||
Sta.WaitFor(() => harness.Events.Contains("hold-start"), "the hold threshold was passed");
|
||||
|
||||
Sta.Run(harness.Service.Dispose);
|
||||
Sta.Pause(TimeSpan.FromMilliseconds(80));
|
||||
|
||||
Assert.Equal(["hold-start"], harness.Events);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_hold_threshold_is_read_on_every_press()
|
||||
{
|
||||
using var harness = Harness.Create(holdMilliseconds: 10_000);
|
||||
|
||||
harness.Press();
|
||||
harness.Release();
|
||||
Sta.WaitFor(() => harness.Events.Count == 1, "the first press counted as short");
|
||||
|
||||
harness.Settings.CapsLockHoldMilliseconds = 20;
|
||||
harness.Press();
|
||||
|
||||
Sta.WaitFor(() => harness.Events.Contains("hold-start"), "the new threshold took effect");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Before_the_start_there_is_no_interception()
|
||||
{
|
||||
using var harness = Harness.Create(holdMilliseconds: 10_000);
|
||||
|
||||
Assert.False(harness.Service.IsRunning);
|
||||
}
|
||||
|
||||
// The real system hook is installed and removed on the interface thread
|
||||
[Fact]
|
||||
public void The_interception_is_installed_and_removed()
|
||||
{
|
||||
using var harness = Harness.Create(holdMilliseconds: 10_000);
|
||||
Sta.Run(() =>
|
||||
{
|
||||
harness.Service.Start();
|
||||
Assert.True(harness.Service.IsRunning);
|
||||
|
||||
// Starting again breaks nothing
|
||||
harness.Service.Start();
|
||||
Assert.True(harness.Service.IsRunning);
|
||||
|
||||
harness.Service.Stop();
|
||||
Assert.False(harness.Service.IsRunning);
|
||||
|
||||
// Nor does stopping again
|
||||
harness.Service.Stop();
|
||||
Assert.False(harness.Service.IsRunning);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Closing_removes_the_interception()
|
||||
{
|
||||
var harness = Harness.Create(holdMilliseconds: 10_000);
|
||||
|
||||
Sta.Run(() =>
|
||||
{
|
||||
harness.Service.Start();
|
||||
harness.Service.Dispose();
|
||||
|
||||
Assert.False(harness.Service.IsRunning);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The service together with its settings and the list of events that happened.
|
||||
/// </summary>
|
||||
private sealed class Harness : IDisposable
|
||||
{
|
||||
private Harness(CapsLockHotkeyService service, AppSettings settings)
|
||||
{
|
||||
Service = service;
|
||||
Settings = settings;
|
||||
}
|
||||
|
||||
internal CapsLockHotkeyService Service { get; }
|
||||
|
||||
internal AppSettings Settings { get; }
|
||||
|
||||
/// <summary>Events arrive from the interface thread and are read by the test thread.</summary>
|
||||
internal ConcurrentQueue<string> Events { get; } = new();
|
||||
|
||||
internal static Harness Create(double holdMilliseconds)
|
||||
{
|
||||
var settings = new AppSettings { CapsLockHoldMilliseconds = holdMilliseconds };
|
||||
|
||||
// The service remembers the dispatcher of the thread it was created on
|
||||
CapsLockHotkeyService service = Sta.Run(() => new CapsLockHotkeyService(settings));
|
||||
var harness = new Harness(service, settings);
|
||||
|
||||
service.Tapped += (_, _) => harness.Events.Enqueue("tap");
|
||||
service.HoldStarted += (_, _) => harness.Events.Enqueue("hold-start");
|
||||
service.HoldEnded += (_, _) => harness.Events.Enqueue("hold-end");
|
||||
|
||||
return harness;
|
||||
}
|
||||
|
||||
internal void Press() => Sta.Run(() => Service.HandleKeyEvent(CapsLock, isKeyDown: true));
|
||||
|
||||
internal void Release() => Sta.Run(() => Service.HandleKeyEvent(CapsLock, isKeyDown: false));
|
||||
|
||||
public void Dispose() => Sta.Run(Service.Dispose);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
using CursorLang.Models;
|
||||
using CursorLang.Services;
|
||||
using CursorLang.Tests.Infrastructure;
|
||||
|
||||
namespace CursorLang.Tests.Services;
|
||||
|
||||
/// <summary>
|
||||
/// What happens on Caps Lock presses and how the interception follows the setting.
|
||||
/// </summary>
|
||||
public sealed class CapsLockSwitchCoordinatorTests
|
||||
{
|
||||
[Fact]
|
||||
public void With_the_setting_on_the_interception_starts_at_once()
|
||||
{
|
||||
var hotkey = new FakeCapsLockHotkeyService();
|
||||
using CapsLockSwitchCoordinator coordinator = Create(hotkey, new AppSettings { UseCapsLockHotkey = true });
|
||||
|
||||
coordinator.Start();
|
||||
|
||||
Assert.True(hotkey.IsRunning);
|
||||
Assert.Equal(1, hotkey.StartCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void With_the_setting_off_there_is_no_interception()
|
||||
{
|
||||
var hotkey = new FakeCapsLockHotkeyService();
|
||||
using CapsLockSwitchCoordinator coordinator = Create(hotkey, new AppSettings { UseCapsLockHotkey = false });
|
||||
|
||||
coordinator.Start();
|
||||
|
||||
Assert.False(hotkey.IsRunning);
|
||||
Assert.Equal(1, hotkey.StopCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Ticking_the_setting_turns_the_interception_on_live()
|
||||
{
|
||||
var hotkey = new FakeCapsLockHotkeyService();
|
||||
var settings = new AppSettings { UseCapsLockHotkey = false };
|
||||
using CapsLockSwitchCoordinator coordinator = Create(hotkey, settings);
|
||||
coordinator.Start();
|
||||
|
||||
settings.UseCapsLockHotkey = true;
|
||||
|
||||
Assert.True(hotkey.IsRunning);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Unticking_the_setting_gives_the_key_its_usual_behaviour_back()
|
||||
{
|
||||
var hotkey = new FakeCapsLockHotkeyService();
|
||||
var settings = new AppSettings { UseCapsLockHotkey = true };
|
||||
using CapsLockSwitchCoordinator coordinator = Create(hotkey, settings);
|
||||
coordinator.Start();
|
||||
|
||||
settings.UseCapsLockHotkey = false;
|
||||
|
||||
Assert.False(hotkey.IsRunning);
|
||||
}
|
||||
|
||||
// The interception only follows its own setting
|
||||
[Fact]
|
||||
public void Other_settings_leave_the_interception_alone()
|
||||
{
|
||||
var hotkey = new FakeCapsLockHotkeyService();
|
||||
var settings = new AppSettings { UseCapsLockHotkey = true };
|
||||
using CapsLockSwitchCoordinator coordinator = Create(hotkey, settings);
|
||||
coordinator.Start();
|
||||
|
||||
int startsBefore = hotkey.StartCalls;
|
||||
int stopsBefore = hotkey.StopCalls;
|
||||
|
||||
settings.FontSize = 44;
|
||||
settings.CapsLockHoldMilliseconds = 700;
|
||||
|
||||
Assert.Equal(startsBefore, hotkey.StartCalls);
|
||||
Assert.Equal(stopsBefore, hotkey.StopCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_short_press_switches_the_layout()
|
||||
{
|
||||
var hotkey = new FakeCapsLockHotkeyService();
|
||||
var layouts = new FakeKeyboardLayoutService();
|
||||
var popup = new FakeLayoutPopupService();
|
||||
|
||||
using var coordinator = new CapsLockSwitchCoordinator(
|
||||
hotkey, layouts, popup, new AppSettings { UseCapsLockHotkey = true });
|
||||
coordinator.Start();
|
||||
|
||||
hotkey.RaiseTapped();
|
||||
|
||||
Assert.Equal(1, layouts.SwitchCalls);
|
||||
Assert.Empty(popup.Shown);
|
||||
Assert.Empty(popup.ShownUntilHidden);
|
||||
}
|
||||
|
||||
// The layout stays put, but staying silent is not an option either: without
|
||||
// a tooltip a long press looks like a key that did not work
|
||||
[Fact]
|
||||
public void A_long_press_shows_the_current_layout()
|
||||
{
|
||||
var hotkey = new FakeCapsLockHotkeyService();
|
||||
var layouts = new FakeKeyboardLayoutService
|
||||
{
|
||||
CurrentLayout = KeyboardLayout.FromLocaleId(0x0419),
|
||||
};
|
||||
var popup = new FakeLayoutPopupService();
|
||||
|
||||
using var coordinator = new CapsLockSwitchCoordinator(
|
||||
hotkey, layouts, popup, new AppSettings { UseCapsLockHotkey = true });
|
||||
coordinator.Start();
|
||||
|
||||
hotkey.RaiseHoldStarted();
|
||||
|
||||
Assert.Equal([layouts.CurrentLayout], popup.ShownUntilHidden);
|
||||
Assert.Equal(0, layouts.SwitchCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void When_the_hold_ends_the_tooltip_goes_away()
|
||||
{
|
||||
var hotkey = new FakeCapsLockHotkeyService();
|
||||
var layouts = new FakeKeyboardLayoutService();
|
||||
var popup = new FakeLayoutPopupService();
|
||||
|
||||
using var coordinator = new CapsLockSwitchCoordinator(
|
||||
hotkey, layouts, popup, new AppSettings { UseCapsLockHotkey = true });
|
||||
coordinator.Start();
|
||||
|
||||
hotkey.RaiseHoldStarted();
|
||||
hotkey.RaiseHoldEnded();
|
||||
|
||||
Assert.Equal(1, popup.HideCalls);
|
||||
Assert.Equal(0, layouts.SwitchCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Before_the_start_presses_do_nothing()
|
||||
{
|
||||
var hotkey = new FakeCapsLockHotkeyService();
|
||||
var layouts = new FakeKeyboardLayoutService();
|
||||
var popup = new FakeLayoutPopupService();
|
||||
|
||||
using var coordinator = new CapsLockSwitchCoordinator(
|
||||
hotkey, layouts, popup, new AppSettings { UseCapsLockHotkey = true });
|
||||
|
||||
hotkey.RaiseTapped();
|
||||
hotkey.RaiseHoldStarted();
|
||||
|
||||
Assert.Equal(0, layouts.SwitchCalls);
|
||||
Assert.Empty(popup.ShownUntilHidden);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Closing_removes_the_interception_and_unsubscribes_from_presses()
|
||||
{
|
||||
var hotkey = new FakeCapsLockHotkeyService();
|
||||
var layouts = new FakeKeyboardLayoutService();
|
||||
var settings = new AppSettings { UseCapsLockHotkey = true };
|
||||
|
||||
var coordinator = new CapsLockSwitchCoordinator(
|
||||
hotkey, layouts, new FakeLayoutPopupService(), settings);
|
||||
coordinator.Start();
|
||||
coordinator.Dispose();
|
||||
|
||||
Assert.False(hotkey.IsRunning);
|
||||
Assert.False(hotkey.HasSubscribers);
|
||||
|
||||
hotkey.RaiseTapped();
|
||||
Assert.Equal(0, layouts.SwitchCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void After_closing_the_setting_no_longer_turns_the_interception_on()
|
||||
{
|
||||
var hotkey = new FakeCapsLockHotkeyService();
|
||||
var settings = new AppSettings { UseCapsLockHotkey = false };
|
||||
|
||||
CapsLockSwitchCoordinator coordinator = Create(hotkey, settings);
|
||||
coordinator.Start();
|
||||
coordinator.Dispose();
|
||||
|
||||
settings.UseCapsLockHotkey = true;
|
||||
|
||||
Assert.False(hotkey.IsRunning);
|
||||
}
|
||||
|
||||
private static CapsLockSwitchCoordinator Create(FakeCapsLockHotkeyService hotkey, AppSettings settings) =>
|
||||
new(hotkey, new FakeKeyboardLayoutService(), new FakeLayoutPopupService(), settings);
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Runtime.InteropServices;
|
||||
using CursorLang.Models;
|
||||
using CursorLang.Services;
|
||||
using CursorLang.Tests.Infrastructure;
|
||||
|
||||
namespace CursorLang.Tests.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Reading the release list of Gitea. The answer of the server is not ours to
|
||||
/// shape, so what matters is what the app makes of it.
|
||||
/// </summary>
|
||||
public sealed class GiteaReleaseFeedTests
|
||||
{
|
||||
private const string Releases = """
|
||||
[
|
||||
{
|
||||
"tag_name": "v1.2.0",
|
||||
"draft": false,
|
||||
"prerelease": false,
|
||||
"html_url": "https://git.alrakis.kz/alrakis/cursor-lang/releases/tag/v1.2.0",
|
||||
"assets": [
|
||||
{
|
||||
"name": "CursorLang-1.2.0.0.msixbundle",
|
||||
"browser_download_url": "https://git.alrakis.kz/attachments/CursorLang-1.2.0.0.msixbundle",
|
||||
"size": 4096
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
""";
|
||||
|
||||
[Fact]
|
||||
public async Task A_release_is_read_whole()
|
||||
{
|
||||
ReleaseInfo? release = await Read(Releases);
|
||||
|
||||
Assert.NotNull(release);
|
||||
Assert.Equal(new Version(1, 2, 0, 0), release.Version);
|
||||
Assert.Equal("v1.2.0", release.Tag);
|
||||
Assert.Equal("https://git.alrakis.kz/alrakis/cursor-lang/releases/tag/v1.2.0", release.PageUrl?.ToString());
|
||||
Assert.Equal("CursorLang-1.2.0.0.msixbundle", release.Package.FileName);
|
||||
Assert.Equal(
|
||||
"https://git.alrakis.kz/attachments/CursorLang-1.2.0.0.msixbundle",
|
||||
release.Package.Url.ToString());
|
||||
Assert.Equal(4096, release.Package.Size);
|
||||
}
|
||||
|
||||
// The API of Gitea lives on the server itself, next to the pages of the
|
||||
// repository
|
||||
[Fact]
|
||||
public async Task The_request_goes_to_the_releases_of_the_project()
|
||||
{
|
||||
var handler = FakeHttpHandler.Json(Releases);
|
||||
var feed = new GiteaReleaseFeed(handler.CreateClient(), Options());
|
||||
|
||||
await feed.GetLatestAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
Uri asked = Assert.Single(handler.Requests).RequestUri!;
|
||||
Assert.Equal("git.alrakis.kz", asked.Host);
|
||||
Assert.StartsWith(
|
||||
"/api/v1/repos/alrakis/cursor-lang/releases", asked.AbsolutePath, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
// A server sitting under a path of its own keeps that path: dropping it
|
||||
// would send the request to a place that answers nothing
|
||||
[Fact]
|
||||
public async Task A_server_behind_a_path_keeps_it()
|
||||
{
|
||||
var handler = FakeHttpHandler.Json(Releases);
|
||||
var feed = new GiteaReleaseFeed(
|
||||
handler.CreateClient(),
|
||||
new UpdateOptions { ServiceUri = new Uri("https://host.example.com/gitea"), Project = "team/app" });
|
||||
|
||||
await feed.GetLatestAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
Uri asked = Assert.Single(handler.Requests).RequestUri!;
|
||||
Assert.StartsWith("/gitea/api/v1/repos/team/app/releases", asked.AbsolutePath, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
// «token» is the scheme of Gitea for keys of access
|
||||
[Fact]
|
||||
public void A_closed_repository_gets_the_token_it_asks_for()
|
||||
{
|
||||
var feed = new GiteaReleaseFeed(new HttpClient(), Options("secret"));
|
||||
using var request = new HttpRequestMessage();
|
||||
|
||||
feed.Authorize(request);
|
||||
|
||||
Assert.Equal("token", request.Headers.Authorization?.Scheme);
|
||||
Assert.Equal("secret", request.Headers.Authorization?.Parameter);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void An_open_repository_is_asked_without_a_token()
|
||||
{
|
||||
var feed = new GiteaReleaseFeed(new HttpClient(), Options());
|
||||
using var request = new HttpRequestMessage();
|
||||
|
||||
feed.Authorize(request);
|
||||
|
||||
Assert.Null(request.Headers.Authorization);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("1.2.3", "1.2.3.0")]
|
||||
[InlineData("v1.2.3", "1.2.3.0")]
|
||||
[InlineData("V1.2", "1.2.0.0")]
|
||||
[InlineData("1.2.3.4", "1.2.3.4")]
|
||||
public async Task A_version_is_read_out_of_the_tag(string tag, string expected)
|
||||
{
|
||||
ReleaseInfo? release = await Read(WithTag(tag));
|
||||
|
||||
Assert.NotNull(release);
|
||||
Assert.Equal(Version.Parse(expected), release.Version);
|
||||
}
|
||||
|
||||
// A pre-release version is not something the app offers by itself:
|
||||
// such a version is asked for on purpose
|
||||
[Theory]
|
||||
[InlineData("v1.2.3-beta")]
|
||||
[InlineData("nightly")]
|
||||
[InlineData("release-1")]
|
||||
public async Task A_tag_that_is_not_a_version_is_passed_over(string tag)
|
||||
{
|
||||
Assert.Null(await Read(WithTag(tag)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_draft_and_a_pre_release_are_passed_over()
|
||||
{
|
||||
const string releases = """
|
||||
[
|
||||
{ "tag_name": "v3.0.0", "draft": true, "assets": [
|
||||
{ "name": "a.msixbundle", "browser_download_url": "https://host/a.msixbundle" } ] },
|
||||
{ "tag_name": "v2.0.0", "prerelease": true, "assets": [
|
||||
{ "name": "b.msixbundle", "browser_download_url": "https://host/b.msixbundle" } ] },
|
||||
{ "tag_name": "v1.0.0", "assets": [
|
||||
{ "name": "c.msixbundle", "browser_download_url": "https://host/c.msixbundle" } ] }
|
||||
]
|
||||
""";
|
||||
|
||||
ReleaseInfo? release = await Read(releases);
|
||||
|
||||
Assert.NotNull(release);
|
||||
Assert.Equal(new Version(1, 0, 0, 0), release.Version);
|
||||
}
|
||||
|
||||
// The order of the releases belongs to the server, the highest number to
|
||||
// the app: a fix to an older branch can be the freshest release
|
||||
[Fact]
|
||||
public async Task The_highest_version_wins_over_the_order_of_the_answer()
|
||||
{
|
||||
const string releases = """
|
||||
[
|
||||
{ "tag_name": "v1.0.5", "assets": [
|
||||
{ "name": "a.msixbundle", "browser_download_url": "https://host/a.msixbundle" } ] },
|
||||
{ "tag_name": "v2.0.0", "assets": [
|
||||
{ "name": "b.msixbundle", "browser_download_url": "https://host/b.msixbundle" } ] }
|
||||
]
|
||||
""";
|
||||
|
||||
ReleaseInfo? release = await Read(releases);
|
||||
|
||||
Assert.NotNull(release);
|
||||
Assert.Equal(new Version(2, 0, 0, 0), release.Version);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_release_without_a_package_is_passed_over()
|
||||
{
|
||||
const string releases = """
|
||||
[
|
||||
{ "tag_name": "v2.0.0", "assets": [
|
||||
{ "name": "notes.txt", "browser_download_url": "https://host/notes.txt" } ] },
|
||||
{ "tag_name": "v1.0.0", "assets": [
|
||||
{ "name": "a.msixbundle", "browser_download_url": "https://host/a.msixbundle" } ] }
|
||||
]
|
||||
""";
|
||||
|
||||
ReleaseInfo? release = await Read(releases);
|
||||
|
||||
Assert.NotNull(release);
|
||||
Assert.Equal(new Version(1, 0, 0, 0), release.Version);
|
||||
}
|
||||
|
||||
// The signature is what Windows checks, but a package offered over an open
|
||||
// connection is not worth downloading in the first place
|
||||
[Fact]
|
||||
public async Task A_package_offered_over_an_open_connection_is_passed_over()
|
||||
{
|
||||
const string releases = """
|
||||
[
|
||||
{ "tag_name": "v2.0.0", "assets": [
|
||||
{ "name": "a.msixbundle", "browser_download_url": "http://host/a.msixbundle" } ] }
|
||||
]
|
||||
""";
|
||||
|
||||
Assert.Null(await Read(releases));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_bundle_wins_over_the_packages_of_single_architectures()
|
||||
{
|
||||
const string releases = """
|
||||
[
|
||||
{ "tag_name": "v2.0.0", "assets": [
|
||||
{ "name": "CursorLang-2.0.0.0-x64.msix", "browser_download_url": "https://host/x64.msix" },
|
||||
{ "name": "CursorLang-2.0.0.0.msixbundle", "browser_download_url": "https://host/all.msixbundle" } ] }
|
||||
]
|
||||
""";
|
||||
|
||||
ReleaseInfo? release = await Read(releases);
|
||||
|
||||
Assert.NotNull(release);
|
||||
Assert.Equal("https://host/all.msixbundle", release.Package.Url.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Out_of_several_packages_the_one_for_this_machine_is_taken()
|
||||
{
|
||||
const string releases = """
|
||||
[
|
||||
{ "tag_name": "v2.0.0", "assets": [
|
||||
{ "name": "CursorLang-2.0.0.0-arm64.msix", "browser_download_url": "https://host/arm64.msix" },
|
||||
{ "name": "CursorLang-2.0.0.0-x64.msix", "browser_download_url": "https://host/x64.msix" } ] }
|
||||
]
|
||||
""";
|
||||
|
||||
string expected = RuntimeInformation.ProcessArchitecture == Architecture.Arm64
|
||||
? "https://host/arm64.msix"
|
||||
: "https://host/x64.msix";
|
||||
|
||||
ReleaseInfo? release = await Read(releases);
|
||||
|
||||
Assert.NotNull(release);
|
||||
Assert.Equal(expected, release.Package.Url.ToString());
|
||||
}
|
||||
|
||||
// Without the architecture in the name there is no telling which package is
|
||||
// for this machine — unless it is the only one there
|
||||
[Fact]
|
||||
public async Task A_package_without_an_architecture_is_taken_only_when_alone()
|
||||
{
|
||||
const string alone = """
|
||||
[
|
||||
{ "tag_name": "v2.0.0", "assets": [
|
||||
{ "name": "CursorLang.msix", "browser_download_url": "https://host/one.msix" } ] }
|
||||
]
|
||||
""";
|
||||
|
||||
const string ambiguous = """
|
||||
[
|
||||
{ "tag_name": "v2.0.0", "assets": [
|
||||
{ "name": "CursorLang.msix", "browser_download_url": "https://host/one.msix" },
|
||||
{ "name": "CursorLang-other.msix", "browser_download_url": "https://host/other.msix" } ] }
|
||||
]
|
||||
""";
|
||||
|
||||
Assert.NotNull(await Read(alone));
|
||||
Assert.Null(await Read(ambiguous));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task An_empty_list_of_releases_means_nothing_to_offer()
|
||||
{
|
||||
Assert.Null(await Read("[]"));
|
||||
}
|
||||
|
||||
// A server answering with something else is no reason to fail
|
||||
[Fact]
|
||||
public async Task An_answer_that_is_not_a_list_leaves_the_app_with_nothing()
|
||||
{
|
||||
Assert.Null(await Read("""{ "message": "Not Found" }"""));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_refusal_of_the_server_is_raised()
|
||||
{
|
||||
var handler = FakeHttpHandler.Status(HttpStatusCode.Unauthorized);
|
||||
var feed = new GiteaReleaseFeed(handler.CreateClient(), Options());
|
||||
|
||||
await Assert.ThrowsAsync<HttpRequestException>(
|
||||
() => feed.GetLatestAsync(TestContext.Current.CancellationToken));
|
||||
}
|
||||
|
||||
private static string WithTag(string tag) => $$"""
|
||||
[
|
||||
{ "tag_name": "{{tag}}", "assets": [
|
||||
{ "name": "CursorLang.msixbundle", "browser_download_url": "https://host/a.msixbundle" } ] }
|
||||
]
|
||||
""";
|
||||
|
||||
private static UpdateOptions Options(string? token = null) => new()
|
||||
{
|
||||
ServiceUri = new Uri("https://git.alrakis.kz/"),
|
||||
Project = "alrakis/cursor-lang",
|
||||
AccessToken = token,
|
||||
};
|
||||
|
||||
private static Task<ReleaseInfo?> Read(string json) =>
|
||||
new GiteaReleaseFeed(FakeHttpHandler.Json(json).CreateClient(), Options())
|
||||
.GetLatestAsync(TestContext.Current.CancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
using System.Collections.Concurrent;
|
||||
using CursorLang.Models;
|
||||
using CursorLang.Services;
|
||||
using CursorLang.Tests.Infrastructure;
|
||||
|
||||
namespace CursorLang.Tests.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Watching the layout of the foreground window. The test supplies what the
|
||||
/// system reports: what is under test is the decision about what counts as
|
||||
/// a layout change.
|
||||
/// </summary>
|
||||
public sealed class KeyboardLayoutServiceTests
|
||||
{
|
||||
private static readonly IntPtr FirstWindow = new(1000);
|
||||
private static readonly IntPtr SecondWindow = new(2000);
|
||||
|
||||
private const int English = 0x0409;
|
||||
private const int Russian = 0x0419;
|
||||
|
||||
[Fact]
|
||||
public void The_current_layout_is_taken_from_the_foreground_window()
|
||||
{
|
||||
using var world = new World { LocaleId = Russian };
|
||||
KeyboardLayoutService service = world.CreateService();
|
||||
|
||||
Assert.Equal("RU", service.Current.ShortName);
|
||||
|
||||
world.LocaleId = English;
|
||||
Assert.Equal("EN", service.Current.ShortName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Switching_asks_the_system_to_change_the_layout()
|
||||
{
|
||||
using var world = new World();
|
||||
KeyboardLayoutService service = world.CreateService();
|
||||
|
||||
service.SwitchToNext();
|
||||
service.SwitchToNext();
|
||||
|
||||
Assert.Equal(2, world.SwitchRequests);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_layout_change_in_the_same_window_counts_as_the_users_doing()
|
||||
{
|
||||
using var world = new World { LocaleId = English };
|
||||
KeyboardLayoutService service = world.CreateService();
|
||||
|
||||
Sta.Run(service.Start);
|
||||
world.LocaleId = Russian;
|
||||
Sta.Run(service.Poll);
|
||||
|
||||
LayoutChangedEventArgs change = Assert.Single(world.Changes);
|
||||
Assert.Equal(LayoutChangeReason.UserSwitched, change.Reason);
|
||||
Assert.Equal("RU", change.Layout.ShortName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Moving_to_another_application_differs_from_switching()
|
||||
{
|
||||
using var world = new World { LocaleId = English };
|
||||
KeyboardLayoutService service = world.CreateService();
|
||||
|
||||
Sta.Run(service.Start);
|
||||
|
||||
world.ForegroundWindow = SecondWindow;
|
||||
world.LocaleId = Russian;
|
||||
Sta.Run(service.Poll);
|
||||
|
||||
LayoutChangedEventArgs change = Assert.Single(world.Changes);
|
||||
Assert.Equal(LayoutChangeReason.ApplicationSwitched, change.Reason);
|
||||
}
|
||||
|
||||
// Moving to an application with the same layout changes nothing
|
||||
[Fact]
|
||||
public void Moving_without_a_layout_change_yields_no_events()
|
||||
{
|
||||
using var world = new World { LocaleId = English };
|
||||
KeyboardLayoutService service = world.CreateService();
|
||||
|
||||
Sta.Run(service.Start);
|
||||
|
||||
world.ForegroundWindow = SecondWindow;
|
||||
Sta.Run(service.Poll);
|
||||
|
||||
Assert.Empty(world.Changes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void An_unchanged_layout_yields_no_events()
|
||||
{
|
||||
using var world = new World { LocaleId = Russian };
|
||||
KeyboardLayoutService service = world.CreateService();
|
||||
|
||||
Sta.Run(service.Start);
|
||||
|
||||
for (int i = 0; i < 5; i++)
|
||||
{
|
||||
Sta.Run(service.Poll);
|
||||
}
|
||||
|
||||
Assert.Empty(world.Changes);
|
||||
}
|
||||
|
||||
// There is no foreground window — during a desktop switch, for one
|
||||
[Fact]
|
||||
public void Without_a_foreground_window_the_poll_is_skipped()
|
||||
{
|
||||
using var world = new World { LocaleId = English };
|
||||
KeyboardLayoutService service = world.CreateService();
|
||||
|
||||
Sta.Run(service.Start);
|
||||
|
||||
world.ForegroundWindow = IntPtr.Zero;
|
||||
world.LocaleId = Russian;
|
||||
Sta.Run(service.Poll);
|
||||
|
||||
Assert.Empty(world.Changes);
|
||||
|
||||
// And the layout was not remembered: the change is noticed once a window is back
|
||||
world.ForegroundWindow = FirstWindow;
|
||||
Sta.Run(service.Poll);
|
||||
|
||||
Assert.Single(world.Changes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void One_change_yields_exactly_one_event()
|
||||
{
|
||||
using var world = new World { LocaleId = English };
|
||||
KeyboardLayoutService service = world.CreateService();
|
||||
|
||||
Sta.Run(service.Start);
|
||||
|
||||
world.LocaleId = Russian;
|
||||
Sta.Run(service.Poll);
|
||||
Sta.Run(service.Poll);
|
||||
Sta.Run(service.Poll);
|
||||
|
||||
Assert.Single(world.Changes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_layout_change_before_the_watch_starts_goes_unnoticed()
|
||||
{
|
||||
using var world = new World { LocaleId = English };
|
||||
KeyboardLayoutService service = world.CreateService();
|
||||
|
||||
world.LocaleId = Russian;
|
||||
Sta.Run(service.Start);
|
||||
Sta.Run(service.Poll);
|
||||
|
||||
// Start remembered the layout that was in place at that moment
|
||||
Assert.Empty(world.Changes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_watch_runs_on_a_timer()
|
||||
{
|
||||
using var world = new World { LocaleId = English };
|
||||
KeyboardLayoutService service = world.CreateService(TimeSpan.FromMilliseconds(15));
|
||||
|
||||
Sta.Run(service.Start);
|
||||
world.LocaleId = Russian;
|
||||
|
||||
Sta.WaitFor(() => !world.Changes.IsEmpty, "the timer noticed the layout change");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Stopping_ends_the_polling()
|
||||
{
|
||||
using var world = new World { LocaleId = English };
|
||||
KeyboardLayoutService service = world.CreateService(TimeSpan.FromMilliseconds(15));
|
||||
|
||||
Sta.Run(service.Start);
|
||||
Sta.Run(service.Stop);
|
||||
|
||||
world.LocaleId = Russian;
|
||||
Sta.Pause(TimeSpan.FromMilliseconds(120));
|
||||
|
||||
Assert.Empty(world.Changes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Closing_ends_the_polling()
|
||||
{
|
||||
using var world = new World { LocaleId = English };
|
||||
KeyboardLayoutService service = world.CreateService(TimeSpan.FromMilliseconds(15));
|
||||
|
||||
Sta.Run(service.Start);
|
||||
Sta.Run(service.Dispose);
|
||||
|
||||
world.LocaleId = Russian;
|
||||
Sta.Pause(TimeSpan.FromMilliseconds(120));
|
||||
|
||||
Assert.Empty(world.Changes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_watch_can_be_resumed()
|
||||
{
|
||||
using var world = new World { LocaleId = English };
|
||||
KeyboardLayoutService service = world.CreateService(TimeSpan.FromMilliseconds(15));
|
||||
|
||||
Sta.Run(service.Start);
|
||||
Sta.Run(service.Stop);
|
||||
Sta.Run(service.Start);
|
||||
|
||||
world.LocaleId = Russian;
|
||||
|
||||
Sta.WaitFor(() => !world.Changes.IsEmpty, "the watch resumed");
|
||||
}
|
||||
|
||||
// The ordinary service asks Windows itself about the layout
|
||||
[Fact]
|
||||
public void The_service_can_work_with_the_real_system()
|
||||
{
|
||||
KeyboardLayoutService service = Sta.Run(() =>
|
||||
new KeyboardLayoutService(new KeyboardLayoutOptions { PollInterval = TimeSpan.FromHours(1) }));
|
||||
|
||||
try
|
||||
{
|
||||
Sta.Run(service.Start);
|
||||
Sta.Run(service.Poll);
|
||||
|
||||
Assert.InRange(service.Current.LocaleId, 1, 0xFFFF);
|
||||
|
||||
Sta.Run(service.Stop);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Sta.Run(service.Dispose);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void By_default_the_poll_runs_more_than_six_times_a_second()
|
||||
{
|
||||
// Any rarer and the tooltip would visibly lag behind the keystroke
|
||||
Assert.True(new KeyboardLayoutOptions().PollInterval <= TimeSpan.FromMilliseconds(150));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The state of the system as the service sees it, and everything the
|
||||
/// service reported about it.
|
||||
/// </summary>
|
||||
private sealed class World : IDisposable
|
||||
{
|
||||
private KeyboardLayoutService? _service;
|
||||
|
||||
internal IntPtr ForegroundWindow { get; set; } = FirstWindow;
|
||||
|
||||
internal int LocaleId { get; set; } = English;
|
||||
|
||||
internal int SwitchRequests { get; private set; }
|
||||
|
||||
internal ConcurrentQueue<LayoutChangedEventArgs> Changes { get; } = new();
|
||||
|
||||
internal KeyboardLayoutService CreateService(TimeSpan? pollInterval = null)
|
||||
{
|
||||
// An hour between ticks means the poll only runs when the test asks for it
|
||||
var options = new KeyboardLayoutOptions
|
||||
{
|
||||
PollInterval = pollInterval ?? TimeSpan.FromHours(1),
|
||||
};
|
||||
|
||||
_service = Sta.Run(() => new KeyboardLayoutService(
|
||||
options,
|
||||
() => ForegroundWindow,
|
||||
() => LocaleId,
|
||||
() => SwitchRequests++));
|
||||
|
||||
_service.LayoutChanged += (_, e) => Changes.Enqueue(e);
|
||||
|
||||
return _service;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_service is not null)
|
||||
{
|
||||
Sta.Run(_service.Dispose);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
using CursorLang.Models;
|
||||
using CursorLang.Services;
|
||||
using CursorLang.Tests.Infrastructure;
|
||||
|
||||
namespace CursorLang.Tests.Services;
|
||||
|
||||
/// <summary>
|
||||
/// The link between watching the layout and showing the tooltip.
|
||||
/// </summary>
|
||||
public sealed class LayoutNotificationCoordinatorTests
|
||||
{
|
||||
private static readonly KeyboardLayout Russian = KeyboardLayout.FromLocaleId(0x0419);
|
||||
|
||||
[Fact]
|
||||
public void Starting_turns_on_the_layout_watch()
|
||||
{
|
||||
var layouts = new FakeKeyboardLayoutService();
|
||||
var popup = new FakeLayoutPopupService();
|
||||
|
||||
using var coordinator = new LayoutNotificationCoordinator(layouts, popup);
|
||||
coordinator.Start();
|
||||
|
||||
Assert.Equal(1, layouts.StartCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_layout_switched_by_the_user_shows_the_tooltip()
|
||||
{
|
||||
var layouts = new FakeKeyboardLayoutService();
|
||||
var popup = new FakeLayoutPopupService();
|
||||
|
||||
using var coordinator = new LayoutNotificationCoordinator(layouts, popup);
|
||||
coordinator.Start();
|
||||
|
||||
layouts.RaiseLayoutChanged(Russian, LayoutChangeReason.UserSwitched);
|
||||
|
||||
Assert.Equal([Russian], popup.Shown);
|
||||
}
|
||||
|
||||
// Moving to another application changes the layout with no user involved,
|
||||
// and the tooltip would be intrusive
|
||||
[Fact]
|
||||
public void Switching_applications_shows_no_tooltip()
|
||||
{
|
||||
var layouts = new FakeKeyboardLayoutService();
|
||||
var popup = new FakeLayoutPopupService();
|
||||
|
||||
using var coordinator = new LayoutNotificationCoordinator(layouts, popup);
|
||||
coordinator.Start();
|
||||
|
||||
layouts.RaiseLayoutChanged(Russian, LayoutChangeReason.ApplicationSwitched);
|
||||
|
||||
Assert.Empty(popup.Shown);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Every_switch_shows_its_own_layout()
|
||||
{
|
||||
var layouts = new FakeKeyboardLayoutService();
|
||||
var popup = new FakeLayoutPopupService();
|
||||
|
||||
using var coordinator = new LayoutNotificationCoordinator(layouts, popup);
|
||||
coordinator.Start();
|
||||
|
||||
KeyboardLayout english = KeyboardLayout.FromLocaleId(0x0409);
|
||||
layouts.RaiseLayoutChanged(Russian, LayoutChangeReason.UserSwitched);
|
||||
layouts.RaiseLayoutChanged(english, LayoutChangeReason.UserSwitched);
|
||||
|
||||
Assert.Equal([Russian, english], popup.Shown);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Before_the_start_no_tooltip_is_shown()
|
||||
{
|
||||
var layouts = new FakeKeyboardLayoutService();
|
||||
var popup = new FakeLayoutPopupService();
|
||||
|
||||
using var coordinator = new LayoutNotificationCoordinator(layouts, popup);
|
||||
|
||||
layouts.RaiseLayoutChanged(Russian, LayoutChangeReason.UserSwitched);
|
||||
|
||||
Assert.Empty(popup.Shown);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Closing_stops_the_watch_and_unsubscribes_from_the_event()
|
||||
{
|
||||
var layouts = new FakeKeyboardLayoutService();
|
||||
var popup = new FakeLayoutPopupService();
|
||||
|
||||
var coordinator = new LayoutNotificationCoordinator(layouts, popup);
|
||||
coordinator.Start();
|
||||
coordinator.Dispose();
|
||||
|
||||
Assert.Equal(1, layouts.StopCalls);
|
||||
Assert.False(layouts.HasSubscribers);
|
||||
|
||||
layouts.RaiseLayoutChanged(Russian, LayoutChangeReason.UserSwitched);
|
||||
Assert.Empty(popup.Shown);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_show_until_hidden_is_not_called_from_here()
|
||||
{
|
||||
var layouts = new FakeKeyboardLayoutService();
|
||||
var popup = new FakeLayoutPopupService();
|
||||
|
||||
using var coordinator = new LayoutNotificationCoordinator(layouts, popup);
|
||||
coordinator.Start();
|
||||
|
||||
layouts.RaiseLayoutChanged(Russian, LayoutChangeReason.UserSwitched);
|
||||
|
||||
Assert.Empty(popup.ShownUntilHidden);
|
||||
Assert.Equal(0, popup.HideCalls);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
using CursorLang.Models;
|
||||
using CursorLang.Services;
|
||||
using CursorLang.Tests.Infrastructure;
|
||||
using CursorLang.ViewModels;
|
||||
|
||||
namespace CursorLang.Tests.Services;
|
||||
|
||||
/// <summary>
|
||||
/// The lifetime of the tooltip. Its timer lives on the interface thread,
|
||||
/// so everything happens there as well.
|
||||
/// </summary>
|
||||
public sealed class LayoutPopupServiceTests
|
||||
{
|
||||
private static readonly KeyboardLayout Russian = KeyboardLayout.FromLocaleId(0x0419);
|
||||
private static readonly KeyboardLayout English = KeyboardLayout.FromLocaleId(0x0409);
|
||||
|
||||
[Fact]
|
||||
public void Showing_puts_out_the_short_name_of_the_layout()
|
||||
{
|
||||
var window = new FakeLayoutPopupWindow();
|
||||
var settings = new AppSettings { DurationMilliseconds = 10_000 };
|
||||
var viewModel = new LayoutPopupViewModel(settings);
|
||||
|
||||
Sta.Run(() =>
|
||||
{
|
||||
using var service = new LayoutPopupService(window, viewModel, settings);
|
||||
service.Show(Russian);
|
||||
});
|
||||
|
||||
Assert.Equal("RU", viewModel.ShortName);
|
||||
Assert.Equal(1, window.ShowCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_tooltip_goes_away_once_its_time_is_up()
|
||||
{
|
||||
var window = new FakeLayoutPopupWindow();
|
||||
var settings = new AppSettings { DurationMilliseconds = 30 };
|
||||
var viewModel = new LayoutPopupViewModel(settings);
|
||||
|
||||
Sta.Run(() =>
|
||||
{
|
||||
using var service = new LayoutPopupService(window, viewModel, settings);
|
||||
service.Show(Russian);
|
||||
|
||||
Assert.Equal(0, window.HideCalls);
|
||||
Sta.WaitFor(() => window.HideCalls == 1, "the tooltip hid itself");
|
||||
});
|
||||
}
|
||||
|
||||
// The duration is read on every show: it is edited in the settings on the fly
|
||||
[Fact]
|
||||
public void A_new_duration_takes_effect_from_the_next_show()
|
||||
{
|
||||
var window = new FakeLayoutPopupWindow();
|
||||
var settings = new AppSettings { DurationMilliseconds = 10_000 };
|
||||
var viewModel = new LayoutPopupViewModel(settings);
|
||||
|
||||
Sta.Run(() =>
|
||||
{
|
||||
using var service = new LayoutPopupService(window, viewModel, settings);
|
||||
service.Show(Russian);
|
||||
|
||||
settings.DurationMilliseconds = 30;
|
||||
service.Show(English);
|
||||
|
||||
Sta.WaitFor(() => window.HideCalls == 1, "the tooltip hid by the new duration");
|
||||
});
|
||||
}
|
||||
|
||||
// Quick switching must not cut the tooltip off mid-word
|
||||
[Fact]
|
||||
public void Showing_again_extends_the_time_on_screen()
|
||||
{
|
||||
var window = new FakeLayoutPopupWindow();
|
||||
var settings = new AppSettings { DurationMilliseconds = 60 };
|
||||
var viewModel = new LayoutPopupViewModel(settings);
|
||||
|
||||
Sta.Run(() =>
|
||||
{
|
||||
using var service = new LayoutPopupService(window, viewModel, settings);
|
||||
|
||||
for (int i = 0; i < 5; i++)
|
||||
{
|
||||
service.Show(i % 2 == 0 ? Russian : English);
|
||||
Sta.Pause(TimeSpan.FromMilliseconds(20));
|
||||
Assert.Equal(0, window.HideCalls);
|
||||
}
|
||||
|
||||
Sta.WaitFor(() => window.HideCalls == 1, "the tooltip hid after the last show");
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_show_until_hidden_does_not_hide_by_itself()
|
||||
{
|
||||
var window = new FakeLayoutPopupWindow();
|
||||
var settings = new AppSettings { DurationMilliseconds = 20 };
|
||||
var viewModel = new LayoutPopupViewModel(settings);
|
||||
|
||||
Sta.Run(() =>
|
||||
{
|
||||
using var service = new LayoutPopupService(window, viewModel, settings);
|
||||
service.ShowUntilHidden(Russian);
|
||||
|
||||
Sta.Pause(TimeSpan.FromMilliseconds(80));
|
||||
|
||||
Assert.Equal(1, window.ShowCalls);
|
||||
Assert.Equal(0, window.HideCalls);
|
||||
|
||||
service.Hide();
|
||||
Assert.Equal(1, window.HideCalls);
|
||||
});
|
||||
}
|
||||
|
||||
// A show until hidden on top of an ordinary one also cancels the countdown
|
||||
[Fact]
|
||||
public void A_show_until_hidden_stops_a_running_countdown()
|
||||
{
|
||||
var window = new FakeLayoutPopupWindow();
|
||||
var settings = new AppSettings { DurationMilliseconds = 30 };
|
||||
var viewModel = new LayoutPopupViewModel(settings);
|
||||
|
||||
Sta.Run(() =>
|
||||
{
|
||||
using var service = new LayoutPopupService(window, viewModel, settings);
|
||||
service.Show(Russian);
|
||||
service.ShowUntilHidden(English);
|
||||
|
||||
Sta.Pause(TimeSpan.FromMilliseconds(100));
|
||||
|
||||
Assert.Equal(0, window.HideCalls);
|
||||
Assert.Equal("EN", viewModel.ShortName);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Hiding_cancels_a_running_countdown()
|
||||
{
|
||||
var window = new FakeLayoutPopupWindow();
|
||||
var settings = new AppSettings { DurationMilliseconds = 30 };
|
||||
var viewModel = new LayoutPopupViewModel(settings);
|
||||
|
||||
Sta.Run(() =>
|
||||
{
|
||||
using var service = new LayoutPopupService(window, viewModel, settings);
|
||||
service.Show(Russian);
|
||||
service.Hide();
|
||||
|
||||
Sta.Pause(TimeSpan.FromMilliseconds(100));
|
||||
|
||||
// There must be no second hide from the timer
|
||||
Assert.Equal(1, window.HideCalls);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Closing_the_service_closes_the_window()
|
||||
{
|
||||
var window = new FakeLayoutPopupWindow();
|
||||
var settings = new AppSettings();
|
||||
var viewModel = new LayoutPopupViewModel(settings);
|
||||
|
||||
Sta.Run(() =>
|
||||
{
|
||||
var service = new LayoutPopupService(window, viewModel, settings);
|
||||
service.Show(Russian);
|
||||
service.Dispose();
|
||||
|
||||
Sta.Pause(TimeSpan.FromMilliseconds(50));
|
||||
});
|
||||
|
||||
Assert.Equal(1, window.CloseCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void After_the_service_is_closed_the_timer_stays_silent()
|
||||
{
|
||||
var window = new FakeLayoutPopupWindow();
|
||||
var settings = new AppSettings { DurationMilliseconds = 20 };
|
||||
var viewModel = new LayoutPopupViewModel(settings);
|
||||
|
||||
Sta.Run(() =>
|
||||
{
|
||||
var service = new LayoutPopupService(window, viewModel, settings);
|
||||
service.Show(Russian);
|
||||
service.Dispose();
|
||||
|
||||
Sta.Pause(TimeSpan.FromMilliseconds(80));
|
||||
|
||||
Assert.Equal(0, window.HideCalls);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Showing_always_comes_before_hiding()
|
||||
{
|
||||
var window = new FakeLayoutPopupWindow();
|
||||
var settings = new AppSettings { DurationMilliseconds = 20 };
|
||||
var viewModel = new LayoutPopupViewModel(settings);
|
||||
|
||||
Sta.Run(() =>
|
||||
{
|
||||
using var service = new LayoutPopupService(window, viewModel, settings);
|
||||
service.Show(Russian);
|
||||
|
||||
Sta.WaitFor(() => window.HideCalls == 1, "the tooltip hid itself");
|
||||
|
||||
Assert.Equal(["show", "hide"], window.Calls);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
using System.ComponentModel;
|
||||
using System.Globalization;
|
||||
using System.Windows.Data;
|
||||
using CursorLang.Services;
|
||||
|
||||
namespace CursorLang.Tests.Services;
|
||||
|
||||
public sealed class LocalizationServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public void The_interface_starts_out_in_English()
|
||||
{
|
||||
Assert.Equal("en", new LocalizationService().CurrentLanguage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_string_comes_from_the_resources_of_the_chosen_language()
|
||||
{
|
||||
var localization = new LocalizationService();
|
||||
|
||||
string english = localization["SettingsTitle"];
|
||||
localization.CurrentLanguage = "ru";
|
||||
string russian = localization["SettingsTitle"];
|
||||
|
||||
Assert.False(string.IsNullOrWhiteSpace(english));
|
||||
Assert.False(string.IsNullOrWhiteSpace(russian));
|
||||
Assert.NotEqual(english, russian);
|
||||
}
|
||||
|
||||
// A missing key shows in the interface but does not bring the app down
|
||||
[Fact]
|
||||
public void An_unknown_key_comes_back_as_is()
|
||||
{
|
||||
var localization = new LocalizationService();
|
||||
|
||||
Assert.Equal("NoSuchKey", localization["NoSuchKey"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_language_change_is_announced_to_subscribers()
|
||||
{
|
||||
var localization = new LocalizationService();
|
||||
List<string?> changed = [];
|
||||
localization.PropertyChanged += (_, e) => changed.Add(e.PropertyName);
|
||||
|
||||
localization.CurrentLanguage = "ru";
|
||||
|
||||
Assert.Contains(nameof(LocalizationService.CurrentLanguage), changed);
|
||||
|
||||
// The indexer is announced separately: that is how the whole text refreshes
|
||||
Assert.Contains(Binding.IndexerName, changed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_same_language_is_not_announced_again()
|
||||
{
|
||||
var localization = new LocalizationService { CurrentLanguage = "ru" };
|
||||
List<string?> changed = [];
|
||||
localization.PropertyChanged += (_, e) => changed.Add(e.PropertyName);
|
||||
|
||||
localization.CurrentLanguage = "ru";
|
||||
|
||||
Assert.Empty(changed);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
[InlineData(null)]
|
||||
public void An_empty_language_changes_nothing(string? value)
|
||||
{
|
||||
var localization = new LocalizationService();
|
||||
List<string?> changed = [];
|
||||
localization.PropertyChanged += (_, e) => changed.Add(e.PropertyName);
|
||||
|
||||
localization.CurrentLanguage = value!;
|
||||
|
||||
Assert.Equal("en", localization.CurrentLanguage);
|
||||
Assert.Empty(changed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_language_with_a_country_falls_back_to_the_language_code()
|
||||
{
|
||||
var localization = new LocalizationService { CurrentLanguage = "ru-RU" };
|
||||
|
||||
Assert.Equal("ru", localization.CurrentLanguage);
|
||||
}
|
||||
|
||||
// Changing the app language has to change the language of the thread as
|
||||
// well: other texts, down to system messages, depend on it
|
||||
[Fact]
|
||||
public void A_language_change_changes_the_language_of_the_thread()
|
||||
{
|
||||
CultureInfo previous = CultureInfo.CurrentUICulture;
|
||||
try
|
||||
{
|
||||
var localization = new LocalizationService { CurrentLanguage = "ru" };
|
||||
|
||||
Assert.Equal("ru", CultureInfo.CurrentUICulture.TwoLetterISOLanguageName);
|
||||
Assert.Equal("ru", localization.CurrentLanguage);
|
||||
}
|
||||
finally
|
||||
{
|
||||
CultureInfo.CurrentUICulture = previous;
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void English_and_Russian_are_offered_for_choosing()
|
||||
{
|
||||
IReadOnlyList<LanguageOption> languages = new LocalizationService().AvailableLanguages;
|
||||
|
||||
Assert.Equal(2, languages.Count);
|
||||
Assert.Contains(languages, language => language.Code == "en");
|
||||
Assert.Contains(languages, language => language.Code == "ru");
|
||||
}
|
||||
|
||||
// A language is named in itself: that way it is recognised even by someone
|
||||
// who does not know the current interface language
|
||||
[Fact]
|
||||
public void The_languages_are_named_in_themselves()
|
||||
{
|
||||
IReadOnlyList<LanguageOption> languages = new LocalizationService().AvailableLanguages;
|
||||
|
||||
Assert.Equal("English", languages.Single(language => language.Code == "en").DisplayName);
|
||||
Assert.Equal("Русский", languages.Single(language => language.Code == "ru").DisplayName);
|
||||
}
|
||||
|
||||
// Accessibility tools take the name of a list item from ToString
|
||||
[Fact]
|
||||
public void A_language_presents_itself_by_its_name()
|
||||
{
|
||||
Assert.Equal("Русский", new LanguageOption("ru", "Русский").ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Languages_with_the_same_code_and_name_are_equal()
|
||||
{
|
||||
Assert.Equal(new LanguageOption("ru", "Русский"), new LanguageOption("ru", "Русский"));
|
||||
Assert.NotEqual(new LanguageOption("ru", "Русский"), new LanguageOption("en", "English"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_service_reports_changes_as_INotifyPropertyChanged()
|
||||
{
|
||||
Assert.IsAssignableFrom<INotifyPropertyChanged>(new LocalizationService());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
using System.Windows;
|
||||
using System.Windows.Interop;
|
||||
using CursorLang.Interop;
|
||||
using CursorLang.Services;
|
||||
using CursorLang.Tests.Infrastructure;
|
||||
|
||||
namespace CursorLang.Tests.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Placing the settings window: the centre maths and bringing the window back
|
||||
/// into the work area.
|
||||
/// </summary>
|
||||
public sealed class MainWindowPlacementTests
|
||||
{
|
||||
private static readonly PopupWindowNative.Rect Work = new()
|
||||
{
|
||||
Left = 0,
|
||||
Top = 0,
|
||||
Right = 1000,
|
||||
Bottom = 800,
|
||||
};
|
||||
|
||||
private static readonly PopupWindowNative.Rect Bounds = new()
|
||||
{
|
||||
Left = 0,
|
||||
Top = 0,
|
||||
Right = 400,
|
||||
Bottom = 300,
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void The_centre_follows_the_window_size_and_the_work_area()
|
||||
{
|
||||
PopupWindowNative.Point point = MainWindowPlacement.Center(Bounds, Work);
|
||||
|
||||
Assert.Equal((1000 - 400) / 2, point.X);
|
||||
Assert.Equal((800 - 300) / 2, point.Y);
|
||||
}
|
||||
|
||||
// The work area of a second monitor does not start at zero
|
||||
[Fact]
|
||||
public void The_centre_of_a_neighbouring_monitor_is_measured_from_its_left_edge()
|
||||
{
|
||||
var work = new PopupWindowNative.Rect { Left = 1920, Top = 100, Right = 3520, Bottom = 1000 };
|
||||
|
||||
PopupWindowNative.Point point = MainWindowPlacement.Center(Bounds, work);
|
||||
|
||||
Assert.Equal(1920 + ((1600 - 400) / 2), point.X);
|
||||
Assert.Equal(100 + ((900 - 300) / 2), point.Y);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_window_inside_the_work_area_stays_where_it_is()
|
||||
{
|
||||
var position = new PopupWindowNative.Point { X = 120, Y = 90 };
|
||||
|
||||
PopupWindowNative.Point clamped = MainWindowPlacement.Clamp(position, Bounds, Work);
|
||||
|
||||
Assert.Equal(120, clamped.X);
|
||||
Assert.Equal(90, clamped.Y);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_window_past_the_right_edge_is_pulled_back_in()
|
||||
{
|
||||
var position = new PopupWindowNative.Point { X = 900, Y = 700 };
|
||||
|
||||
PopupWindowNative.Point clamped = MainWindowPlacement.Clamp(position, Bounds, Work);
|
||||
|
||||
Assert.Equal(1000 - 400, clamped.X);
|
||||
Assert.Equal(800 - 300, clamped.Y);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_window_past_the_left_and_top_edges_is_pulled_back_in()
|
||||
{
|
||||
var position = new PopupWindowNative.Point { X = -500, Y = -400 };
|
||||
|
||||
PopupWindowNative.Point clamped = MainWindowPlacement.Clamp(position, Bounds, Work);
|
||||
|
||||
Assert.Equal(Work.Left, clamped.X);
|
||||
Assert.Equal(Work.Top, clamped.Y);
|
||||
}
|
||||
|
||||
// The window height matches its content and on a short monitor exceeds the
|
||||
// work area. The title bar matters more than the bottom of the window
|
||||
[Fact]
|
||||
public void A_window_taller_than_the_work_area_is_pinned_to_its_top()
|
||||
{
|
||||
var tall = new PopupWindowNative.Rect { Left = 0, Top = 0, Right = 400, Bottom = 900 };
|
||||
var position = new PopupWindowNative.Point { X = 0, Y = 300 };
|
||||
|
||||
PopupWindowNative.Point clamped = MainWindowPlacement.Clamp(position, tall, Work);
|
||||
|
||||
Assert.Equal(Work.Top, clamped.Y);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_window_wider_than_the_work_area_is_pinned_to_its_left_edge()
|
||||
{
|
||||
var wide = new PopupWindowNative.Rect { Left = 0, Top = 0, Right = 1200, Bottom = 300 };
|
||||
var position = new PopupWindowNative.Point { X = 400, Y = 0 };
|
||||
|
||||
PopupWindowNative.Point clamped = MainWindowPlacement.Clamp(position, wide, Work);
|
||||
|
||||
Assert.Equal(Work.Left, clamped.X);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0, 0, 0, 0, true)]
|
||||
[InlineData(0, 0, 100, 0, true)]
|
||||
[InlineData(0, 0, 0, 100, true)]
|
||||
[InlineData(100, 100, 100, 200, true)]
|
||||
[InlineData(0, 0, 1, 1, false)]
|
||||
[InlineData(-100, -100, 100, 100, false)]
|
||||
public void An_area_without_width_or_height_counts_as_empty(
|
||||
int left, int top, int right, int bottom, bool expected)
|
||||
{
|
||||
var rect = new PopupWindowNative.Rect { Left = left, Top = top, Right = right, Bottom = bottom };
|
||||
|
||||
Assert.Equal(expected, MainWindowPlacement.IsEmpty(rect));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_first_time_in_a_session_the_window_lands_centred_on_the_active_monitor()
|
||||
{
|
||||
Sta.Run(() =>
|
||||
{
|
||||
var placement = new MainWindowPlacement();
|
||||
using var window = new TestWindow();
|
||||
|
||||
(PopupWindowNative.Rect before, _) = PopupWindowNative.GetActiveMonitorWorkArea();
|
||||
placement.Apply(window);
|
||||
(PopupWindowNative.Rect work, _) = PopupWindowNative.GetActiveMonitorWorkArea();
|
||||
|
||||
if (!before.Equals(work))
|
||||
{
|
||||
// The user moved to another monitor right during the check
|
||||
Assert.Skip("The active monitor changed while the check was running");
|
||||
}
|
||||
|
||||
PopupWindowNative.Rect bounds = WindowPlacementNative.TryGetBounds(window.Handle)!.Value;
|
||||
|
||||
// Pixel precision: the window goes exactly where it was computed to go
|
||||
PopupWindowNative.Point expected = MainWindowPlacement.Clamp(
|
||||
MainWindowPlacement.Center(bounds, work), bounds, work);
|
||||
|
||||
Assert.Equal(expected.X, bounds.Left);
|
||||
Assert.Equal(expected.Y, bounds.Top);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_window_returns_where_the_user_moved_it()
|
||||
{
|
||||
Sta.Run(() =>
|
||||
{
|
||||
var placement = new MainWindowPlacement();
|
||||
using var window = new TestWindow();
|
||||
|
||||
placement.Attach(window);
|
||||
placement.Apply(window);
|
||||
|
||||
// Move the window the way the user does it with the mouse
|
||||
PopupWindowNative.Rect centered = WindowPlacementNative.TryGetBounds(window.Handle)!.Value;
|
||||
PopupWindowNative.MoveTo(window.Handle, centered.Left + 40, centered.Top + 30);
|
||||
window.RaiseLocationChanged();
|
||||
|
||||
// Showing the window again — it has to stay where it was left
|
||||
placement.Apply(window);
|
||||
|
||||
PopupWindowNative.Rect after = WindowPlacementNative.TryGetBounds(window.Handle)!.Value;
|
||||
Assert.Equal(centered.Left + 40, after.Left);
|
||||
Assert.Equal(centered.Top + 30, after.Top);
|
||||
});
|
||||
}
|
||||
|
||||
// While we move the window ourselves its position must not drift from repeats
|
||||
[Fact]
|
||||
public void Placing_again_does_not_move_the_window()
|
||||
{
|
||||
Sta.Run(() =>
|
||||
{
|
||||
var placement = new MainWindowPlacement();
|
||||
using var window = new TestWindow();
|
||||
|
||||
placement.Attach(window);
|
||||
placement.Apply(window);
|
||||
|
||||
PopupWindowNative.Rect first = WindowPlacementNative.TryGetBounds(window.Handle)!.Value;
|
||||
|
||||
placement.Apply(window);
|
||||
placement.Apply(window);
|
||||
|
||||
PopupWindowNative.Rect third = WindowPlacementNative.TryGetBounds(window.Handle)!.Value;
|
||||
Assert.Equal(first.Left, third.Left);
|
||||
Assert.Equal(first.Top, third.Top);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_minimised_window_is_not_placed()
|
||||
{
|
||||
Sta.Run(() =>
|
||||
{
|
||||
var placement = new MainWindowPlacement();
|
||||
using var window = new TestWindow();
|
||||
|
||||
PopupWindowNative.MoveTo(window.Handle, 7, 9);
|
||||
window.WindowState = WindowState.Minimized;
|
||||
|
||||
placement.Apply(window);
|
||||
|
||||
PopupWindowNative.Rect bounds = WindowPlacementNative.TryGetBounds(window.Handle)!.Value;
|
||||
Assert.Equal(7, bounds.Left);
|
||||
Assert.Equal(9, bounds.Top);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_window_without_a_handle_is_not_placed()
|
||||
{
|
||||
Sta.Run(() =>
|
||||
{
|
||||
var placement = new MainWindowPlacement();
|
||||
var window = new Window { Width = 200, Height = 150 };
|
||||
|
||||
// There must be no exception: the window does not exist yet,
|
||||
// so there is nothing to place
|
||||
placement.Apply(window);
|
||||
|
||||
Assert.Equal(IntPtr.Zero, new WindowInteropHelper(window).Handle);
|
||||
});
|
||||
}
|
||||
|
||||
// The place of the window lives in memory only: the set of monitors may be
|
||||
// different by the next run
|
||||
[Fact]
|
||||
public void Every_placement_starts_its_session_afresh()
|
||||
{
|
||||
Sta.Run(() =>
|
||||
{
|
||||
using var window = new TestWindow();
|
||||
|
||||
var first = new MainWindowPlacement();
|
||||
first.Attach(window);
|
||||
first.Apply(window);
|
||||
|
||||
PopupWindowNative.Rect centered = WindowPlacementNative.TryGetBounds(window.Handle)!.Value;
|
||||
|
||||
PopupWindowNative.MoveTo(window.Handle, centered.Left + 60, centered.Top + 60);
|
||||
window.RaiseLocationChanged();
|
||||
|
||||
// A new placement knows nothing of the earlier move and centres the window again
|
||||
var second = new MainWindowPlacement();
|
||||
second.Apply(window);
|
||||
|
||||
PopupWindowNative.Rect bounds = WindowPlacementNative.TryGetBounds(window.Handle)!.Value;
|
||||
Assert.Equal(centered.Left, bounds.Left);
|
||||
Assert.Equal(centered.Top, bounds.Top);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A window with a ready handle that never appears on screen: placement
|
||||
/// works with the system bounds, and a created window is enough for those.
|
||||
/// </summary>
|
||||
private sealed class TestWindow : Window, IDisposable
|
||||
{
|
||||
internal TestWindow()
|
||||
{
|
||||
Width = 400;
|
||||
Height = 300;
|
||||
ShowInTaskbar = false;
|
||||
WindowStartupLocation = WindowStartupLocation.Manual;
|
||||
|
||||
Handle = new WindowInteropHelper(this).EnsureHandle();
|
||||
}
|
||||
|
||||
internal IntPtr Handle { get; }
|
||||
|
||||
/// <summary>Reports a move the way WPF does after the user acts.</summary>
|
||||
internal void RaiseLocationChanged() => OnLocationChanged(EventArgs.Empty);
|
||||
|
||||
public void Dispose() => Close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
using CursorLang.Interop;
|
||||
using CursorLang.Models;
|
||||
using CursorLang.Services;
|
||||
|
||||
namespace CursorLang.Tests.Services;
|
||||
|
||||
/// <summary>
|
||||
/// The placement maths for the tooltip. This is the easiest place to get a sign
|
||||
/// or half a size wrong, and on screen such a mistake is only visible by eye.
|
||||
/// </summary>
|
||||
public sealed class PopupLayoutTests
|
||||
{
|
||||
// The anchor: 100..140 horizontally, 200..220 vertically
|
||||
private static readonly PopupWindowNative.Rect Anchor = new()
|
||||
{
|
||||
Left = 100,
|
||||
Top = 200,
|
||||
Right = 140,
|
||||
Bottom = 220,
|
||||
};
|
||||
|
||||
private const int Offset = 10;
|
||||
private const int Width = 30;
|
||||
private const int Height = 16;
|
||||
|
||||
[Fact]
|
||||
public void Bottom_right_offsets_the_tooltip_from_the_bottom_right_corner()
|
||||
{
|
||||
PopupWindowNative.Point point = Near(AnchorSide.BottomRight);
|
||||
|
||||
Assert.Equal(140 + 10, point.X);
|
||||
Assert.Equal(220 + 10, point.Y);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Bottom_left_fits_the_tooltip_to_the_left_of_the_anchor()
|
||||
{
|
||||
PopupWindowNative.Point point = Near(AnchorSide.BottomLeft);
|
||||
|
||||
Assert.Equal(100 - 10 - Width, point.X);
|
||||
Assert.Equal(220 + 10, point.Y);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Top_right_fits_the_tooltip_above_the_anchor()
|
||||
{
|
||||
PopupWindowNative.Point point = Near(AnchorSide.TopRight);
|
||||
|
||||
Assert.Equal(140 + 10, point.X);
|
||||
Assert.Equal(200 - 10 - Height, point.Y);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Top_left_fits_the_tooltip_both_left_of_and_above_the_anchor()
|
||||
{
|
||||
PopupWindowNative.Point point = Near(AnchorSide.TopLeft);
|
||||
|
||||
Assert.Equal(100 - 10 - Width, point.X);
|
||||
Assert.Equal(200 - 10 - Height, point.Y);
|
||||
}
|
||||
|
||||
// At the sides the tooltip lines up with the middle of the anchor
|
||||
[Fact]
|
||||
public void On_the_right_the_tooltip_lines_up_with_the_anchor()
|
||||
{
|
||||
PopupWindowNative.Point point = Near(AnchorSide.Right);
|
||||
|
||||
Assert.Equal(140 + 10, point.X);
|
||||
Assert.Equal(200 + ((20 - Height) / 2), point.Y);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void On_the_left_the_tooltip_lines_up_with_the_anchor()
|
||||
{
|
||||
PopupWindowNative.Point point = Near(AnchorSide.Left);
|
||||
|
||||
Assert.Equal(100 - 10 - Width, point.X);
|
||||
Assert.Equal(200 + ((20 - Height) / 2), point.Y);
|
||||
}
|
||||
|
||||
// A tooltip taller than the input field: the middle is measured from the
|
||||
// anchor, not from zero
|
||||
[Fact]
|
||||
public void At_the_side_a_tooltip_taller_than_the_anchor_rises_above_it()
|
||||
{
|
||||
PopupWindowNative.Point point =
|
||||
PopupLayout.NearAnchor(Anchor, AnchorSide.Right, Offset, Width, height: 40);
|
||||
|
||||
Assert.Equal(200 + ((20 - 40) / 2), point.Y);
|
||||
Assert.True(point.Y < Anchor.Top);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_cursor_anchors_as_a_rectangle_of_zero_size()
|
||||
{
|
||||
PopupWindowNative.Rect anchor = PopupLayout.AsAnchor(new PopupWindowNative.Point { X = 50, Y = 60 });
|
||||
|
||||
Assert.Equal(50, anchor.Left);
|
||||
Assert.Equal(50, anchor.Right);
|
||||
Assert.Equal(60, anchor.Top);
|
||||
Assert.Equal(60, anchor.Bottom);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void At_the_cursor_both_sides_are_measured_from_the_same_point()
|
||||
{
|
||||
PopupWindowNative.Rect cursor = PopupLayout.AsAnchor(new PopupWindowNative.Point { X = 50, Y = 60 });
|
||||
|
||||
PopupWindowNative.Point bottomRight =
|
||||
PopupLayout.NearAnchor(cursor, AnchorSide.BottomRight, Offset, Width, Height);
|
||||
PopupWindowNative.Point topLeft =
|
||||
PopupLayout.NearAnchor(cursor, AnchorSide.TopLeft, Offset, Width, Height);
|
||||
|
||||
Assert.Equal(60, bottomRight.X);
|
||||
Assert.Equal(70, bottomRight.Y);
|
||||
Assert.Equal(50 - 10 - Width, topLeft.X);
|
||||
Assert.Equal(60 - 10 - Height, topLeft.Y);
|
||||
}
|
||||
|
||||
// A monitor to the left of the primary one gives negative coordinates — that is normal
|
||||
[Fact]
|
||||
public void Negative_coordinates_of_a_neighbouring_monitor_are_allowed()
|
||||
{
|
||||
var anchor = new PopupWindowNative.Rect { Left = -800, Top = -200, Right = -800, Bottom = -200 };
|
||||
|
||||
PopupWindowNative.Point point =
|
||||
PopupLayout.NearAnchor(anchor, AnchorSide.BottomRight, Offset, Width, Height);
|
||||
|
||||
Assert.Equal(-790, point.X);
|
||||
Assert.Equal(-190, point.Y);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_zero_offset_puts_the_tooltip_flush_against_the_anchor()
|
||||
{
|
||||
PopupWindowNative.Point point =
|
||||
PopupLayout.NearAnchor(Anchor, AnchorSide.BottomRight, offset: 0, Width, Height);
|
||||
|
||||
Assert.Equal(Anchor.Right, point.X);
|
||||
Assert.Equal(Anchor.Bottom, point.Y);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(AnchorSide.TopLeft)]
|
||||
[InlineData(AnchorSide.TopRight)]
|
||||
[InlineData(AnchorSide.Left)]
|
||||
[InlineData(AnchorSide.Right)]
|
||||
[InlineData(AnchorSide.BottomLeft)]
|
||||
[InlineData(AnchorSide.BottomRight)]
|
||||
public void No_side_is_left_behind(AnchorSide side)
|
||||
{
|
||||
// The sides are handled by a switch expression with a fallback branch:
|
||||
// each of them has to get its own place, not the shared "bottom right"
|
||||
PopupWindowNative.Point point = Near(side);
|
||||
PopupWindowNative.Point bottomRight = Near(AnchorSide.BottomRight);
|
||||
|
||||
if (side != AnchorSide.BottomRight)
|
||||
{
|
||||
Assert.True(point.X != bottomRight.X || point.Y != bottomRight.Y);
|
||||
}
|
||||
}
|
||||
|
||||
public static TheoryData<ScreenPosition, int, int> ScreenCases() => new()
|
||||
{
|
||||
// A work area of 0..1000 horizontally and 0..800 vertically, margin 20
|
||||
{ ScreenPosition.TopLeft, 20, 20 },
|
||||
{ ScreenPosition.Top, (1000 - Width) / 2, 20 },
|
||||
{ ScreenPosition.TopRight, 1000 - 20 - Width, 20 },
|
||||
{ ScreenPosition.Center, (1000 - Width) / 2, (800 - Height) / 2 },
|
||||
{ ScreenPosition.BottomLeft, 20, 800 - 20 - Height },
|
||||
{ ScreenPosition.Bottom, (1000 - Width) / 2, 800 - 20 - Height },
|
||||
{ ScreenPosition.BottomRight, 1000 - 20 - Width, 800 - 20 - Height },
|
||||
};
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(ScreenCases))]
|
||||
public void The_place_on_the_monitor_is_measured_from_the_work_area(
|
||||
ScreenPosition position, int expectedX, int expectedY)
|
||||
{
|
||||
var work = new PopupWindowNative.Rect { Left = 0, Top = 0, Right = 1000, Bottom = 800 };
|
||||
|
||||
PopupWindowNative.Point point = PopupLayout.OnScreen(work, position, margin: 20, Width, Height);
|
||||
|
||||
Assert.Equal(expectedX, point.X);
|
||||
Assert.Equal(expectedY, point.Y);
|
||||
}
|
||||
|
||||
// The work area of a second monitor does not start at zero, and the taskbar
|
||||
// takes its bottom away — the place is measured from those bounds
|
||||
[Fact]
|
||||
public void The_place_on_a_neighbouring_monitor_is_measured_from_its_own_bounds()
|
||||
{
|
||||
var work = new PopupWindowNative.Rect { Left = 1920, Top = 0, Right = 3520, Bottom = 860 };
|
||||
|
||||
PopupWindowNative.Point point =
|
||||
PopupLayout.OnScreen(work, ScreenPosition.BottomRight, margin: 20, Width, Height);
|
||||
|
||||
Assert.Equal(3520 - 20 - Width, point.X);
|
||||
Assert.Equal(860 - 20 - Height, point.Y);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void In_the_centre_of_the_monitor_the_margin_is_ignored()
|
||||
{
|
||||
var work = new PopupWindowNative.Rect { Left = 0, Top = 0, Right = 1000, Bottom = 800 };
|
||||
|
||||
PopupWindowNative.Point withMargin = PopupLayout.OnScreen(work, ScreenPosition.Center, 20, Width, Height);
|
||||
PopupWindowNative.Point withoutMargin = PopupLayout.OnScreen(work, ScreenPosition.Center, 0, Width, Height);
|
||||
|
||||
Assert.Equal(withoutMargin.X, withMargin.X);
|
||||
Assert.Equal(withoutMargin.Y, withMargin.Y);
|
||||
}
|
||||
|
||||
private static PopupWindowNative.Point Near(AnchorSide side) =>
|
||||
PopupLayout.NearAnchor(Anchor, side, Offset, Width, Height);
|
||||
|
||||
[Theory]
|
||||
[InlineData(16, 1.0, 16)]
|
||||
[InlineData(16, 1.25, 20)]
|
||||
[InlineData(16, 1.5, 24)]
|
||||
[InlineData(16, 2.0, 32)]
|
||||
[InlineData(0, 2.0, 0)]
|
||||
[InlineData(20.4, 1.0, 20)]
|
||||
[InlineData(20.6, 1.0, 21)]
|
||||
public void WPF_units_turn_into_pixels_by_the_scale(double units, double scale, int expected)
|
||||
{
|
||||
Assert.Equal(expected, PopupLayout.ToPixels(units, scale));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
using CursorLang.Models;
|
||||
using CursorLang.Services;
|
||||
using CursorLang.Tests.Infrastructure;
|
||||
using Microsoft.Win32;
|
||||
|
||||
namespace CursorLang.Tests.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Startup of a build unpacked into a folder: a value under the Run key. The tests
|
||||
/// keep to a root of their own, so the startup list of the machine is untouched.
|
||||
/// </summary>
|
||||
public sealed class RegistryStartupTests
|
||||
{
|
||||
private const string RunPath = @"Software\Microsoft\Windows\CurrentVersion\Run";
|
||||
|
||||
private const string ApprovedPath =
|
||||
@"Software\Microsoft\Windows\CurrentVersion\Explorer\StartupApproved\Run";
|
||||
|
||||
private const string ValueName = "CursorLang";
|
||||
|
||||
private const string Command = @"""C:\Apps\CursorLang\CursorLang.exe""";
|
||||
|
||||
[Fact]
|
||||
public void With_nothing_written_down_startup_is_off()
|
||||
{
|
||||
using var root = new TempRegistryKey();
|
||||
|
||||
Assert.Equal(StartupState.Disabled, new RegistryStartup(root.Key, Command).GetState());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Switching_startup_on_writes_the_path_of_the_app()
|
||||
{
|
||||
using var root = new TempRegistryKey();
|
||||
var startup = new RegistryStartup(root.Key, Command);
|
||||
|
||||
Assert.Equal(StartupState.Enabled, startup.SetEnabled(true));
|
||||
Assert.Equal(Command, ReadRunValue(root));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Switching_startup_off_takes_the_entry_away()
|
||||
{
|
||||
using var root = new TempRegistryKey();
|
||||
var startup = new RegistryStartup(root.Key, Command);
|
||||
|
||||
startup.SetEnabled(true);
|
||||
|
||||
Assert.Equal(StartupState.Disabled, startup.SetEnabled(false));
|
||||
Assert.Null(ReadRunValue(root));
|
||||
}
|
||||
|
||||
// Switching off what is already off is what happens when Windows and the app
|
||||
// disagree about the state, and it is no reason to fail
|
||||
[Fact]
|
||||
public void Switching_off_startup_that_is_already_off_passes_quietly()
|
||||
{
|
||||
using var root = new TempRegistryKey();
|
||||
|
||||
Assert.Equal(StartupState.Disabled, new RegistryStartup(root.Key, Command).SetEnabled(false));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_path_is_written_afresh_every_time()
|
||||
{
|
||||
using var root = new TempRegistryKey();
|
||||
|
||||
new RegistryStartup(root.Key, @"""C:\Old\CursorLang.exe""").SetEnabled(true);
|
||||
new RegistryStartup(root.Key, Command).SetEnabled(true);
|
||||
|
||||
Assert.Equal(Command, ReadRunValue(root));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void An_entry_the_user_has_banned_counts_as_off()
|
||||
{
|
||||
using var root = new TempRegistryKey();
|
||||
var startup = new RegistryStartup(root.Key, Command);
|
||||
|
||||
startup.SetEnabled(true);
|
||||
Ban(root);
|
||||
|
||||
Assert.Equal(StartupState.DisabledByUser, startup.GetState());
|
||||
}
|
||||
|
||||
// The ban outlives the request: the entry is written, and Windows still ignores it
|
||||
[Fact]
|
||||
public void The_ban_of_the_user_survives_a_request_to_switch_startup_on()
|
||||
{
|
||||
using var root = new TempRegistryKey();
|
||||
var startup = new RegistryStartup(root.Key, Command);
|
||||
|
||||
Ban(root);
|
||||
|
||||
Assert.Equal(StartupState.DisabledByUser, startup.SetEnabled(true));
|
||||
Assert.Equal(Command, ReadRunValue(root));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_verdict_of_the_user_in_favour_leaves_startup_on()
|
||||
{
|
||||
using var root = new TempRegistryKey();
|
||||
var startup = new RegistryStartup(root.Key, Command);
|
||||
|
||||
startup.SetEnabled(true);
|
||||
WriteVerdict(root, 0x02);
|
||||
|
||||
Assert.Equal(StartupState.Enabled, startup.GetState());
|
||||
}
|
||||
|
||||
// An empty blob is not a ban: Windows writes twelve bytes, but a value cut
|
||||
// short says nothing about the will of the user
|
||||
[Fact]
|
||||
public void A_verdict_with_no_bytes_in_it_is_no_ban()
|
||||
{
|
||||
using var root = new TempRegistryKey();
|
||||
var startup = new RegistryStartup(root.Key, Command);
|
||||
|
||||
startup.SetEnabled(true);
|
||||
|
||||
using RegistryKey approved = root.Key.CreateSubKey(ApprovedPath);
|
||||
approved.SetValue(ValueName, Array.Empty<byte>(), RegistryValueKind.Binary);
|
||||
|
||||
Assert.Equal(StartupState.Enabled, startup.GetState());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void With_no_path_to_the_app_startup_is_unavailable()
|
||||
{
|
||||
using var root = new TempRegistryKey();
|
||||
var startup = new RegistryStartup(root.Key, command: null);
|
||||
|
||||
Assert.Equal(StartupState.Unavailable, startup.GetState());
|
||||
Assert.Equal(StartupState.Unavailable, startup.SetEnabled(true));
|
||||
Assert.Equal(StartupState.Unavailable, startup.SetEnabled(false));
|
||||
Assert.Null(ReadRunValue(root));
|
||||
}
|
||||
|
||||
private static string? ReadRunValue(TempRegistryKey root)
|
||||
{
|
||||
using RegistryKey? run = root.Key.OpenSubKey(RunPath);
|
||||
return run?.GetValue(ValueName) as string;
|
||||
}
|
||||
|
||||
/// <summary>The mark Windows leaves after the user switches the entry off.</summary>
|
||||
private static void Ban(TempRegistryKey root) => WriteVerdict(root, 0x03);
|
||||
|
||||
private static void WriteVerdict(TempRegistryKey root, byte first)
|
||||
{
|
||||
var verdict = new byte[12];
|
||||
verdict[0] = first;
|
||||
|
||||
using RegistryKey approved = root.Key.CreateSubKey(ApprovedPath);
|
||||
approved.SetValue(ValueName, verdict, RegistryValueKind.Binary);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,454 @@
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
using System.Windows.Media;
|
||||
using CursorLang.Models;
|
||||
using CursorLang.Services;
|
||||
using CursorLang.Tests.Infrastructure;
|
||||
using CursorLang.Tests.Models;
|
||||
|
||||
namespace CursorLang.Tests.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Keeping the settings in a file. Everything happens in a temporary folder:
|
||||
/// the tests have no business touching the user's own settings.
|
||||
/// </summary>
|
||||
public sealed class SettingsServiceTests
|
||||
{
|
||||
/// <summary>The deferred write delay in tests: half a second is not worth waiting for.</summary>
|
||||
private static readonly TimeSpan SaveDelay = TimeSpan.FromMilliseconds(20);
|
||||
|
||||
[Fact]
|
||||
public void Without_a_file_the_defaults_are_handed_out()
|
||||
{
|
||||
using var folder = new TempFolder();
|
||||
|
||||
AppSettings settings = Sta.Run(() =>
|
||||
{
|
||||
using SettingsService service = Create(folder);
|
||||
return service.Load();
|
||||
});
|
||||
|
||||
Assert.Equal(AppTheme.System, settings.Theme);
|
||||
Assert.Equal(20, settings.FontSize);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("ru", "ru")]
|
||||
[InlineData("ru-RU", "ru")]
|
||||
[InlineData("en-US", "en")]
|
||||
[InlineData("de-DE", "en")]
|
||||
[InlineData("fr", "en")]
|
||||
public void The_default_language_follows_the_language_of_Windows(string uiCulture, string expected)
|
||||
{
|
||||
using var folder = new TempFolder();
|
||||
|
||||
AppSettings settings = Sta.Run(() =>
|
||||
{
|
||||
CultureInfo previous = CultureInfo.CurrentUICulture;
|
||||
try
|
||||
{
|
||||
CultureInfo.CurrentUICulture = CultureInfo.GetCultureInfo(uiCulture);
|
||||
|
||||
using SettingsService service = Create(folder);
|
||||
return service.Load();
|
||||
}
|
||||
finally
|
||||
{
|
||||
CultureInfo.CurrentUICulture = previous;
|
||||
}
|
||||
});
|
||||
|
||||
Assert.Equal(expected, settings.Language);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Saved_settings_are_read_back()
|
||||
{
|
||||
using var folder = new TempFolder();
|
||||
|
||||
Sta.Run(() =>
|
||||
{
|
||||
using SettingsService service = Create(folder);
|
||||
AppSettings settings = service.Load();
|
||||
|
||||
settings.FontSize = 42;
|
||||
settings.Theme = AppTheme.Dark;
|
||||
settings.PlacementMode = PopupPlacementMode.AtCaret;
|
||||
settings.BackgroundColor = Color.FromRgb(0x11, 0x22, 0x33);
|
||||
settings.UseCapsLockHotkey = true;
|
||||
|
||||
service.Save();
|
||||
});
|
||||
|
||||
AppSettings restored = Sta.Run(() =>
|
||||
{
|
||||
using SettingsService service = Create(folder);
|
||||
return service.Load();
|
||||
});
|
||||
|
||||
Assert.Equal(42, restored.FontSize);
|
||||
Assert.Equal(AppTheme.Dark, restored.Theme);
|
||||
Assert.Equal(PopupPlacementMode.AtCaret, restored.PlacementMode);
|
||||
Assert.Equal(Color.FromRgb(0x11, 0x22, 0x33), restored.BackgroundColor);
|
||||
Assert.True(restored.UseCapsLockHotkey);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_settings_land_in_the_file_in_a_readable_form()
|
||||
{
|
||||
using var folder = new TempFolder();
|
||||
|
||||
Sta.Run(() =>
|
||||
{
|
||||
using SettingsService service = Create(folder);
|
||||
AppSettings settings = service.Load();
|
||||
settings.Theme = AppTheme.Dark;
|
||||
settings.BackgroundColor = Color.FromRgb(0x20, 0x20, 0x20);
|
||||
service.Save();
|
||||
});
|
||||
|
||||
string json = File.ReadAllText(folder.File("settings.json"));
|
||||
|
||||
// The theme as a word rather than a number; the colour in its usual notation
|
||||
Assert.Contains("\"Theme\": \"Dark\"", json, StringComparison.Ordinal);
|
||||
Assert.Contains("#FF202020", json, StringComparison.Ordinal);
|
||||
|
||||
// And all of it across lines: the file is sometimes edited by hand
|
||||
Assert.Contains('\n', json);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_changed_setting_saves_itself()
|
||||
{
|
||||
using var folder = new TempFolder();
|
||||
string path = folder.File("settings.json");
|
||||
|
||||
Sta.Run(() =>
|
||||
{
|
||||
using SettingsService service = Create(folder);
|
||||
AppSettings settings = service.Load();
|
||||
|
||||
settings.FontSize = 33;
|
||||
|
||||
// Right after the edit there is nothing on disk yet: the write is deferred
|
||||
Assert.False(File.Exists(path));
|
||||
|
||||
Sta.WaitFor(() => File.Exists(path), "the settings were written by the timer");
|
||||
});
|
||||
|
||||
Assert.Contains("\"FontSize\": 33", File.ReadAllText(path), StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
// A slider changes its value continuously, and writing every move to disk is pointless
|
||||
[Fact]
|
||||
public void A_run_of_edits_defers_the_write_until_a_pause()
|
||||
{
|
||||
using var folder = new TempFolder();
|
||||
string path = folder.File("settings.json");
|
||||
|
||||
Sta.Run(() =>
|
||||
{
|
||||
using SettingsService service = new(path, folder.File("inherited.json"), TimeSpan.FromMilliseconds(150));
|
||||
AppSettings settings = service.Load();
|
||||
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
settings.Opacity = 0.5 + (i * 0.01);
|
||||
Assert.False(File.Exists(path));
|
||||
Sta.Pause(TimeSpan.FromMilliseconds(10));
|
||||
}
|
||||
|
||||
Sta.WaitFor(() => File.Exists(path), "the write happened after the pause in edits");
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Closing_the_service_saves_the_latest_edits()
|
||||
{
|
||||
using var folder = new TempFolder();
|
||||
|
||||
Sta.Run(() =>
|
||||
{
|
||||
SettingsService service = Create(folder);
|
||||
AppSettings settings = service.Load();
|
||||
settings.FontSize = 27;
|
||||
|
||||
service.Dispose();
|
||||
});
|
||||
|
||||
Assert.Contains(
|
||||
"\"FontSize\": 27",
|
||||
File.ReadAllText(folder.File("settings.json")),
|
||||
StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void After_closing_edits_no_longer_reach_the_disk()
|
||||
{
|
||||
using var folder = new TempFolder();
|
||||
string path = folder.File("settings.json");
|
||||
|
||||
Sta.Run(() =>
|
||||
{
|
||||
SettingsService service = Create(folder);
|
||||
AppSettings settings = service.Load();
|
||||
service.Dispose();
|
||||
|
||||
string afterDispose = File.ReadAllText(path);
|
||||
|
||||
settings.FontSize = 99;
|
||||
Sta.Pause(TimeSpan.FromMilliseconds(60));
|
||||
|
||||
Assert.Equal(afterDispose, File.ReadAllText(path));
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Settings_of_a_previous_install_are_taken_over()
|
||||
{
|
||||
using var folder = new TempFolder();
|
||||
string inherited = folder.File("inherited.json");
|
||||
string own = folder.File("settings.json");
|
||||
|
||||
File.WriteAllText(inherited, """{"FontSize": 31, "Language": "ru"}""");
|
||||
|
||||
AppSettings settings = Sta.Run(() =>
|
||||
{
|
||||
using SettingsService service = new(own, inherited, SaveDelay);
|
||||
return service.Load();
|
||||
});
|
||||
|
||||
Assert.Equal(31, settings.FontSize);
|
||||
Assert.Equal("ru", settings.Language);
|
||||
|
||||
// What was taken over is pinned to its new place at once rather than on the first edit
|
||||
Assert.True(File.Exists(own));
|
||||
Assert.Contains("\"FontSize\": 31", File.ReadAllText(own), StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
// Both builds may be installed side by side: the other one keeps its settings
|
||||
[Fact]
|
||||
public void The_previous_install_does_not_lose_its_settings()
|
||||
{
|
||||
using var folder = new TempFolder();
|
||||
string inherited = folder.File("inherited.json");
|
||||
string original = """{"FontSize": 31}""";
|
||||
|
||||
File.WriteAllText(inherited, original);
|
||||
|
||||
Sta.Run(() =>
|
||||
{
|
||||
using SettingsService service = new(folder.File("settings.json"), inherited, SaveDelay);
|
||||
_ = service.Load();
|
||||
});
|
||||
|
||||
Assert.Equal(original, File.ReadAllText(inherited));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Own_settings_outweigh_those_of_a_previous_install()
|
||||
{
|
||||
using var folder = new TempFolder();
|
||||
string own = folder.File("settings.json");
|
||||
string inherited = folder.File("inherited.json");
|
||||
|
||||
File.WriteAllText(own, """{"FontSize": 12}""");
|
||||
File.WriteAllText(inherited, """{"FontSize": 31}""");
|
||||
|
||||
AppSettings settings = Sta.Run(() =>
|
||||
{
|
||||
using SettingsService service = new(own, inherited, SaveDelay);
|
||||
return service.Load();
|
||||
});
|
||||
|
||||
Assert.Equal(12, settings.FontSize);
|
||||
}
|
||||
|
||||
// Outside a package both paths are the same, so there is nothing to take over
|
||||
[Fact]
|
||||
public void Without_a_package_no_settings_are_taken_over()
|
||||
{
|
||||
using var folder = new TempFolder();
|
||||
string path = folder.File("settings.json");
|
||||
|
||||
Sta.Run(() =>
|
||||
{
|
||||
using SettingsService service = new(path, path, SaveDelay);
|
||||
_ = service.Load();
|
||||
|
||||
// No file appeared: there was nothing to take over and nowhere to take it from
|
||||
Assert.False(File.Exists(path));
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_broken_settings_file_does_not_bring_the_app_down()
|
||||
{
|
||||
using var folder = new TempFolder();
|
||||
File.WriteAllText(folder.File("settings.json"), "{this is not json");
|
||||
|
||||
AppSettings settings = Sta.Run(() =>
|
||||
{
|
||||
using SettingsService service = Create(folder);
|
||||
return service.Load();
|
||||
});
|
||||
|
||||
Assert.Equal(20, settings.FontSize);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Settings_with_unknown_fields_are_still_read()
|
||||
{
|
||||
using var folder = new TempFolder();
|
||||
File.WriteAllText(folder.File("settings.json"), """{"FontSize": 15, "SomethingNew": true}""");
|
||||
|
||||
AppSettings settings = Sta.Run(() =>
|
||||
{
|
||||
using SettingsService service = Create(folder);
|
||||
return service.Load();
|
||||
});
|
||||
|
||||
Assert.Equal(15, settings.FontSize);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("\"#FF102030\"", 0x10, 0x20, 0x30)]
|
||||
[InlineData("\"#102030\"", 0x10, 0x20, 0x30)]
|
||||
[InlineData("\"Red\"", 0xFF, 0x00, 0x00)]
|
||||
public void A_colour_is_read_from_its_usual_notation(string stored, byte r, byte g, byte b)
|
||||
{
|
||||
using var folder = new TempFolder();
|
||||
File.WriteAllText(folder.File("settings.json"), $$"""{"BackgroundColor": {{stored}}}""");
|
||||
|
||||
AppSettings settings = Sta.Run(() =>
|
||||
{
|
||||
using SettingsService service = Create(folder);
|
||||
return service.Load();
|
||||
});
|
||||
|
||||
Assert.Equal(Color.FromRgb(r, g, b), settings.BackgroundColor);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("\"\"")]
|
||||
[InlineData("\" \"")]
|
||||
[InlineData("\"not a colour\"")]
|
||||
public void An_unintelligible_colour_becomes_black(string stored)
|
||||
{
|
||||
using var folder = new TempFolder();
|
||||
File.WriteAllText(folder.File("settings.json"), $$"""{"BackgroundColor": {{stored}}}""");
|
||||
|
||||
AppSettings settings = Sta.Run(() =>
|
||||
{
|
||||
using SettingsService service = Create(folder);
|
||||
return service.Load();
|
||||
});
|
||||
|
||||
Assert.Equal(Colors.Black, settings.BackgroundColor);
|
||||
}
|
||||
|
||||
// The service creates the settings folder itself
|
||||
[Fact]
|
||||
public void The_settings_folder_is_created_on_write()
|
||||
{
|
||||
using var folder = new TempFolder();
|
||||
string nested = Path.Combine(folder.Path, "a", "b", "settings.json");
|
||||
|
||||
Sta.Run(() =>
|
||||
{
|
||||
using SettingsService service = new(nested, folder.File("inherited.json"), SaveDelay);
|
||||
_ = service.Load();
|
||||
service.Save();
|
||||
});
|
||||
|
||||
Assert.True(File.Exists(nested));
|
||||
}
|
||||
|
||||
// Settings are not the kind of thing worth bringing the app down for
|
||||
[Fact]
|
||||
public void A_path_that_cannot_be_written_does_not_bring_the_app_down()
|
||||
{
|
||||
using var folder = new TempFolder();
|
||||
|
||||
// A folder sits where the settings file should be: writing there will not work
|
||||
string path = folder.File("settings.json");
|
||||
Directory.CreateDirectory(path);
|
||||
|
||||
Sta.Run(() =>
|
||||
{
|
||||
using SettingsService service = new(path, folder.File("inherited.json"), SaveDelay);
|
||||
AppSettings settings = service.Load();
|
||||
|
||||
settings.FontSize = 18;
|
||||
service.Save();
|
||||
});
|
||||
|
||||
Assert.True(Directory.Exists(path));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Saving_without_loading_writes_nothing()
|
||||
{
|
||||
using var folder = new TempFolder();
|
||||
|
||||
Sta.Run(() =>
|
||||
{
|
||||
using SettingsService service = Create(folder);
|
||||
service.Save();
|
||||
});
|
||||
|
||||
Assert.False(File.Exists(folder.File("settings.json")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Closing_without_loading_passes_without_consequence()
|
||||
{
|
||||
using var folder = new TempFolder();
|
||||
|
||||
Sta.Run(() =>
|
||||
{
|
||||
SettingsService service = Create(folder);
|
||||
service.Dispose();
|
||||
});
|
||||
|
||||
Assert.False(File.Exists(folder.File("settings.json")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Every_setting_of_the_app_reaches_the_file()
|
||||
{
|
||||
using var folder = new TempFolder();
|
||||
|
||||
Sta.Run(() =>
|
||||
{
|
||||
using SettingsService service = Create(folder);
|
||||
_ = service.Load();
|
||||
service.Save();
|
||||
});
|
||||
|
||||
using JsonDocument document = JsonDocument.Parse(File.ReadAllText(folder.File("settings.json")));
|
||||
List<string> stored = [.. document.RootElement.EnumerateObject().Select(property => property.Name)];
|
||||
|
||||
foreach (string name in AppSettingsTests.WritablePropertyNames())
|
||||
{
|
||||
Assert.Contains(name, stored);
|
||||
}
|
||||
}
|
||||
|
||||
// An ordinary run picks the storage place itself: a package keeps settings
|
||||
// of its own, a separate install keeps them in the user profile
|
||||
[Fact]
|
||||
public void The_storage_place_is_chosen_on_its_own()
|
||||
{
|
||||
Sta.Run(() =>
|
||||
{
|
||||
// Nothing is read and nothing is written: only the fact that a path
|
||||
// gets chosen without error is under test
|
||||
using var service = new SettingsService();
|
||||
});
|
||||
}
|
||||
|
||||
private static SettingsService Create(TempFolder folder) =>
|
||||
new(folder.File("settings.json"), folder.File("inherited.json"), SaveDelay);
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
using System.Collections.Concurrent;
|
||||
using CursorLang.Services;
|
||||
using CursorLang.Tests.Infrastructure;
|
||||
|
||||
namespace CursorLang.Tests.Services;
|
||||
|
||||
/// <summary>
|
||||
/// The place of the single instance. The kernel object names in the tests are
|
||||
/// their own: sharing them with a running application is not an option.
|
||||
/// </summary>
|
||||
public sealed class SingleInstanceGateTests
|
||||
{
|
||||
[Fact]
|
||||
public void The_first_run_takes_the_place()
|
||||
{
|
||||
string suffix = UniqueSuffix();
|
||||
SingleInstanceGate gate = Sta.Run(() => new SingleInstanceGate(suffix));
|
||||
|
||||
try
|
||||
{
|
||||
Assert.True(Sta.Run(gate.TryAcquire));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Sta.Run(gate.Dispose);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_second_run_does_not_get_the_place()
|
||||
{
|
||||
string suffix = UniqueSuffix();
|
||||
SingleInstanceGate first = Sta.Run(() => new SingleInstanceGate(suffix));
|
||||
|
||||
try
|
||||
{
|
||||
Assert.True(Sta.Run(first.TryAcquire));
|
||||
Assert.False(TryAcquireApart(suffix));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Sta.Run(first.Dispose);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_second_run_asks_the_running_one_to_show_its_window()
|
||||
{
|
||||
string suffix = UniqueSuffix();
|
||||
SingleInstanceGate first = Sta.Run(() => new SingleInstanceGate(suffix));
|
||||
|
||||
ConcurrentQueue<EventArgs> requests = new();
|
||||
first.ActivationRequested += (_, e) => requests.Enqueue(e);
|
||||
|
||||
try
|
||||
{
|
||||
Sta.Run(first.TryAcquire);
|
||||
Assert.False(TryAcquireApart(suffix));
|
||||
|
||||
Sta.WaitFor(() => !requests.IsEmpty, "the running instance got the request to show itself");
|
||||
}
|
||||
finally
|
||||
{
|
||||
Sta.Run(first.Dispose);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Without_a_second_run_no_request_arrives()
|
||||
{
|
||||
string suffix = UniqueSuffix();
|
||||
SingleInstanceGate gate = Sta.Run(() => new SingleInstanceGate(suffix));
|
||||
|
||||
ConcurrentQueue<EventArgs> requests = new();
|
||||
gate.ActivationRequested += (_, e) => requests.Enqueue(e);
|
||||
|
||||
try
|
||||
{
|
||||
Sta.Run(gate.TryAcquire);
|
||||
Sta.Pause(TimeSpan.FromMilliseconds(80));
|
||||
|
||||
Assert.Empty(requests);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Sta.Run(gate.Dispose);
|
||||
}
|
||||
}
|
||||
|
||||
// The place is released on exit — otherwise the app would never start again
|
||||
[Fact]
|
||||
public void After_the_exit_the_place_is_free_again()
|
||||
{
|
||||
string suffix = UniqueSuffix();
|
||||
|
||||
SingleInstanceGate first = Sta.Run(() => new SingleInstanceGate(suffix));
|
||||
Assert.True(Sta.Run(first.TryAcquire));
|
||||
Sta.Run(first.Dispose);
|
||||
|
||||
Assert.True(TryAcquireApart(suffix));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void No_requests_arrive_after_the_exit()
|
||||
{
|
||||
string suffix = UniqueSuffix();
|
||||
SingleInstanceGate first = Sta.Run(() => new SingleInstanceGate(suffix));
|
||||
|
||||
ConcurrentQueue<EventArgs> requests = new();
|
||||
first.ActivationRequested += (_, e) => requests.Enqueue(e);
|
||||
|
||||
Sta.Run(first.TryAcquire);
|
||||
Sta.Run(first.Dispose);
|
||||
|
||||
// The place is free, so the new run simply takes it for itself
|
||||
Assert.True(TryAcquireApart(suffix));
|
||||
Sta.Pause(TimeSpan.FromMilliseconds(80));
|
||||
|
||||
Assert.Empty(requests);
|
||||
}
|
||||
|
||||
// The previous instance crashed and did not release the place. It has no
|
||||
// owner any more, which means the place is free
|
||||
[Fact]
|
||||
public void A_place_left_by_a_crash_counts_as_free()
|
||||
{
|
||||
string suffix = UniqueSuffix();
|
||||
|
||||
// A thread that took the mutex and ended without releasing it is exactly
|
||||
// what a crashed application looks like to Windows
|
||||
Sta.RunApart(() =>
|
||||
{
|
||||
var abandoned = new Mutex(initiallyOwned: false, "CursorLang.SingleInstance" + suffix);
|
||||
abandoned.WaitOne(TimeSpan.Zero, exitContext: false);
|
||||
});
|
||||
|
||||
SingleInstanceGate gate = Sta.Run(() => new SingleInstanceGate(suffix));
|
||||
|
||||
try
|
||||
{
|
||||
Assert.True(Sta.Run(gate.TryAcquire));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Sta.Run(gate.Dispose);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Closing_without_taking_the_place_passes_without_consequence()
|
||||
{
|
||||
SingleInstanceGate gate = Sta.Run(() => new SingleInstanceGate(UniqueSuffix()));
|
||||
|
||||
Sta.Run(gate.Dispose);
|
||||
}
|
||||
|
||||
// The application takes the place under its ordinary name
|
||||
[Fact]
|
||||
public void The_ordinary_application_takes_the_place_under_its_own_name()
|
||||
{
|
||||
SingleInstanceGate gate = Sta.Run(() => new SingleInstanceGate());
|
||||
|
||||
// The place may be held by a running application — then it is simply not taken
|
||||
Sta.Run(gate.Dispose);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Closing_twice_passes_without_consequence()
|
||||
{
|
||||
SingleInstanceGate gate = Sta.Run(() => new SingleInstanceGate(UniqueSuffix()));
|
||||
|
||||
Sta.Run(gate.TryAcquire);
|
||||
Sta.Run(gate.Dispose);
|
||||
Sta.Run(gate.Dispose);
|
||||
}
|
||||
|
||||
// Every test gets its own namespace of kernel objects
|
||||
private static string UniqueSuffix() => "." + Guid.NewGuid().ToString("N");
|
||||
|
||||
/// <summary>
|
||||
/// Tries to take the place the way a run started afterwards does it —
|
||||
/// from another thread rather than from the same one.
|
||||
/// </summary>
|
||||
private static bool TryAcquireApart(string suffix)
|
||||
{
|
||||
bool acquired = false;
|
||||
|
||||
Sta.RunApart(() =>
|
||||
{
|
||||
var gate = new SingleInstanceGate(suffix);
|
||||
|
||||
try
|
||||
{
|
||||
acquired = gate.TryAcquire();
|
||||
}
|
||||
finally
|
||||
{
|
||||
gate.Dispose();
|
||||
}
|
||||
});
|
||||
|
||||
return acquired;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
using Windows.ApplicationModel;
|
||||
using CursorLang.Interop;
|
||||
using CursorLang.Models;
|
||||
using CursorLang.Services;
|
||||
|
||||
namespace CursorLang.Tests.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Startup by way of Windows. The tests run outside an MSIX package — as does any
|
||||
/// run of the app from a folder — so the answer they get comes from the registry.
|
||||
/// </summary>
|
||||
public sealed class StartupServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Outside_a_package_the_state_comes_from_the_registry()
|
||||
{
|
||||
if (PackageIdentityNative.IsPackaged)
|
||||
{
|
||||
// The tests are running as a package: the answer comes from the task instead
|
||||
return;
|
||||
}
|
||||
|
||||
// Whether startup is on depends on the machine; what matters is that the
|
||||
// question is answered at all and the setting is not hidden
|
||||
Assert.NotEqual(StartupState.Unavailable, await new StartupService().GetStateAsync());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(StartupTaskState.Enabled, StartupState.Enabled)]
|
||||
[InlineData(StartupTaskState.Disabled, StartupState.Disabled)]
|
||||
[InlineData(StartupTaskState.DisabledByUser, StartupState.DisabledByUser)]
|
||||
[InlineData(StartupTaskState.DisabledByPolicy, StartupState.DisabledByPolicy)]
|
||||
[InlineData(StartupTaskState.EnabledByPolicy, StartupState.EnabledByPolicy)]
|
||||
public void A_Windows_task_state_translates_into_an_app_state(
|
||||
StartupTaskState windows, StartupState expected)
|
||||
{
|
||||
Assert.Equal(expected, StartupService.Translate(windows));
|
||||
}
|
||||
|
||||
// Windows may grow a state the app knows nothing about
|
||||
[Fact]
|
||||
public void An_unfamiliar_state_counts_as_unavailable()
|
||||
{
|
||||
Assert.Equal(StartupState.Unavailable, StartupService.Translate((StartupTaskState)999));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void No_Windows_state_is_left_behind()
|
||||
{
|
||||
foreach (StartupTaskState state in Enum.GetValues<StartupTaskState>())
|
||||
{
|
||||
Assert.NotEqual(StartupState.Unavailable, StartupService.Translate(state));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,454 @@
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Interop;
|
||||
using System.Windows.Media;
|
||||
using System.Windows.Shapes;
|
||||
using CursorLang.Models;
|
||||
using CursorLang.Services;
|
||||
using CursorLang.Tests.Infrastructure;
|
||||
using Microsoft.Win32;
|
||||
|
||||
namespace CursorLang.Tests.Services;
|
||||
|
||||
/// <summary>
|
||||
/// The look of the windows. The palette lives in the application resources,
|
||||
/// so everything happens on the interface thread.
|
||||
/// </summary>
|
||||
public sealed class ThemeServiceTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(AppTheme.Light)]
|
||||
[InlineData(AppTheme.Dark)]
|
||||
public void A_chosen_theme_is_applied_as_is(AppTheme theme)
|
||||
{
|
||||
var settings = new AppSettings { Theme = theme };
|
||||
|
||||
Sta.Run(() =>
|
||||
{
|
||||
using var service = new ThemeService(settings, static () => AppTheme.Dark);
|
||||
|
||||
Assert.Equal(theme, service.CurrentTheme);
|
||||
});
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(AppTheme.Light)]
|
||||
[InlineData(AppTheme.Dark)]
|
||||
public void The_system_theme_is_taken_from_Windows(AppTheme system)
|
||||
{
|
||||
var settings = new AppSettings { Theme = AppTheme.System };
|
||||
|
||||
Sta.Run(() =>
|
||||
{
|
||||
using var service = new ThemeService(settings, () => system);
|
||||
|
||||
Assert.Equal(system, service.CurrentTheme);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Changing_the_theme_in_the_settings_repaints_the_windows()
|
||||
{
|
||||
var settings = new AppSettings { Theme = AppTheme.Light };
|
||||
|
||||
Sta.Run(() =>
|
||||
{
|
||||
using var service = new ThemeService(settings, static () => AppTheme.Light);
|
||||
|
||||
Color light = WindowBackground();
|
||||
|
||||
settings.Theme = AppTheme.Dark;
|
||||
|
||||
Assert.Equal(AppTheme.Dark, service.CurrentTheme);
|
||||
Assert.NotEqual(light, WindowBackground());
|
||||
});
|
||||
}
|
||||
|
||||
// The palette is replaced rather than piled up: otherwise the light one
|
||||
// would still sit under the dark one
|
||||
[Fact]
|
||||
public void The_palette_does_not_pile_up_in_the_resources()
|
||||
{
|
||||
var settings = new AppSettings { Theme = AppTheme.Light };
|
||||
|
||||
Sta.Run(() =>
|
||||
{
|
||||
int before = Application.Current.Resources.MergedDictionaries.Count;
|
||||
|
||||
using var service = new ThemeService(settings, static () => AppTheme.Light);
|
||||
|
||||
settings.Theme = AppTheme.Dark;
|
||||
settings.Theme = AppTheme.Light;
|
||||
settings.Theme = AppTheme.Dark;
|
||||
|
||||
Assert.Equal(before + 1, Application.Current.Resources.MergedDictionaries.Count);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Choosing_the_same_theme_again_leaves_the_resources_alone()
|
||||
{
|
||||
var settings = new AppSettings { Theme = AppTheme.Dark };
|
||||
|
||||
Sta.Run(() =>
|
||||
{
|
||||
using var service = new ThemeService(settings, static () => AppTheme.Light);
|
||||
|
||||
int count = Application.Current.Resources.MergedDictionaries.Count;
|
||||
ResourceDictionary palette = Application.Current.Resources.MergedDictionaries[^1];
|
||||
|
||||
settings.Theme = AppTheme.Dark;
|
||||
|
||||
Assert.Equal(count, Application.Current.Resources.MergedDictionaries.Count);
|
||||
Assert.Same(palette, Application.Current.Resources.MergedDictionaries[^1]);
|
||||
});
|
||||
}
|
||||
|
||||
// The other settings have nothing to do with the look
|
||||
[Fact]
|
||||
public void Other_settings_do_not_change_the_theme()
|
||||
{
|
||||
var settings = new AppSettings { Theme = AppTheme.Light };
|
||||
|
||||
Sta.Run(() =>
|
||||
{
|
||||
using var service = new ThemeService(settings, static () => AppTheme.Dark);
|
||||
|
||||
settings.FontSize = 40;
|
||||
settings.Language = "ru";
|
||||
|
||||
Assert.Equal(AppTheme.Light, service.CurrentTheme);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_window_that_already_exists_is_attached_at_once()
|
||||
{
|
||||
var settings = new AppSettings { Theme = AppTheme.Dark };
|
||||
|
||||
Sta.Run(() =>
|
||||
{
|
||||
using var service = new ThemeService(settings, static () => AppTheme.Light);
|
||||
var window = new Window();
|
||||
|
||||
try
|
||||
{
|
||||
_ = new WindowInteropHelper(window).EnsureHandle();
|
||||
|
||||
// The title bar is painted by Windows, and the only way to check
|
||||
// this is that the call goes through without an error
|
||||
service.Register(window);
|
||||
}
|
||||
finally
|
||||
{
|
||||
window.Close();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_window_without_a_handle_is_attached_once_it_appears()
|
||||
{
|
||||
var settings = new AppSettings { Theme = AppTheme.Dark };
|
||||
|
||||
Sta.Run(() =>
|
||||
{
|
||||
using var service = new ThemeService(settings, static () => AppTheme.Light);
|
||||
var window = new Window { Opacity = 0, ShowInTaskbar = false, ShowActivated = false };
|
||||
|
||||
try
|
||||
{
|
||||
service.Register(window);
|
||||
|
||||
// The window is created on show — and the look comes with it
|
||||
window.Show();
|
||||
}
|
||||
finally
|
||||
{
|
||||
window.Close();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// The ordinary service takes the theme from the Windows settings
|
||||
[Fact]
|
||||
public void The_service_can_work_with_the_real_Windows_theme()
|
||||
{
|
||||
Sta.Run(() =>
|
||||
{
|
||||
using var service = new ThemeService(new AppSettings { Theme = AppTheme.System });
|
||||
|
||||
Assert.True(service.CurrentTheme is AppTheme.Light or AppTheme.Dark);
|
||||
Assert.Equal(ThemeService.DetectSystemTheme(), service.CurrentTheme);
|
||||
});
|
||||
}
|
||||
|
||||
// Windows reports a look change from a thread other than the interface one
|
||||
[Fact]
|
||||
public void A_look_change_in_Windows_repaints_the_windows()
|
||||
{
|
||||
var settings = new AppSettings { Theme = AppTheme.System };
|
||||
AppTheme system = AppTheme.Light;
|
||||
|
||||
Sta.Run(() =>
|
||||
{
|
||||
using var service = new ThemeService(settings, () => system);
|
||||
Assert.Equal(AppTheme.Light, service.CurrentTheme);
|
||||
|
||||
system = AppTheme.Dark;
|
||||
RaiseUserPreferenceChanged(service);
|
||||
|
||||
Sta.WaitFor(() => service.CurrentTheme == AppTheme.Dark, "the theme was recomputed at the request of Windows");
|
||||
});
|
||||
}
|
||||
|
||||
// Windows reports a look change even when nothing changed for the app:
|
||||
// in that case there is nothing to repaint
|
||||
[Fact]
|
||||
public void The_same_theme_does_not_replace_the_resources()
|
||||
{
|
||||
var settings = new AppSettings { Theme = AppTheme.System };
|
||||
|
||||
Sta.Run(() =>
|
||||
{
|
||||
using var service = new ThemeService(settings, static () => AppTheme.Dark);
|
||||
|
||||
ResourceDictionary palette = Application.Current.Resources.MergedDictionaries[^1];
|
||||
int count = Application.Current.Resources.MergedDictionaries.Count;
|
||||
|
||||
RaiseUserPreferenceChanged(service);
|
||||
Sta.Pause(TimeSpan.FromMilliseconds(30));
|
||||
|
||||
Assert.Same(palette, Application.Current.Resources.MergedDictionaries[^1]);
|
||||
Assert.Equal(count, Application.Current.Resources.MergedDictionaries.Count);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_closed_window_is_forgotten()
|
||||
{
|
||||
var settings = new AppSettings { Theme = AppTheme.Light };
|
||||
|
||||
Sta.Run(() =>
|
||||
{
|
||||
using var service = new ThemeService(settings, static () => AppTheme.Light);
|
||||
var window = new Window();
|
||||
_ = new WindowInteropHelper(window).EnsureHandle();
|
||||
|
||||
service.Register(window);
|
||||
window.Close();
|
||||
|
||||
// A theme change must no longer concern the closed window
|
||||
settings.Theme = AppTheme.Dark;
|
||||
|
||||
Assert.Equal(AppTheme.Dark, service.CurrentTheme);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Closing_the_service_lets_go_of_the_attached_windows()
|
||||
{
|
||||
var settings = new AppSettings { Theme = AppTheme.Light };
|
||||
|
||||
Sta.Run(() =>
|
||||
{
|
||||
var service = new ThemeService(settings, static () => AppTheme.Light);
|
||||
var window = new Window { Opacity = 0, ShowInTaskbar = false, ShowActivated = false };
|
||||
|
||||
try
|
||||
{
|
||||
window.Show();
|
||||
service.Register(window);
|
||||
|
||||
service.Dispose();
|
||||
|
||||
// The window now closes on its own, with no regard for the theme
|
||||
window.Close();
|
||||
}
|
||||
finally
|
||||
{
|
||||
window.Close();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void After_the_service_is_closed_the_setting_no_longer_changes_the_theme()
|
||||
{
|
||||
var settings = new AppSettings { Theme = AppTheme.Light };
|
||||
|
||||
Sta.Run(() =>
|
||||
{
|
||||
var service = new ThemeService(settings, static () => AppTheme.Light);
|
||||
service.Dispose();
|
||||
|
||||
settings.Theme = AppTheme.Dark;
|
||||
|
||||
Assert.Equal(AppTheme.Light, service.CurrentTheme);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void After_the_service_is_closed_requests_from_Windows_go_unanswered()
|
||||
{
|
||||
var settings = new AppSettings { Theme = AppTheme.System };
|
||||
AppTheme system = AppTheme.Light;
|
||||
|
||||
Sta.Run(() =>
|
||||
{
|
||||
var service = new ThemeService(settings, () => system);
|
||||
service.Dispose();
|
||||
|
||||
system = AppTheme.Dark;
|
||||
Sta.Pause(TimeSpan.FromMilliseconds(50));
|
||||
|
||||
Assert.Equal(AppTheme.Light, service.CurrentTheme);
|
||||
});
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(AppTheme.Light)]
|
||||
[InlineData(AppTheme.Dark)]
|
||||
public void The_palette_of_each_theme_lives_in_the_application_assembly(AppTheme theme)
|
||||
{
|
||||
Sta.Run(() =>
|
||||
{
|
||||
var palette = new ResourceDictionary { Source = ThemeService.PaletteUri(theme) };
|
||||
|
||||
Assert.NotEmpty(palette.Keys);
|
||||
Assert.True(palette.Contains("Theme.WindowBackground"));
|
||||
});
|
||||
}
|
||||
|
||||
// The palette address names the application assembly rather than the one
|
||||
// the process started from
|
||||
[Fact]
|
||||
public void The_palette_address_names_the_application_assembly()
|
||||
{
|
||||
Assert.Contains("CursorLang;component", ThemeService.PaletteUri(AppTheme.Dark).ToString(), StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_light_and_dark_palettes_share_one_set_of_keys()
|
||||
{
|
||||
Sta.Run(() =>
|
||||
{
|
||||
var light = new ResourceDictionary { Source = ThemeService.PaletteUri(AppTheme.Light) };
|
||||
var dark = new ResourceDictionary { Source = ThemeService.PaletteUri(AppTheme.Dark) };
|
||||
|
||||
Assert.Equal(light.Keys.Cast<object>().OrderBy(key => key.ToString()),
|
||||
dark.Keys.Cast<object>().OrderBy(key => key.ToString()));
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_Windows_theme_is_read_without_errors()
|
||||
{
|
||||
AppTheme theme = ThemeService.DetectSystemTheme();
|
||||
|
||||
// The "follow the system" setting has to yield something definite
|
||||
Assert.True(theme is AppTheme.Light or AppTheme.Dark);
|
||||
}
|
||||
|
||||
// A control the shared dictionary says nothing about keeps the look Windows
|
||||
// gives it and stays light in the dark theme
|
||||
[Theory]
|
||||
[InlineData(typeof(Button))]
|
||||
[InlineData(typeof(ComboBox))]
|
||||
[InlineData(typeof(CheckBox))]
|
||||
[InlineData(typeof(GroupBox))]
|
||||
[InlineData(typeof(ProgressBar))]
|
||||
[InlineData(typeof(Slider))]
|
||||
public void A_control_of_the_window_is_repainted_together_with_the_theme(Type control)
|
||||
{
|
||||
var settings = new AppSettings { Theme = AppTheme.Light };
|
||||
|
||||
Sta.Run(() =>
|
||||
{
|
||||
using var service = new ThemeService(settings, static () => AppTheme.Light);
|
||||
|
||||
var window = new Window
|
||||
{
|
||||
Opacity = 0,
|
||||
ShowInTaskbar = false,
|
||||
ShowActivated = false,
|
||||
Width = 200,
|
||||
Height = 100,
|
||||
Content = (Control)Activator.CreateInstance(control)!,
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
window.Show();
|
||||
window.UpdateLayout();
|
||||
|
||||
IReadOnlyList<Color> light = PaintOf(window);
|
||||
Assert.NotEmpty(light);
|
||||
|
||||
settings.Theme = AppTheme.Dark;
|
||||
window.UpdateLayout();
|
||||
|
||||
Assert.NotEqual(light, PaintOf(window));
|
||||
}
|
||||
finally
|
||||
{
|
||||
window.Close();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Windows reports a look change through an event that cannot be synthesised:
|
||||
// the test goes straight to the handler that event arrives at
|
||||
private static void RaiseUserPreferenceChanged(ThemeService service)
|
||||
{
|
||||
System.Reflection.MethodInfo handler = typeof(ThemeService)
|
||||
.GetMethod("OnUserPreferenceChanged", System.Reflection.BindingFlags.Instance
|
||||
| System.Reflection.BindingFlags.NonPublic)!;
|
||||
|
||||
handler.Invoke(service, [null, new UserPreferenceChangedEventArgs(UserPreferenceCategory.General)]);
|
||||
}
|
||||
|
||||
private static Color WindowBackground() =>
|
||||
((SolidColorBrush)Application.Current.Resources["Theme.WindowBackground"]).Color;
|
||||
|
||||
/// <summary>
|
||||
/// Every colour the element tree is painted with. What is compared is the
|
||||
/// whole set: which part of a control the palette reaches is its own business.
|
||||
/// </summary>
|
||||
private static IReadOnlyList<Color> PaintOf(DependencyObject root)
|
||||
{
|
||||
var colours = new List<Color>();
|
||||
Collect(root, colours);
|
||||
|
||||
return colours;
|
||||
}
|
||||
|
||||
private static void Collect(DependencyObject node, List<Color> colours)
|
||||
{
|
||||
switch (node)
|
||||
{
|
||||
case Control control:
|
||||
Add(colours, control.Background, control.BorderBrush, control.Foreground);
|
||||
break;
|
||||
case Border border:
|
||||
Add(colours, border.Background, border.BorderBrush);
|
||||
break;
|
||||
case Shape shape:
|
||||
Add(colours, shape.Fill, shape.Stroke);
|
||||
break;
|
||||
case TextBlock text:
|
||||
Add(colours, text.Background, text.Foreground);
|
||||
break;
|
||||
}
|
||||
|
||||
int count = VisualTreeHelper.GetChildrenCount(node);
|
||||
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
Collect(VisualTreeHelper.GetChild(node, i), colours);
|
||||
}
|
||||
}
|
||||
|
||||
private static void Add(List<Color> colours, params Brush?[] brushes) =>
|
||||
colours.AddRange(brushes.OfType<SolidColorBrush>().Select(brush => brush.Color));
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
using CursorLang.Services;
|
||||
|
||||
namespace CursorLang.Tests.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Where the app looks for its releases. The values belong to the build, and a
|
||||
/// wrong one shows only as an update that never arrives.
|
||||
/// </summary>
|
||||
public sealed class UpdateOptionsTests
|
||||
{
|
||||
[Fact]
|
||||
public void Out_of_the_box_the_releases_are_looked_for_in_the_repository_of_the_app()
|
||||
{
|
||||
var options = new UpdateOptions();
|
||||
|
||||
Assert.Equal("git.alrakis.kz", options.ServiceUri.Host);
|
||||
Assert.Equal("alrakis/cursor-lang", options.Project);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Another_server_is_taken_as_it_is_given()
|
||||
{
|
||||
var options = new UpdateOptions
|
||||
{
|
||||
ServiceUri = new Uri("https://git.example.com/"),
|
||||
Project = "team/app",
|
||||
};
|
||||
|
||||
Assert.Equal("git.example.com", options.ServiceUri.Host);
|
||||
Assert.Equal("team/app", options.Project);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_app_asks_about_releases_no_more_than_once_a_day()
|
||||
{
|
||||
Assert.Equal(TimeSpan.FromDays(1), new UpdateOptions().CheckInterval);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
using System.IO;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Text;
|
||||
using CursorLang.Models;
|
||||
using CursorLang.Services;
|
||||
using CursorLang.Tests.Infrastructure;
|
||||
|
||||
namespace CursorLang.Tests.Services;
|
||||
|
||||
/// <summary>
|
||||
/// What the app does with a release once it has found one: whether it is newer
|
||||
/// at all, and what ends up on disk.
|
||||
/// </summary>
|
||||
public sealed class UpdateServiceTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("1.0.0.0", "1.0.1.0", true)]
|
||||
[InlineData("1.0.0.0", "2.0.0.0", true)]
|
||||
[InlineData("1.0.0.0", "1.0.0.0", false)]
|
||||
[InlineData("1.0.1.0", "1.0.0.0", false)]
|
||||
public async Task Only_a_higher_version_counts_as_an_update(string current, string found, bool offered)
|
||||
{
|
||||
var feed = new FakeReleaseFeed { Release = Release(found) };
|
||||
using TempFolder folder = new();
|
||||
using UpdateService service = Create(feed, folder, current);
|
||||
|
||||
ReleaseInfo? update = await service.CheckAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(offered, update is not null);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task An_empty_repository_leaves_the_app_with_nothing()
|
||||
{
|
||||
var feed = new FakeReleaseFeed { Release = null };
|
||||
using TempFolder folder = new();
|
||||
using UpdateService service = Create(feed, folder);
|
||||
|
||||
Assert.Null(await service.CheckAsync(TestContext.Current.CancellationToken));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_package_ends_up_on_disk_whole()
|
||||
{
|
||||
byte[] content = Encoding.UTF8.GetBytes(new string('p', 300_000));
|
||||
using TempFolder folder = new();
|
||||
using UpdateService service = Create(new FakeReleaseFeed(), folder, client: FakeHttpHandler.Bytes(content));
|
||||
|
||||
string path = await service.DownloadAsync(Release("2.0.0.0"), null, TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(content, await File.ReadAllBytesAsync(path, TestContext.Current.CancellationToken));
|
||||
}
|
||||
|
||||
// The name comes from the version, not from the answer: the app creates a
|
||||
// file with it, and the answer comes from the other side
|
||||
[Fact]
|
||||
public async Task The_name_of_the_file_is_built_by_the_app_itself()
|
||||
{
|
||||
using TempFolder folder = new();
|
||||
using UpdateService service = Create(new FakeReleaseFeed(), folder, client: FakeHttpHandler.Bytes([1, 2, 3]));
|
||||
|
||||
var release = new ReleaseInfo(
|
||||
new Version(2, 0, 0, 0),
|
||||
"v2.0.0",
|
||||
null,
|
||||
new ReleaseAsset(@"..\..\evil.msixbundle", new Uri("https://host/a"), 3));
|
||||
|
||||
string path = await service.DownloadAsync(release, null, TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal("CursorLang-2.0.0.0.msixbundle", Path.GetFileName(path));
|
||||
Assert.Equal(folder.Path, Path.GetDirectoryName(path));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_download_reports_how_far_it_has_come()
|
||||
{
|
||||
byte[] content = new byte[500_000];
|
||||
using TempFolder folder = new();
|
||||
using UpdateService service = Create(new FakeReleaseFeed(), folder, client: FakeHttpHandler.Bytes(content));
|
||||
|
||||
var reported = new CollectingProgress();
|
||||
await service.DownloadAsync(Release("2.0.0.0"), reported, TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.NotEmpty(reported.Values);
|
||||
Assert.All(reported.Values, value => Assert.InRange(value, 0, 1));
|
||||
Assert.Equal(reported.Values, [.. reported.Values.Order()]);
|
||||
Assert.Equal(1, reported.Values[^1]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_closed_repository_gets_the_token_with_the_download_too()
|
||||
{
|
||||
var feed = new FakeReleaseFeed();
|
||||
using TempFolder folder = new();
|
||||
using UpdateService service = Create(feed, folder, client: FakeHttpHandler.Bytes([1]));
|
||||
|
||||
await service.DownloadAsync(Release("2.0.0.0"), null, TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Single(feed.Authorized);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_refusal_of_the_hosting_service_leaves_no_package_behind()
|
||||
{
|
||||
using TempFolder folder = new();
|
||||
using UpdateService service = Create(
|
||||
new FakeReleaseFeed(), folder, client: FakeHttpHandler.Status(HttpStatusCode.NotFound));
|
||||
|
||||
await Assert.ThrowsAsync<HttpRequestException>(
|
||||
() => service.DownloadAsync(Release("2.0.0.0"), null, TestContext.Current.CancellationToken));
|
||||
|
||||
Assert.Empty(Directory.GetFiles(folder.Path));
|
||||
}
|
||||
|
||||
// A package left from an earlier download takes up room and is of no use
|
||||
// once it has been installed
|
||||
[Fact]
|
||||
public async Task An_older_download_is_cleared_away()
|
||||
{
|
||||
using TempFolder folder = new();
|
||||
string leftover = folder.File("CursorLang-1.5.0.0.msixbundle");
|
||||
await File.WriteAllTextAsync(leftover, "old", TestContext.Current.CancellationToken);
|
||||
|
||||
using UpdateService service = Create(new FakeReleaseFeed(), folder, client: FakeHttpHandler.Bytes([1]));
|
||||
string path = await service.DownloadAsync(Release("2.0.0.0"), null, TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.False(File.Exists(leftover));
|
||||
Assert.True(File.Exists(path));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The reports as the download makes them. <c>Progress<T></c> would
|
||||
/// hand them over to another thread, and a test has nowhere to wait for that.
|
||||
/// </summary>
|
||||
private sealed class CollectingProgress : IProgress<double>
|
||||
{
|
||||
internal List<double> Values { get; } = [];
|
||||
|
||||
public void Report(double value) => Values.Add(value);
|
||||
}
|
||||
|
||||
private static ReleaseInfo Release(string version) => new(
|
||||
Version.Parse(version),
|
||||
$"v{version}",
|
||||
new Uri("https://host/releases/tag"),
|
||||
new ReleaseAsset($"CursorLang-{version}.msixbundle", new Uri("https://host/package"), 0));
|
||||
|
||||
private static UpdateService Create(
|
||||
IReleaseFeed feed,
|
||||
TempFolder folder,
|
||||
string current = "1.0.0.0",
|
||||
FakeHttpHandler? client = null) =>
|
||||
new(feed,
|
||||
(client ?? FakeHttpHandler.Bytes([])).CreateClient(),
|
||||
Version.Parse(current),
|
||||
folder.Path);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
using CursorLang.Models;
|
||||
using CursorLang.ViewModels;
|
||||
|
||||
namespace CursorLang.Tests.ViewModels;
|
||||
|
||||
public sealed class EnumOptionTests
|
||||
{
|
||||
[Fact]
|
||||
public void An_option_remembers_its_value_and_caption()
|
||||
{
|
||||
var option = new EnumOption<AppTheme>(AppTheme.Dark, "Dark theme");
|
||||
|
||||
Assert.Equal(AppTheme.Dark, option.Value);
|
||||
Assert.Equal("Dark theme", option.Display);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_changed_caption_is_announced_to_subscribers()
|
||||
{
|
||||
var option = new EnumOption<AppTheme>(AppTheme.Dark, "Dark");
|
||||
List<string?> changed = [];
|
||||
option.PropertyChanged += (_, e) => changed.Add(e.PropertyName);
|
||||
|
||||
option.Display = "Dark theme";
|
||||
|
||||
Assert.Equal("Dark theme", option.Display);
|
||||
Assert.Equal([nameof(EnumOption<>.Display)], changed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_same_caption_is_not_announced_again()
|
||||
{
|
||||
var option = new EnumOption<AppTheme>(AppTheme.Dark, "Dark theme");
|
||||
List<string?> changed = [];
|
||||
option.PropertyChanged += (_, e) => changed.Add(e.PropertyName);
|
||||
|
||||
option.Display = "Dark theme";
|
||||
|
||||
Assert.Empty(changed);
|
||||
}
|
||||
|
||||
// Accessibility tools take the name of a list item from here
|
||||
[Fact]
|
||||
public void An_option_presents_itself_by_its_caption()
|
||||
{
|
||||
Assert.Equal("Dark theme", new EnumOption<AppTheme>(AppTheme.Dark, "Dark theme").ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_changed_caption_changes_the_presentation_too()
|
||||
{
|
||||
var option = new EnumOption<ScreenPosition>(ScreenPosition.Center, "Center") { Display = "In the centre" };
|
||||
|
||||
Assert.Equal("In the centre", option.ToString());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
using CursorLang.Models;
|
||||
using CursorLang.ViewModels;
|
||||
|
||||
namespace CursorLang.Tests.ViewModels;
|
||||
|
||||
public sealed class LayoutPopupViewModelTests
|
||||
{
|
||||
// Before the first layout change there is nothing to show, yet the window
|
||||
// is already being built
|
||||
[Fact]
|
||||
public void Before_the_first_layout_a_dash_is_shown()
|
||||
{
|
||||
Assert.Equal("—", new LayoutPopupViewModel(new AppSettings()).ShortName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_look_comes_straight_from_the_settings()
|
||||
{
|
||||
var settings = new AppSettings();
|
||||
|
||||
Assert.Same(settings, new LayoutPopupViewModel(settings).Settings);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_changed_layout_name_is_announced_to_subscribers()
|
||||
{
|
||||
var viewModel = new LayoutPopupViewModel(new AppSettings());
|
||||
List<string?> changed = [];
|
||||
viewModel.PropertyChanged += (_, e) => changed.Add(e.PropertyName);
|
||||
|
||||
viewModel.ShortName = "RU";
|
||||
|
||||
Assert.Equal("RU", viewModel.ShortName);
|
||||
Assert.Equal([nameof(LayoutPopupViewModel.ShortName)], changed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_same_layout_name_is_not_announced_again()
|
||||
{
|
||||
var viewModel = new LayoutPopupViewModel(new AppSettings()) { ShortName = "RU" };
|
||||
List<string?> changed = [];
|
||||
viewModel.PropertyChanged += (_, e) => changed.Add(e.PropertyName);
|
||||
|
||||
viewModel.ShortName = "RU";
|
||||
|
||||
Assert.Empty(changed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
using System.Windows.Media;
|
||||
using CursorLang.Models;
|
||||
using CursorLang.Services;
|
||||
using CursorLang.Tests.Infrastructure;
|
||||
using CursorLang.ViewModels;
|
||||
|
||||
namespace CursorLang.Tests.ViewModels;
|
||||
|
||||
/// <summary>
|
||||
/// The settings window in terms of what it shows and what it is in charge of.
|
||||
/// </summary>
|
||||
public sealed class SettingsViewModelTests
|
||||
{
|
||||
[Fact]
|
||||
public void The_interface_language_comes_from_the_settings()
|
||||
{
|
||||
var settings = new AppSettings { Language = "ru" };
|
||||
var localization = new FakeLocalizationService();
|
||||
|
||||
using var viewModel = Create(settings, localization);
|
||||
|
||||
Assert.Equal("ru", localization.CurrentLanguage);
|
||||
Assert.Same(settings, viewModel.Settings);
|
||||
Assert.Same(localization, viewModel.Localization);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Changing_the_language_setting_switches_the_interface()
|
||||
{
|
||||
var settings = new AppSettings { Language = "en" };
|
||||
var localization = new FakeLocalizationService();
|
||||
using var viewModel = Create(settings, localization);
|
||||
|
||||
settings.Language = "ru";
|
||||
|
||||
Assert.Equal("ru", localization.CurrentLanguage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Other_settings_leave_the_language_alone()
|
||||
{
|
||||
var settings = new AppSettings { Language = "en" };
|
||||
var localization = new FakeLocalizationService();
|
||||
using var viewModel = Create(settings, localization);
|
||||
|
||||
settings.FontSize = 30;
|
||||
|
||||
Assert.Equal("en", localization.CurrentLanguage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_lists_are_built_from_every_value_of_the_enums()
|
||||
{
|
||||
using SettingsViewModel viewModel = Create();
|
||||
|
||||
Assert.Equal(Enum.GetValues<AppTheme>(), viewModel.Themes.Select(option => option.Value));
|
||||
Assert.Equal(Enum.GetValues<PopupPlacementMode>(), viewModel.PlacementModes.Select(option => option.Value));
|
||||
Assert.Equal(Enum.GetValues<AnchorSide>(), viewModel.AnchorSides.Select(option => option.Value));
|
||||
Assert.Equal(Enum.GetValues<ScreenPosition>(), viewModel.ScreenPositions.Select(option => option.Value));
|
||||
}
|
||||
|
||||
// A caption key is built from the type name and the value
|
||||
[Fact]
|
||||
public void The_option_captions_come_from_the_resources()
|
||||
{
|
||||
var localization = new FakeLocalizationService();
|
||||
using var viewModel = Create(localization: localization);
|
||||
|
||||
Assert.Equal("en:AppTheme_System", viewModel.Themes[0].Display);
|
||||
Assert.Contains("PopupPlacementMode_AtCursor", localization.RequestedKeys);
|
||||
}
|
||||
|
||||
// If the list items were recreated, the ComboBox would drop the selected value
|
||||
[Fact]
|
||||
public void Changing_the_language_changes_the_captions_not_the_options()
|
||||
{
|
||||
var settings = new AppSettings { Language = "en" };
|
||||
var localization = new FakeLocalizationService();
|
||||
using var viewModel = Create(settings, localization);
|
||||
|
||||
EnumOption<AppTheme> first = viewModel.Themes[0];
|
||||
|
||||
settings.Language = "ru";
|
||||
|
||||
Assert.Same(first, viewModel.Themes[0]);
|
||||
Assert.Equal("ru:AppTheme_System", first.Display);
|
||||
Assert.Equal("ru:AnchorSide_TopLeft", viewModel.AnchorSides[0].Display);
|
||||
Assert.Equal("ru:ScreenPosition_TopLeft", viewModel.ScreenPositions[0].Display);
|
||||
Assert.Equal("ru:PopupPlacementMode_AtCursor", viewModel.PlacementModes[0].Display);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_background_and_text_palettes_are_non_empty_and_different()
|
||||
{
|
||||
using SettingsViewModel viewModel = Create();
|
||||
|
||||
Assert.NotEmpty(viewModel.BackgroundPalette);
|
||||
Assert.NotEmpty(viewModel.TextPalette);
|
||||
Assert.NotEqual(viewModel.BackgroundPalette, viewModel.TextPalette);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_palettes_hold_no_duplicates()
|
||||
{
|
||||
using SettingsViewModel viewModel = Create();
|
||||
|
||||
Assert.Equal(viewModel.BackgroundPalette.Count, viewModel.BackgroundPalette.Distinct().Count());
|
||||
Assert.Equal(viewModel.TextPalette.Count, viewModel.TextPalette.Distinct().Count());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_default_colours_are_present_in_the_palettes()
|
||||
{
|
||||
var settings = new AppSettings();
|
||||
using var viewModel = Create(settings);
|
||||
|
||||
Assert.Contains(settings.BackgroundColor, viewModel.BackgroundPalette);
|
||||
Assert.Contains(settings.ForegroundColor, viewModel.TextPalette);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_palettes_are_the_same_for_every_window()
|
||||
{
|
||||
using SettingsViewModel first = Create();
|
||||
using SettingsViewModel second = Create();
|
||||
|
||||
Assert.Same(first.BackgroundPalette, second.BackgroundPalette);
|
||||
Assert.Same(first.TextPalette, second.TextPalette);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_background_palette_consists_of_colours()
|
||||
{
|
||||
using SettingsViewModel viewModel = Create();
|
||||
|
||||
Assert.All(viewModel.BackgroundPalette, color => Assert.IsType<Color>(color));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Until_Windows_answers_the_startup_setting_stays_hidden()
|
||||
{
|
||||
using SettingsViewModel viewModel = Create();
|
||||
|
||||
Assert.False(viewModel.IsStartupAvailable);
|
||||
Assert.False(viewModel.CanChangeStartup);
|
||||
Assert.False(viewModel.IsStartupLocked);
|
||||
Assert.False(viewModel.RunAtStartup);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(StartupState.Enabled, true, true, false, true)]
|
||||
[InlineData(StartupState.Disabled, true, true, false, false)]
|
||||
[InlineData(StartupState.DisabledByUser, true, false, true, false)]
|
||||
[InlineData(StartupState.DisabledByPolicy, true, false, true, false)]
|
||||
[InlineData(StartupState.EnabledByPolicy, true, false, true, true)]
|
||||
[InlineData(StartupState.Unavailable, false, false, false, false)]
|
||||
public async Task The_startup_state_decides_how_the_setting_looks(
|
||||
StartupState state, bool available, bool canChange, bool locked, bool enabled)
|
||||
{
|
||||
var startup = new FakeStartupService { State = state };
|
||||
using var viewModel = Create(startup: startup);
|
||||
|
||||
await viewModel.InitializeAsync();
|
||||
|
||||
Assert.Equal(available, viewModel.IsStartupAvailable);
|
||||
Assert.Equal(canChange, viewModel.CanChangeStartup);
|
||||
Assert.Equal(locked, viewModel.IsStartupLocked);
|
||||
Assert.Equal(enabled, viewModel.RunAtStartup);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_startup_setting_is_announced_after_Windows_answers()
|
||||
{
|
||||
var startup = new FakeStartupService { State = StartupState.Enabled };
|
||||
using var viewModel = Create(startup: startup);
|
||||
|
||||
List<string?> changed = [];
|
||||
viewModel.PropertyChanged += (_, e) => changed.Add(e.PropertyName);
|
||||
|
||||
await viewModel.InitializeAsync();
|
||||
|
||||
Assert.Contains(nameof(SettingsViewModel.RunAtStartup), changed);
|
||||
Assert.Contains(nameof(SettingsViewModel.IsStartupAvailable), changed);
|
||||
Assert.Contains(nameof(SettingsViewModel.CanChangeStartup), changed);
|
||||
Assert.Contains(nameof(SettingsViewModel.IsStartupLocked), changed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Enabling_startup_reaches_Windows()
|
||||
{
|
||||
var startup = new FakeStartupService { State = StartupState.Disabled };
|
||||
using var viewModel = Create(startup: startup);
|
||||
await viewModel.InitializeAsync();
|
||||
|
||||
viewModel.RunAtStartup = true;
|
||||
|
||||
await WaitForStartupRequests(startup, 1);
|
||||
Assert.Equal([true], startup.Requests);
|
||||
Assert.True(viewModel.RunAtStartup);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Disabling_startup_reaches_Windows()
|
||||
{
|
||||
var startup = new FakeStartupService { State = StartupState.Enabled };
|
||||
using var viewModel = Create(startup: startup);
|
||||
await viewModel.InitializeAsync();
|
||||
|
||||
viewModel.RunAtStartup = false;
|
||||
|
||||
await WaitForStartupRequests(startup, 1);
|
||||
Assert.Equal([false], startup.Requests);
|
||||
Assert.False(viewModel.RunAtStartup);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Setting_the_same_value_again_leaves_Windows_alone()
|
||||
{
|
||||
var startup = new FakeStartupService { State = StartupState.Enabled };
|
||||
using var viewModel = Create(startup: startup);
|
||||
await viewModel.InitializeAsync();
|
||||
|
||||
viewModel.RunAtStartup = true;
|
||||
|
||||
Assert.Empty(startup.Requests);
|
||||
}
|
||||
|
||||
// A ban by the user is not for the app to argue with: the tick has to come back
|
||||
[Fact]
|
||||
public async Task A_refused_request_puts_the_tick_back()
|
||||
{
|
||||
var startup = new FakeStartupService
|
||||
{
|
||||
State = StartupState.Disabled,
|
||||
AnswerOnEnable = StartupState.DisabledByUser,
|
||||
};
|
||||
|
||||
using var viewModel = Create(startup: startup);
|
||||
await viewModel.InitializeAsync();
|
||||
|
||||
List<string?> changed = [];
|
||||
viewModel.PropertyChanged += (_, e) => changed.Add(e.PropertyName);
|
||||
|
||||
viewModel.RunAtStartup = true;
|
||||
|
||||
await WaitForStartupRequests(startup, 1);
|
||||
|
||||
Assert.False(viewModel.RunAtStartup);
|
||||
Assert.True(viewModel.IsStartupLocked);
|
||||
Assert.Contains(nameof(SettingsViewModel.RunAtStartup), changed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Closing_unsubscribes_from_the_settings_and_the_language()
|
||||
{
|
||||
var settings = new AppSettings { Language = "en" };
|
||||
var localization = new FakeLocalizationService();
|
||||
SettingsViewModel viewModel = Create(settings, localization);
|
||||
|
||||
EnumOption<AppTheme> option = viewModel.Themes[0];
|
||||
string display = option.Display;
|
||||
|
||||
viewModel.Dispose();
|
||||
|
||||
settings.Language = "ru";
|
||||
|
||||
// Neither the interface language nor the option captions change any more
|
||||
Assert.Equal("en", localization.CurrentLanguage);
|
||||
Assert.Equal(display, option.Display);
|
||||
}
|
||||
|
||||
private static SettingsViewModel Create(
|
||||
AppSettings? settings = null,
|
||||
ILocalizationService? localization = null,
|
||||
IStartupService? startup = null) =>
|
||||
new(settings ?? new AppSettings(),
|
||||
localization ?? new FakeLocalizationService(),
|
||||
startup ?? new FakeStartupService(),
|
||||
Fake.Updates());
|
||||
|
||||
// The setting travels to Windows without being awaited: the window must not freeze
|
||||
private static async Task WaitForStartupRequests(FakeStartupService startup, int count)
|
||||
{
|
||||
for (int i = 0; i < 100 && startup.Requests.Count < count; i++)
|
||||
{
|
||||
await Task.Delay(5);
|
||||
}
|
||||
|
||||
Assert.Equal(count, startup.Requests.Count);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
using System.IO;
|
||||
using System.Net.Http;
|
||||
using CursorLang.Models;
|
||||
using CursorLang.Services;
|
||||
using CursorLang.Tests.Infrastructure;
|
||||
using CursorLang.ViewModels;
|
||||
|
||||
namespace CursorLang.Tests.ViewModels;
|
||||
|
||||
/// <summary>
|
||||
/// The updates section of the settings window: what it shows at every step and
|
||||
/// what it asks of the service behind it.
|
||||
/// </summary>
|
||||
public sealed class UpdateViewModelTests
|
||||
{
|
||||
[Fact]
|
||||
public void Before_the_first_check_the_section_says_nothing()
|
||||
{
|
||||
using UpdateViewModel viewModel = Create(new FakeUpdateService());
|
||||
|
||||
Assert.Equal(UpdateStatus.Idle, viewModel.Status);
|
||||
Assert.False(viewModel.HasStatus);
|
||||
Assert.False(viewModel.IsDownloadOffered);
|
||||
Assert.False(viewModel.IsInstallOffered);
|
||||
Assert.True(viewModel.CanCheck);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task An_update_found_is_offered_for_download()
|
||||
{
|
||||
var updates = new FakeUpdateService { Release = Release("2.0.0.0") };
|
||||
using UpdateViewModel viewModel = Create(updates);
|
||||
|
||||
await viewModel.CheckCommand.ExecuteAsync(null);
|
||||
|
||||
Assert.Equal(UpdateStatus.Available, viewModel.Status);
|
||||
Assert.True(viewModel.IsDownloadOffered);
|
||||
Assert.False(viewModel.IsInstallOffered);
|
||||
Assert.Equal("https://host/releases/tag", viewModel.ReleaseUrl?.ToString());
|
||||
Assert.True(viewModel.IsReleaseLinkShown);
|
||||
Assert.Equal("en:UpdateAvailable", viewModel.StatusText);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task With_the_latest_version_installed_there_is_nothing_to_offer()
|
||||
{
|
||||
using UpdateViewModel viewModel = Create(new FakeUpdateService());
|
||||
|
||||
await viewModel.CheckCommand.ExecuteAsync(null);
|
||||
|
||||
Assert.Equal(UpdateStatus.UpToDate, viewModel.Status);
|
||||
Assert.False(viewModel.IsDownloadOffered);
|
||||
Assert.False(viewModel.IsReleaseLinkShown);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_check_by_the_button_says_when_it_did_not_work_out()
|
||||
{
|
||||
var updates = new FakeUpdateService { Failure = new HttpRequestException("no network") };
|
||||
using UpdateViewModel viewModel = Create(updates);
|
||||
|
||||
await viewModel.CheckCommand.ExecuteAsync(null);
|
||||
|
||||
Assert.Equal(UpdateStatus.Failed, viewModel.Status);
|
||||
Assert.True(viewModel.HasStatus);
|
||||
}
|
||||
|
||||
// The app does not always start with a live network, and the user who never
|
||||
// asked about updates has no use for the complaint
|
||||
[Fact]
|
||||
public async Task A_check_at_startup_keeps_a_failure_to_itself()
|
||||
{
|
||||
var updates = new FakeUpdateService { Failure = new HttpRequestException("no network") };
|
||||
using UpdateViewModel viewModel = Create(updates);
|
||||
|
||||
await viewModel.StartAsync();
|
||||
|
||||
Assert.Equal(UpdateStatus.Idle, viewModel.Status);
|
||||
Assert.False(viewModel.HasStatus);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_successful_check_is_remembered_in_the_settings()
|
||||
{
|
||||
var settings = new AppSettings();
|
||||
var updates = new FakeUpdateService { Release = Release("2.0.0.0") };
|
||||
using UpdateViewModel viewModel = Create(updates, settings);
|
||||
|
||||
await viewModel.CheckCommand.ExecuteAsync(null);
|
||||
|
||||
Assert.NotNull(settings.LastUpdateCheck);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_check_that_did_not_work_out_is_not_remembered()
|
||||
{
|
||||
var settings = new AppSettings();
|
||||
var updates = new FakeUpdateService { Failure = new HttpRequestException("no network") };
|
||||
using UpdateViewModel viewModel = Create(updates, settings);
|
||||
|
||||
await viewModel.CheckCommand.ExecuteAsync(null);
|
||||
|
||||
Assert.Null(settings.LastUpdateCheck);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_recent_check_is_not_repeated_at_startup()
|
||||
{
|
||||
var settings = new AppSettings { LastUpdateCheck = DateTimeOffset.UtcNow };
|
||||
var updates = new FakeUpdateService();
|
||||
using UpdateViewModel viewModel = Create(updates, settings);
|
||||
|
||||
await viewModel.StartAsync();
|
||||
|
||||
Assert.Equal(0, updates.CheckCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_check_of_yesterday_is_repeated_at_startup()
|
||||
{
|
||||
var settings = new AppSettings { LastUpdateCheck = DateTimeOffset.UtcNow - TimeSpan.FromDays(2) };
|
||||
var updates = new FakeUpdateService();
|
||||
using UpdateViewModel viewModel = Create(updates, settings);
|
||||
|
||||
await viewModel.StartAsync();
|
||||
|
||||
Assert.Equal(1, updates.CheckCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_ban_on_checking_by_itself_is_obeyed()
|
||||
{
|
||||
var settings = new AppSettings { CheckForUpdates = false };
|
||||
var updates = new FakeUpdateService();
|
||||
using UpdateViewModel viewModel = Create(updates, settings);
|
||||
|
||||
await viewModel.StartAsync();
|
||||
|
||||
Assert.Equal(0, updates.CheckCalls);
|
||||
|
||||
// The button still works: the setting is about the app doing it on its own
|
||||
await viewModel.CheckCommand.ExecuteAsync(null);
|
||||
Assert.Equal(1, updates.CheckCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_ban_on_checking_travels_to_the_settings()
|
||||
{
|
||||
var settings = new AppSettings { CheckForUpdates = true };
|
||||
using UpdateViewModel viewModel = Create(new FakeUpdateService(), settings);
|
||||
|
||||
viewModel.CheckAutomatically = false;
|
||||
|
||||
Assert.False(settings.CheckForUpdates);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_package_from_Store_is_left_to_Store()
|
||||
{
|
||||
var updates = new FakeUpdateService { IsSupported = false, Release = Release("2.0.0.0") };
|
||||
using UpdateViewModel viewModel = Create(updates);
|
||||
|
||||
await viewModel.StartAsync();
|
||||
await viewModel.CheckCommand.ExecuteAsync(null);
|
||||
|
||||
Assert.False(viewModel.IsSupported);
|
||||
Assert.Equal(0, updates.CheckCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_downloaded_package_is_offered_for_installation()
|
||||
{
|
||||
using TempFolder folder = new();
|
||||
string package = folder.File("CursorLang-2.0.0.0.msixbundle");
|
||||
await File.WriteAllTextAsync(package, "package", TestContext.Current.CancellationToken);
|
||||
|
||||
var updates = new FakeUpdateService { Release = Release("2.0.0.0"), PackagePath = package };
|
||||
using UpdateViewModel viewModel = Create(updates);
|
||||
|
||||
await viewModel.CheckCommand.ExecuteAsync(null);
|
||||
await viewModel.DownloadCommand.ExecuteAsync(null);
|
||||
|
||||
Assert.Equal(UpdateStatus.Ready, viewModel.Status);
|
||||
Assert.True(viewModel.IsInstallOffered);
|
||||
Assert.False(viewModel.IsDownloadOffered);
|
||||
|
||||
viewModel.InstallCommand.Execute(null);
|
||||
|
||||
Assert.Equal([package], updates.Installed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task While_the_package_is_downloading_the_section_shows_it()
|
||||
{
|
||||
var updates = new FakeUpdateService
|
||||
{
|
||||
Release = Release("2.0.0.0"),
|
||||
DownloadGate = new TaskCompletionSource(),
|
||||
};
|
||||
|
||||
using UpdateViewModel viewModel = Create(updates);
|
||||
await viewModel.CheckCommand.ExecuteAsync(null);
|
||||
|
||||
Task download = viewModel.DownloadCommand.ExecuteAsync(null);
|
||||
|
||||
Assert.Equal(UpdateStatus.Downloading, viewModel.Status);
|
||||
Assert.True(viewModel.IsProgressShown);
|
||||
Assert.True(viewModel.IsBusy);
|
||||
Assert.False(viewModel.CanCheck);
|
||||
|
||||
updates.DownloadGate.SetResult();
|
||||
await download;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_download_that_did_not_work_out_is_told_about()
|
||||
{
|
||||
var updates = new FakeUpdateService { Release = Release("2.0.0.0") };
|
||||
using UpdateViewModel viewModel = Create(updates);
|
||||
await viewModel.CheckCommand.ExecuteAsync(null);
|
||||
|
||||
updates.Failure = new HttpRequestException("the connection dropped");
|
||||
await viewModel.DownloadCommand.ExecuteAsync(null);
|
||||
|
||||
Assert.Equal(UpdateStatus.Failed, viewModel.Status);
|
||||
}
|
||||
|
||||
// The temp folder is cleared by Windows as it sees fit, and the app has no
|
||||
// business handing a file that is gone to the installer
|
||||
[Fact]
|
||||
public async Task A_package_gone_from_the_disk_is_offered_for_download_again()
|
||||
{
|
||||
var updates = new FakeUpdateService
|
||||
{
|
||||
Release = Release("2.0.0.0"),
|
||||
PackagePath = Path.Combine(Path.GetTempPath(), "CursorLang.Tests", "never-written.msixbundle"),
|
||||
};
|
||||
|
||||
using UpdateViewModel viewModel = Create(updates);
|
||||
await viewModel.CheckCommand.ExecuteAsync(null);
|
||||
await viewModel.DownloadCommand.ExecuteAsync(null);
|
||||
|
||||
viewModel.InstallCommand.Execute(null);
|
||||
|
||||
Assert.Empty(updates.Installed);
|
||||
Assert.Equal(UpdateStatus.Available, viewModel.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_status_is_written_in_the_chosen_language()
|
||||
{
|
||||
var localization = new FakeLocalizationService();
|
||||
using UpdateViewModel viewModel = Create(new FakeUpdateService(), localization: localization);
|
||||
|
||||
await viewModel.CheckCommand.ExecuteAsync(null);
|
||||
Assert.Equal("en:UpdateUpToDate", viewModel.StatusText);
|
||||
|
||||
localization.CurrentLanguage = "ru";
|
||||
Assert.Equal("ru:UpdateUpToDate", viewModel.StatusText);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Closing_unsubscribes_from_the_language()
|
||||
{
|
||||
var localization = new FakeLocalizationService();
|
||||
UpdateViewModel viewModel = Create(new FakeUpdateService(), localization: localization);
|
||||
await viewModel.CheckCommand.ExecuteAsync(null);
|
||||
|
||||
List<string?> changed = [];
|
||||
viewModel.PropertyChanged += (_, e) => changed.Add(e.PropertyName);
|
||||
|
||||
viewModel.Dispose();
|
||||
localization.CurrentLanguage = "ru";
|
||||
|
||||
Assert.Empty(changed);
|
||||
}
|
||||
|
||||
private static ReleaseInfo Release(string version) => new(
|
||||
Version.Parse(version),
|
||||
$"v{version}",
|
||||
new Uri("https://host/releases/tag"),
|
||||
new ReleaseAsset($"CursorLang-{version}.msixbundle", new Uri("https://host/package"), 0));
|
||||
|
||||
private static UpdateViewModel Create(
|
||||
IUpdateService updates,
|
||||
AppSettings? settings = null,
|
||||
ILocalizationService? localization = null) =>
|
||||
new(updates,
|
||||
localization ?? new FakeLocalizationService(),
|
||||
settings ?? new AppSettings(),
|
||||
new UpdateOptions());
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
using System.Globalization;
|
||||
using System.Windows;
|
||||
using System.Windows.Data;
|
||||
using System.Windows.Media;
|
||||
using CursorLang.Models;
|
||||
using CursorLang.Views;
|
||||
|
||||
namespace CursorLang.Tests.Views;
|
||||
|
||||
/// <summary>
|
||||
/// The binding converters: they decide what the settings window shows and what
|
||||
/// it keeps out of sight.
|
||||
/// </summary>
|
||||
public sealed class ConvertersTests
|
||||
{
|
||||
private static readonly CultureInfo Culture = CultureInfo.InvariantCulture;
|
||||
|
||||
[Fact]
|
||||
public void A_match_with_the_single_listed_value_shows_the_element()
|
||||
{
|
||||
var converter = new EnumToVisibilityConverter();
|
||||
|
||||
Assert.Equal(
|
||||
Visibility.Visible,
|
||||
converter.Convert(PopupPlacementMode.AtCursor, typeof(Visibility), "AtCursor", Culture));
|
||||
}
|
||||
|
||||
// The anchor settings suit two placement modes at once
|
||||
[Fact]
|
||||
public void A_match_with_one_of_the_listed_values_shows_the_element()
|
||||
{
|
||||
var converter = new EnumToVisibilityConverter();
|
||||
|
||||
Assert.Equal(
|
||||
Visibility.Visible,
|
||||
converter.Convert(PopupPlacementMode.AtCaret, typeof(Visibility), "AtCursor, AtCaret", Culture));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void No_match_hides_the_element()
|
||||
{
|
||||
var converter = new EnumToVisibilityConverter();
|
||||
|
||||
Assert.Equal(
|
||||
Visibility.Collapsed,
|
||||
converter.Convert(PopupPlacementMode.FixedPoint, typeof(Visibility), "AtCursor, AtCaret", Culture));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null, "AtCursor")]
|
||||
[InlineData(PopupPlacementMode.AtCursor, null)]
|
||||
[InlineData(PopupPlacementMode.AtCursor, "")]
|
||||
[InlineData(PopupPlacementMode.AtCursor, ",,")]
|
||||
[InlineData(null, null)]
|
||||
public void Without_a_value_or_without_a_list_the_element_is_hidden(object? value, string? parameter)
|
||||
{
|
||||
var converter = new EnumToVisibilityConverter();
|
||||
|
||||
Assert.Equal(Visibility.Collapsed, converter.Convert(value, typeof(Visibility), parameter, Culture));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Extra_spaces_in_the_list_do_not_get_in_the_way()
|
||||
{
|
||||
var converter = new EnumToVisibilityConverter();
|
||||
|
||||
Assert.Equal(
|
||||
Visibility.Visible,
|
||||
converter.Convert(ScreenPosition.Center, typeof(Visibility), " Top , Center ", Culture));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Visibility_does_not_convert_back()
|
||||
{
|
||||
var converter = new EnumToVisibilityConverter();
|
||||
|
||||
Assert.Equal(
|
||||
Binding.DoNothing,
|
||||
converter.ConvertBack(Visibility.Visible, typeof(PopupPlacementMode), "AtCursor", Culture));
|
||||
}
|
||||
|
||||
// The alpha is not shown: transparency is a setting of its own
|
||||
[Fact]
|
||||
public void A_colour_is_shown_with_six_digits()
|
||||
{
|
||||
var converter = new ColorToHexConverter();
|
||||
|
||||
Assert.Equal("#0A1B2C", converter.Convert(Color.FromRgb(0x0A, 0x1B, 0x2C), typeof(string), null, Culture));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_semi_transparent_colour_is_shown_without_its_alpha()
|
||||
{
|
||||
var converter = new ColorToHexConverter();
|
||||
|
||||
Assert.Equal(
|
||||
"#102030",
|
||||
converter.Convert(Color.FromArgb(0x80, 0x10, 0x20, 0x30), typeof(string), null, Culture));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("not a colour")]
|
||||
[InlineData(42)]
|
||||
public void Anything_that_is_not_a_colour_shows_as_an_empty_string(object? value)
|
||||
{
|
||||
var converter = new ColorToHexConverter();
|
||||
|
||||
Assert.Equal(string.Empty, converter.Convert(value, typeof(string), null, Culture));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_colour_notation_does_not_convert_back()
|
||||
{
|
||||
var converter = new ColorToHexConverter();
|
||||
|
||||
Assert.Equal(Binding.DoNothing, converter.ConvertBack("#102030", typeof(Color), null, Culture));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_colour_turns_into_a_brush()
|
||||
{
|
||||
var converter = new ColorToBrushConverter();
|
||||
Color color = Color.FromRgb(0x10, 0x20, 0x30);
|
||||
|
||||
var brush = Assert.IsType<SolidColorBrush>(converter.Convert(color, typeof(Brush), null, Culture));
|
||||
|
||||
Assert.Equal(color, brush.Color);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("not a colour")]
|
||||
public void Anything_that_is_not_a_colour_turns_into_a_transparent_brush(object? value)
|
||||
{
|
||||
var converter = new ColorToBrushConverter();
|
||||
|
||||
Assert.Same(Brushes.Transparent, converter.Convert(value, typeof(Brush), null, Culture));
|
||||
}
|
||||
|
||||
// Picking a swatch in the list sends the colour back into the settings
|
||||
[Fact]
|
||||
public void A_brush_converts_back_into_a_colour()
|
||||
{
|
||||
var converter = new ColorToBrushConverter();
|
||||
Color color = Color.FromRgb(0x10, 0x20, 0x30);
|
||||
|
||||
Assert.Equal(color, converter.ConvertBack(new SolidColorBrush(color), typeof(Color), null, Culture));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("not a brush")]
|
||||
public void Anything_that_is_not_a_brush_does_not_convert_back(object? value)
|
||||
{
|
||||
var converter = new ColorToBrushConverter();
|
||||
|
||||
Assert.Equal(Binding.DoNothing, converter.ConvertBack(value, typeof(Color), null, Culture));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_converters_are_fit_for_bindings()
|
||||
{
|
||||
Assert.IsAssignableFrom<IValueConverter>(new EnumToVisibilityConverter());
|
||||
Assert.IsAssignableFrom<IValueConverter>(new ColorToHexConverter());
|
||||
Assert.IsAssignableFrom<IValueConverter>(new ColorToBrushConverter());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,354 @@
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Windows;
|
||||
using System.Windows.Interop;
|
||||
using CursorLang.Interop;
|
||||
using CursorLang.Models;
|
||||
using CursorLang.Services;
|
||||
using CursorLang.Tests.Infrastructure;
|
||||
using CursorLang.ViewModels;
|
||||
using CursorLang.Views;
|
||||
|
||||
namespace CursorLang.Tests.Views;
|
||||
|
||||
/// <summary>
|
||||
/// The tooltip window itself: where it ends up and how Windows sees it.
|
||||
/// </summary>
|
||||
public sealed class LayoutPopupWindowTests
|
||||
{
|
||||
private const int GwlExstyle = -20;
|
||||
private const int WsExNoactivate = 0x08000000;
|
||||
private const int WsExToolwindow = 0x00000080;
|
||||
|
||||
[Fact]
|
||||
public void The_window_is_created_before_the_first_show()
|
||||
{
|
||||
Sta.Run(() =>
|
||||
{
|
||||
using var popup = Popup.Create(new AppSettings());
|
||||
|
||||
// The handle is needed to set the window bounds before the show:
|
||||
// otherwise the window flashes at its default size for a moment
|
||||
Assert.NotEqual(IntPtr.Zero, popup.Handle);
|
||||
Assert.False(popup.Window.IsVisible);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_window_shows_what_the_view_model_gave_it()
|
||||
{
|
||||
Sta.Run(() =>
|
||||
{
|
||||
var settings = new AppSettings();
|
||||
using var popup = Popup.Create(settings);
|
||||
|
||||
Assert.Same(popup.ViewModel, popup.Window.DataContext);
|
||||
|
||||
popup.ViewModel.ShortName = "RU";
|
||||
popup.Window.ShowPopup();
|
||||
|
||||
Assert.True(popup.Window.IsVisible);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_window_does_what_the_popup_service_expects_of_it()
|
||||
{
|
||||
Sta.Run(() =>
|
||||
{
|
||||
using var popup = Popup.Create(new AppSettings());
|
||||
|
||||
Assert.IsAssignableFrom<ILayoutPopupWindow>(popup.Window);
|
||||
});
|
||||
}
|
||||
|
||||
// The tooltip pops up over other applications and must neither take the
|
||||
// focus nor turn up in Alt+Tab
|
||||
[Fact]
|
||||
public void The_window_takes_no_focus_and_stays_out_of_the_switcher()
|
||||
{
|
||||
Sta.Run(() =>
|
||||
{
|
||||
using var popup = Popup.Create(new AppSettings());
|
||||
|
||||
int style = GetWindowLong(popup.Handle, GwlExstyle);
|
||||
|
||||
Assert.Equal(WsExNoactivate, style & WsExNoactivate);
|
||||
Assert.Equal(WsExToolwindow, style & WsExToolwindow);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_window_stays_on_top_and_out_of_the_mouses_way()
|
||||
{
|
||||
Sta.Run(() =>
|
||||
{
|
||||
using var popup = Popup.Create(new AppSettings());
|
||||
|
||||
Assert.True(popup.Window.Topmost);
|
||||
Assert.False(popup.Window.ShowInTaskbar);
|
||||
Assert.False(popup.Window.ShowActivated);
|
||||
Assert.False(popup.Window.IsHitTestVisible);
|
||||
Assert.False(popup.Window.Focusable);
|
||||
Assert.Equal(WindowStyle.None, popup.Window.WindowStyle);
|
||||
Assert.True(popup.Window.AllowsTransparency);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_opacity_comes_from_the_settings()
|
||||
{
|
||||
Sta.Run(() =>
|
||||
{
|
||||
var settings = new AppSettings { Opacity = 0.42 };
|
||||
using var popup = Popup.Create(settings);
|
||||
popup.Window.ShowPopup();
|
||||
|
||||
Assert.Equal(0.42, popup.Window.Opacity, precision: 3);
|
||||
|
||||
settings.Opacity = 0.75;
|
||||
Assert.Equal(0.75, popup.Window.Opacity, precision: 3);
|
||||
});
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(ScreenPosition.TopLeft)]
|
||||
[InlineData(ScreenPosition.Top)]
|
||||
[InlineData(ScreenPosition.TopRight)]
|
||||
[InlineData(ScreenPosition.Center)]
|
||||
[InlineData(ScreenPosition.BottomLeft)]
|
||||
[InlineData(ScreenPosition.Bottom)]
|
||||
[InlineData(ScreenPosition.BottomRight)]
|
||||
public void In_the_fixed_point_mode_the_window_lands_where_it_was_computed(ScreenPosition position)
|
||||
{
|
||||
Sta.Run(() =>
|
||||
{
|
||||
var settings = new AppSettings
|
||||
{
|
||||
PlacementMode = PopupPlacementMode.FixedPoint, ScreenPosition = position, ScreenMargin = 24,
|
||||
};
|
||||
|
||||
using var popup = Popup.Create(settings);
|
||||
popup.ViewModel.ShortName = "RU";
|
||||
|
||||
(PopupWindowNative.Rect before, _) = PopupWindowNative.GetActiveMonitorWorkArea();
|
||||
popup.Window.ShowPopup();
|
||||
(PopupWindowNative.Rect work, double scale) = PopupWindowNative.GetActiveMonitorWorkArea();
|
||||
|
||||
if (!before.Equals(work))
|
||||
{
|
||||
Assert.Skip("The active monitor changed while the check was running");
|
||||
}
|
||||
|
||||
PopupWindowNative.Rect bounds = WindowPlacementNative.TryGetBounds(popup.Handle)!.Value;
|
||||
|
||||
int width = bounds.Right - bounds.Left;
|
||||
int height = bounds.Bottom - bounds.Top;
|
||||
PopupWindowNative.Point expected = PopupLayout.OnScreen(
|
||||
work, position, PopupLayout.ToPixels(settings.ScreenMargin, scale), width, height);
|
||||
|
||||
Assert.Equal(expected.X, bounds.Left);
|
||||
Assert.Equal(expected.Y, bounds.Top);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void At_the_cursor_the_window_lands_next_to_it()
|
||||
{
|
||||
Sta.Run(() =>
|
||||
{
|
||||
var settings = new AppSettings
|
||||
{
|
||||
PlacementMode = PopupPlacementMode.AtCursor, CursorSide = AnchorSide.BottomRight, CursorOffset = 16,
|
||||
};
|
||||
|
||||
using var popup = Popup.Create(settings);
|
||||
popup.ViewModel.ShortName = "RU";
|
||||
|
||||
// The place is computed from where the cursor was at the moment of
|
||||
// the show. If it was moving right then, show it once more
|
||||
PopupWindowNative.Point before = default;
|
||||
|
||||
for (int attempt = 0; attempt < 10; attempt++)
|
||||
{
|
||||
before = PopupWindowNative.GetCursorPosition();
|
||||
popup.Window.ShowPopup();
|
||||
PopupWindowNative.Point after = PopupWindowNative.GetCursorPosition();
|
||||
|
||||
if (before.X == after.X && before.Y == after.Y)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
if (attempt == 9)
|
||||
{
|
||||
Assert.Skip("The cursor kept moving the whole time");
|
||||
}
|
||||
}
|
||||
|
||||
PopupWindowNative.Rect bounds = WindowPlacementNative.TryGetBounds(popup.Handle)!.Value;
|
||||
double scale = PopupWindowNative.GetScaleAt(before);
|
||||
|
||||
PopupWindowNative.Point expected = PopupLayout.NearAnchor(
|
||||
PopupLayout.AsAnchor(before),
|
||||
AnchorSide.BottomRight,
|
||||
PopupLayout.ToPixels(settings.CursorOffset, scale),
|
||||
bounds.Right - bounds.Left,
|
||||
bounds.Bottom - bounds.Top);
|
||||
|
||||
Assert.Equal(expected.X, bounds.Left);
|
||||
Assert.Equal(expected.Y, bounds.Top);
|
||||
});
|
||||
}
|
||||
|
||||
// There is no caret in the test environment, and the tooltip has to fall
|
||||
// back to the cursor
|
||||
[Fact]
|
||||
public void Without_a_caret_the_window_lands_at_the_cursor()
|
||||
{
|
||||
Sta.Run(() =>
|
||||
{
|
||||
var settings = new AppSettings
|
||||
{
|
||||
PlacementMode = PopupPlacementMode.AtCaret, CaretSide = AnchorSide.BottomRight, CaretOffset = 8,
|
||||
};
|
||||
|
||||
using var popup = Popup.Create(settings);
|
||||
popup.ViewModel.ShortName = "RU";
|
||||
|
||||
PopupWindowNative.Point before = default;
|
||||
|
||||
for (int attempt = 0; attempt < 10; attempt++)
|
||||
{
|
||||
if (CaretNative.TryGetCaretRect() is not null)
|
||||
{
|
||||
Assert.Skip("The foreground window reported a caret of its own");
|
||||
}
|
||||
|
||||
before = PopupWindowNative.GetCursorPosition();
|
||||
popup.Window.ShowPopup();
|
||||
PopupWindowNative.Point after = PopupWindowNative.GetCursorPosition();
|
||||
|
||||
if (before.X == after.X && before.Y == after.Y)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
if (attempt == 9)
|
||||
{
|
||||
Assert.Skip("The cursor kept moving the whole time");
|
||||
}
|
||||
}
|
||||
|
||||
PopupWindowNative.Rect bounds = WindowPlacementNative.TryGetBounds(popup.Handle)!.Value;
|
||||
double scale = PopupWindowNative.GetScaleAt(before);
|
||||
|
||||
PopupWindowNative.Point expected = PopupLayout.NearAnchor(
|
||||
PopupLayout.AsAnchor(before),
|
||||
settings.CaretSide,
|
||||
PopupLayout.ToPixels(settings.CaretOffset, scale),
|
||||
bounds.Right - bounds.Left,
|
||||
bounds.Bottom - bounds.Top);
|
||||
|
||||
Assert.Equal(expected.X, bounds.Left);
|
||||
Assert.Equal(expected.Y, bounds.Top);
|
||||
});
|
||||
}
|
||||
|
||||
// The window size equals the size of the text: the tooltip has no frame
|
||||
[Fact]
|
||||
public void The_window_size_follows_the_size_of_the_caption()
|
||||
{
|
||||
Sta.Run(() =>
|
||||
{
|
||||
var small = new AppSettings { FontSize = 12, PlacementMode = PopupPlacementMode.FixedPoint };
|
||||
var large = new AppSettings { FontSize = 48, PlacementMode = PopupPlacementMode.FixedPoint };
|
||||
|
||||
using var smallPopup = Popup.Create(small);
|
||||
using var largePopup = Popup.Create(large);
|
||||
|
||||
smallPopup.ViewModel.ShortName = "RU";
|
||||
largePopup.ViewModel.ShortName = "RU";
|
||||
|
||||
smallPopup.Window.ShowPopup();
|
||||
largePopup.Window.ShowPopup();
|
||||
|
||||
PopupWindowNative.Rect smallBounds = WindowPlacementNative.TryGetBounds(smallPopup.Handle)!.Value;
|
||||
PopupWindowNative.Rect largeBounds = WindowPlacementNative.TryGetBounds(largePopup.Handle)!.Value;
|
||||
|
||||
Assert.True(largeBounds.Right - largeBounds.Left > smallBounds.Right - smallBounds.Left);
|
||||
Assert.True(largeBounds.Bottom - largeBounds.Top > smallBounds.Bottom - smallBounds.Top);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Showing_again_moves_the_window_to_its_new_place()
|
||||
{
|
||||
Sta.Run(() =>
|
||||
{
|
||||
var settings = new AppSettings
|
||||
{
|
||||
PlacementMode = PopupPlacementMode.FixedPoint, ScreenPosition = ScreenPosition.TopLeft,
|
||||
};
|
||||
|
||||
using var popup = Popup.Create(settings);
|
||||
popup.ViewModel.ShortName = "RU";
|
||||
popup.Window.ShowPopup();
|
||||
|
||||
PopupWindowNative.Rect topLeft = WindowPlacementNative.TryGetBounds(popup.Handle)!.Value;
|
||||
|
||||
settings.ScreenPosition = ScreenPosition.BottomRight;
|
||||
popup.Window.ShowPopup();
|
||||
|
||||
PopupWindowNative.Rect bottomRight = WindowPlacementNative.TryGetBounds(popup.Handle)!.Value;
|
||||
|
||||
Assert.True(bottomRight.Left > topLeft.Left);
|
||||
Assert.True(bottomRight.Top > topLeft.Top);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_hidden_window_stays_alive()
|
||||
{
|
||||
Sta.Run(() =>
|
||||
{
|
||||
using var popup = Popup.Create(new AppSettings());
|
||||
|
||||
popup.Window.ShowPopup();
|
||||
popup.Window.Hide();
|
||||
|
||||
Assert.False(popup.Window.IsVisible);
|
||||
Assert.NotEqual(IntPtr.Zero, popup.Handle);
|
||||
|
||||
popup.Window.ShowPopup();
|
||||
Assert.True(popup.Window.IsVisible);
|
||||
});
|
||||
}
|
||||
|
||||
[DllImport("user32.dll", SetLastError = true)]
|
||||
private static extern int GetWindowLong(IntPtr hWnd, int nIndex);
|
||||
|
||||
/// <summary>The tooltip window together with everything it needs to work.</summary>
|
||||
private sealed class Popup : IDisposable
|
||||
{
|
||||
private Popup(LayoutPopupWindow window, LayoutPopupViewModel viewModel)
|
||||
{
|
||||
Window = window;
|
||||
ViewModel = viewModel;
|
||||
Handle = new WindowInteropHelper(window).Handle;
|
||||
}
|
||||
|
||||
internal LayoutPopupWindow Window { get; }
|
||||
|
||||
internal LayoutPopupViewModel ViewModel { get; }
|
||||
|
||||
internal IntPtr Handle { get; }
|
||||
|
||||
internal static Popup Create(AppSettings settings)
|
||||
{
|
||||
var viewModel = new LayoutPopupViewModel(settings);
|
||||
return new Popup(new LayoutPopupWindow(viewModel, settings), viewModel);
|
||||
}
|
||||
|
||||
public void Dispose() => Window.Close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,365 @@
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Controls.Primitives;
|
||||
using System.Windows.Data;
|
||||
using System.Windows.Media;
|
||||
using CursorLang.Models;
|
||||
using CursorLang.Services;
|
||||
using CursorLang.Tests.Infrastructure;
|
||||
using CursorLang.ViewModels;
|
||||
using CursorLang.Views;
|
||||
|
||||
namespace CursorLang.Tests.Views;
|
||||
|
||||
/// <summary>
|
||||
/// The settings window as a whole: the markup, the bindings and the hook-up
|
||||
/// to the theme.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The window has to be shown for real: before the show WPF builds no element
|
||||
/// tree and computes no bindings. Full transparency keeps it out of sight.
|
||||
/// </remarks>
|
||||
public sealed class MainWindowTests
|
||||
{
|
||||
[Fact]
|
||||
public void The_window_is_built_and_takes_its_data_from_the_view_model()
|
||||
{
|
||||
Sta.Run(() =>
|
||||
{
|
||||
using SettingsViewModel viewModel = CreateViewModel();
|
||||
var theme = new FakeThemeService();
|
||||
|
||||
Open(viewModel, theme, window =>
|
||||
{
|
||||
Assert.Same(viewModel, window.DataContext);
|
||||
Assert.Equal([window], theme.Registered);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_window_title_comes_from_the_interface_strings()
|
||||
{
|
||||
Sta.Run(() =>
|
||||
{
|
||||
var localization = new FakeLocalizationService();
|
||||
using SettingsViewModel viewModel = CreateViewModel(localization: localization);
|
||||
|
||||
Open(viewModel, window =>
|
||||
{
|
||||
Assert.Equal("en:SettingsTitle", window.Title);
|
||||
|
||||
// A language change goes over every binding to a string
|
||||
localization.CurrentLanguage = "ru";
|
||||
Assert.Equal("ru:SettingsTitle", window.Title);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_window_fits_its_height_to_its_content()
|
||||
{
|
||||
Sta.Run(() =>
|
||||
{
|
||||
using SettingsViewModel viewModel = CreateViewModel();
|
||||
|
||||
Open(viewModel, window =>
|
||||
{
|
||||
Assert.Equal(SizeToContent.Height, window.SizeToContent);
|
||||
Assert.Equal(ResizeMode.CanMinimize, window.ResizeMode);
|
||||
Assert.True(window.ActualHeight > 0);
|
||||
Assert.True(window.ActualWidth > 0);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// The markup asks the view model for lists and palettes: if a name drifts
|
||||
// apart from the model, the binding silently shows an empty list
|
||||
[Fact]
|
||||
public void The_lists_in_the_window_are_filled()
|
||||
{
|
||||
Sta.Run(() =>
|
||||
{
|
||||
using SettingsViewModel viewModel = CreateViewModel();
|
||||
|
||||
Open(viewModel, window =>
|
||||
{
|
||||
List<ComboBox> boxes = [.. FindAll<ComboBox>(window)];
|
||||
|
||||
Assert.NotEmpty(boxes);
|
||||
Assert.All(boxes, box => Assert.NotEmpty(box.Items));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_lists_show_captions_in_the_chosen_language()
|
||||
{
|
||||
Sta.Run(() =>
|
||||
{
|
||||
using SettingsViewModel viewModel = CreateViewModel();
|
||||
|
||||
Open(viewModel, window =>
|
||||
{
|
||||
// Languages are named in themselves, while the enum options are
|
||||
// named by strings from the resources: the latter are checked
|
||||
List<string> displays =
|
||||
[
|
||||
.. FindAll<ComboBox>(window)
|
||||
.SelectMany(box => box.Items.OfType<object>())
|
||||
.Where(item => item.GetType().Name.StartsWith("EnumOption", StringComparison.Ordinal))
|
||||
.Select(item => item.ToString() ?? string.Empty),
|
||||
];
|
||||
|
||||
Assert.NotEmpty(displays);
|
||||
Assert.All(displays, display => Assert.StartsWith("en:", display, StringComparison.Ordinal));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_window_shows_a_preview_of_the_tooltip()
|
||||
{
|
||||
Sta.Run(() =>
|
||||
{
|
||||
var settings = new AppSettings { FontSize = 33 };
|
||||
using SettingsViewModel viewModel = CreateViewModel(settings);
|
||||
|
||||
Open(viewModel, window =>
|
||||
// The font size from the settings is visible right in the window
|
||||
Assert.Contains(FindAll<TextBlock>(window), text => Math.Abs(text.FontSize - 33) < 0.001));
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_tooltip_colours_are_shown_as_swatches()
|
||||
{
|
||||
Sta.Run(() =>
|
||||
{
|
||||
var settings = new AppSettings();
|
||||
using SettingsViewModel viewModel = CreateViewModel(settings);
|
||||
|
||||
// The chosen colour is shown as a swatch with a caption — in the
|
||||
// same notation the settings file uses
|
||||
Color chosen = viewModel.BackgroundPalette[2];
|
||||
settings.BackgroundColor = chosen;
|
||||
|
||||
string expected = $"#{chosen.R:X2}{chosen.G:X2}{chosen.B:X2}";
|
||||
|
||||
Open(viewModel, window => Assert.Contains(
|
||||
FindAll<TextBlock>(window),
|
||||
text => text.Text.Equals(expected, StringComparison.OrdinalIgnoreCase)));
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_startup_setting_hides_until_Windows_answers()
|
||||
{
|
||||
Sta.Run(() =>
|
||||
{
|
||||
using SettingsViewModel viewModel = CreateViewModel();
|
||||
|
||||
Open(viewModel, window =>
|
||||
{
|
||||
Assert.False(viewModel.IsStartupAvailable);
|
||||
Assert.All(FindStartupCheckBoxes(window), box => Assert.False(box.IsVisible));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void An_allowed_startup_shows_up_in_the_window()
|
||||
{
|
||||
Sta.Run(() =>
|
||||
{
|
||||
var startup = new FakeStartupService { State = StartupState.Disabled };
|
||||
using SettingsViewModel viewModel = CreateViewModel(startup: startup);
|
||||
|
||||
Open(viewModel, window =>
|
||||
{
|
||||
viewModel.InitializeAsync().GetAwaiter().GetResult();
|
||||
window.UpdateLayout();
|
||||
|
||||
CheckBox box = Assert.Single(FindStartupCheckBoxes(window));
|
||||
Assert.True(box.IsVisible);
|
||||
Assert.True(box.IsEnabled);
|
||||
Assert.False(box.IsChecked);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// A ban by the user is not for the app to argue with: the tick is shown,
|
||||
// but it cannot be moved
|
||||
[Fact]
|
||||
public void A_startup_banned_by_Windows_is_shown_as_unavailable()
|
||||
{
|
||||
Sta.Run(() =>
|
||||
{
|
||||
var startup = new FakeStartupService { State = StartupState.DisabledByUser };
|
||||
using SettingsViewModel viewModel = CreateViewModel(startup: startup);
|
||||
|
||||
Open(viewModel, window =>
|
||||
{
|
||||
viewModel.InitializeAsync().GetAwaiter().GetResult();
|
||||
window.UpdateLayout();
|
||||
|
||||
CheckBox box = Assert.Single(FindStartupCheckBoxes(window));
|
||||
Assert.True(box.IsVisible);
|
||||
Assert.False(box.IsEnabled);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_Caps_Lock_interception_is_toggled_by_a_tick()
|
||||
{
|
||||
Sta.Run(() =>
|
||||
{
|
||||
var settings = new AppSettings { UseCapsLockHotkey = false };
|
||||
using SettingsViewModel viewModel = CreateViewModel(settings);
|
||||
|
||||
Open(viewModel, window =>
|
||||
{
|
||||
CheckBox box = Assert.Single(FindCheckBoxesBoundTo(window, "Settings.UseCapsLockHotkey"));
|
||||
|
||||
box.IsChecked = true;
|
||||
|
||||
Assert.True(settings.UseCapsLockHotkey);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_updates_are_checked_by_the_button_in_the_window()
|
||||
{
|
||||
Sta.Run(() =>
|
||||
{
|
||||
var updates = new FakeUpdateService();
|
||||
using UpdateViewModel section = CreateUpdates(updates);
|
||||
using SettingsViewModel viewModel = CreateViewModel(updates: section);
|
||||
|
||||
Open(viewModel, window =>
|
||||
{
|
||||
Button check = Assert.Single(FindButtonsBoundTo(window, "Updates.CheckCommand"));
|
||||
|
||||
Assert.True(check.IsVisible);
|
||||
check.Command.Execute(null);
|
||||
|
||||
Assert.Equal(1, updates.CheckCalls);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// An app installed from the Store is updated by the Store
|
||||
[Fact]
|
||||
public void An_app_that_updates_itself_elsewhere_shows_no_updates_section()
|
||||
{
|
||||
Sta.Run(() =>
|
||||
{
|
||||
using UpdateViewModel section = CreateUpdates(new FakeUpdateService { IsSupported = false });
|
||||
using SettingsViewModel viewModel = CreateViewModel(updates: section);
|
||||
|
||||
Open(viewModel, window =>
|
||||
Assert.All(FindButtonsBoundTo(window, "Updates.CheckCommand"), button =>
|
||||
Assert.False(button.IsVisible)));
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_window_hooks_up_to_the_placement()
|
||||
{
|
||||
Sta.Run(() =>
|
||||
{
|
||||
using SettingsViewModel viewModel = CreateViewModel();
|
||||
var placement = new MainWindowPlacement();
|
||||
|
||||
Open(viewModel, new FakeThemeService(), placement, window =>
|
||||
{
|
||||
// The placement works off the window creation event: the window
|
||||
// has to end up on a monitor rather than beyond its edges
|
||||
Assert.True(window.Left > -10_000);
|
||||
Assert.True(window.Top > -10_000);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private static SettingsViewModel CreateViewModel(
|
||||
AppSettings? settings = null,
|
||||
ILocalizationService? localization = null,
|
||||
IStartupService? startup = null,
|
||||
UpdateViewModel? updates = null) =>
|
||||
new(settings ?? new AppSettings(),
|
||||
localization ?? new FakeLocalizationService(),
|
||||
startup ?? new FakeStartupService(),
|
||||
updates ?? Fake.Updates());
|
||||
|
||||
private static void Open(SettingsViewModel viewModel, Action<MainWindow> check) =>
|
||||
Open(viewModel, new FakeThemeService(), new MainWindowPlacement(), check);
|
||||
|
||||
private static void Open(SettingsViewModel viewModel, IThemeService theme, Action<MainWindow> check) =>
|
||||
Open(viewModel, theme, new MainWindowPlacement(), check);
|
||||
|
||||
private static void Open(
|
||||
SettingsViewModel viewModel,
|
||||
IThemeService theme,
|
||||
MainWindowPlacement placement,
|
||||
Action<MainWindow> check)
|
||||
{
|
||||
var window = new MainWindow(viewModel, theme, placement)
|
||||
{
|
||||
// The window is needed alive, but not in sight
|
||||
Opacity = 0,
|
||||
ShowInTaskbar = false,
|
||||
ShowActivated = false,
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
window.Show();
|
||||
window.UpdateLayout();
|
||||
|
||||
check(window);
|
||||
}
|
||||
finally
|
||||
{
|
||||
window.Close();
|
||||
}
|
||||
}
|
||||
|
||||
private static UpdateViewModel CreateUpdates(IUpdateService updates) =>
|
||||
new(updates, new FakeLocalizationService(), new AppSettings(), new UpdateOptions());
|
||||
|
||||
private static IEnumerable<Button> FindButtonsBoundTo(DependencyObject root, string path) =>
|
||||
FindAll<Button>(root).Where(button =>
|
||||
BindingOperations.GetBinding(button, ButtonBase.CommandProperty)?.Path.Path == path);
|
||||
|
||||
private static IEnumerable<CheckBox> FindStartupCheckBoxes(DependencyObject root) =>
|
||||
FindCheckBoxesBoundTo(root, nameof(SettingsViewModel.RunAtStartup));
|
||||
|
||||
// An element is found by what it is bound to: the markup gives them no names
|
||||
private static IEnumerable<CheckBox> FindCheckBoxesBoundTo(DependencyObject root, string path) =>
|
||||
FindAll<CheckBox>(root).Where(box =>
|
||||
BindingOperations.GetBinding(box, ToggleButton.IsCheckedProperty)?.Path.Path == path);
|
||||
|
||||
// Walking the element tree: the window markup is large, and things have to be searched for
|
||||
private static IEnumerable<TElement> FindAll<TElement>(DependencyObject root)
|
||||
where TElement : DependencyObject
|
||||
{
|
||||
int count = VisualTreeHelper.GetChildrenCount(root);
|
||||
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
DependencyObject child = VisualTreeHelper.GetChild(root, i);
|
||||
|
||||
if (child is TElement found)
|
||||
{
|
||||
yield return found;
|
||||
}
|
||||
|
||||
foreach (TElement nested in FindAll<TElement>(child))
|
||||
{
|
||||
yield return nested;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<RunSettings>
|
||||
<RunConfiguration>
|
||||
<ResultsDirectory>..\TestResults</ResultsDirectory>
|
||||
</RunConfiguration>
|
||||
|
||||
<DataCollectionRunSettings>
|
||||
<DataCollectors>
|
||||
<DataCollector friendlyName="XPlat code coverage">
|
||||
<Configuration>
|
||||
<!-- Coverage is measured for the application alone -->
|
||||
<Include>[CursorLang]*</Include>
|
||||
<Format>cobertura</Format>
|
||||
<SingleHit>false</SingleHit>
|
||||
<UseSourceLink>false</UseSourceLink>
|
||||
<IncludeTestAssembly>false</IncludeTestAssembly>
|
||||
<ExcludeByFile>**/*.g.cs,**/*.g.i.cs</ExcludeByFile>
|
||||
</Configuration>
|
||||
</DataCollector>
|
||||
</DataCollectors>
|
||||
</DataCollectionRunSettings>
|
||||
</RunSettings>
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"$schema": "https://xunit.net/schema/current/xunit.runner.schema.json",
|
||||
"methodDisplay": "method",
|
||||
"parallelizeTestCollections": false
|
||||
}
|
||||
@@ -2,15 +2,68 @@
|
||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CursorLang", "CursorLang\CursorLang.csproj", "{4729F06C-53D8-4871-8750-A0632F0A6B07}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CursorLang.Tests", "CursorLang.Tests\CursorLang.Tests.csproj", "{558F7646-AC3F-4E5E-853D-9F2A09E89C06}"
|
||||
EndProject
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Packaging", "Packaging", "{E927F87B-A585-A586-52B2-1371051E189F}"
|
||||
ProjectSection(SolutionItems) = preProject
|
||||
Packaging\AppxManifest.xml = Packaging\AppxManifest.xml
|
||||
Packaging\build-msix.ps1 = Packaging\build-msix.ps1
|
||||
Packaging\New-Assets.ps1 = Packaging\New-Assets.ps1
|
||||
EndProjectSection
|
||||
EndProject
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "CI", "CI", "{3F1B9C24-7A0E-4C55-9E2D-6B41A8D5E713}"
|
||||
ProjectSection(SolutionItems) = preProject
|
||||
.gitea\workflows\pull-request.yml = .gitea\workflows\pull-request.yml
|
||||
.gitea\workflows\release.yml = .gitea\workflows\release.yml
|
||||
EndProjectSection
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SdkTools", "Packaging\Tools\SdkTools.csproj", "{788339D3-F0C3-4F1A-9216-501814C7BF78}"
|
||||
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(ProjectConfigurationPlatforms) = postSolution
|
||||
{4729F06C-53D8-4871-8750-A0632F0A6B07}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{4729F06C-53D8-4871-8750-A0632F0A6B07}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{4729F06C-53D8-4871-8750-A0632F0A6B07}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{4729F06C-53D8-4871-8750-A0632F0A6B07}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{4729F06C-53D8-4871-8750-A0632F0A6B07}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{4729F06C-53D8-4871-8750-A0632F0A6B07}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{4729F06C-53D8-4871-8750-A0632F0A6B07}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{4729F06C-53D8-4871-8750-A0632F0A6B07}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{4729F06C-53D8-4871-8750-A0632F0A6B07}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{4729F06C-53D8-4871-8750-A0632F0A6B07}.Release|x64.Build.0 = Release|Any CPU
|
||||
{4729F06C-53D8-4871-8750-A0632F0A6B07}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{4729F06C-53D8-4871-8750-A0632F0A6B07}.Release|x86.Build.0 = Release|Any CPU
|
||||
{558F7646-AC3F-4E5E-853D-9F2A09E89C06}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{558F7646-AC3F-4E5E-853D-9F2A09E89C06}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{558F7646-AC3F-4E5E-853D-9F2A09E89C06}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{558F7646-AC3F-4E5E-853D-9F2A09E89C06}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{558F7646-AC3F-4E5E-853D-9F2A09E89C06}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{558F7646-AC3F-4E5E-853D-9F2A09E89C06}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{558F7646-AC3F-4E5E-853D-9F2A09E89C06}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{558F7646-AC3F-4E5E-853D-9F2A09E89C06}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{558F7646-AC3F-4E5E-853D-9F2A09E89C06}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{558F7646-AC3F-4E5E-853D-9F2A09E89C06}.Release|x64.Build.0 = Release|Any CPU
|
||||
{558F7646-AC3F-4E5E-853D-9F2A09E89C06}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{558F7646-AC3F-4E5E-853D-9F2A09E89C06}.Release|x86.Build.0 = Release|Any CPU
|
||||
{788339D3-F0C3-4F1A-9216-501814C7BF78}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{788339D3-F0C3-4F1A-9216-501814C7BF78}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{788339D3-F0C3-4F1A-9216-501814C7BF78}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{788339D3-F0C3-4F1A-9216-501814C7BF78}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{788339D3-F0C3-4F1A-9216-501814C7BF78}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{788339D3-F0C3-4F1A-9216-501814C7BF78}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
EndGlobalSection
|
||||
GlobalSection(NestedProjects) = preSolution
|
||||
{788339D3-F0C3-4F1A-9216-501814C7BF78} = {E927F87B-A585-A586-52B2-1371051E189F}
|
||||
EndGlobalSection
|
||||
EndGlobal
|
||||
|
||||
+5
-1
@@ -3,6 +3,10 @@
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
ShutdownMode="OnMainWindowClose">
|
||||
<Application.Resources>
|
||||
|
||||
<ResourceDictionary>
|
||||
<ResourceDictionary.MergedDictionaries>
|
||||
<ResourceDictionary Source="Themes/Controls.xaml" />
|
||||
</ResourceDictionary.MergedDictionaries>
|
||||
</ResourceDictionary>
|
||||
</Application.Resources>
|
||||
</Application>
|
||||
|
||||
+57
-7
@@ -1,5 +1,4 @@
|
||||
using System.Windows;
|
||||
using CursorLang.Models;
|
||||
using CursorLang.Services;
|
||||
using CursorLang.ViewModels;
|
||||
using CursorLang.Views;
|
||||
@@ -7,51 +6,102 @@ using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace CursorLang;
|
||||
|
||||
/// <summary>
|
||||
/// Композиционный корень: собирает контейнер и запускает окно настроек.
|
||||
/// </summary>
|
||||
// ReSharper disable once RedundantExtendsListEntry
|
||||
public partial class App : Application
|
||||
{
|
||||
private ServiceProvider? _services;
|
||||
private SingleInstanceGate? _instanceGate;
|
||||
private MainWindowPlacement? _placement;
|
||||
|
||||
protected override void OnStartup(StartupEventArgs e)
|
||||
{
|
||||
base.OnStartup(e);
|
||||
|
||||
var gate = new SingleInstanceGate();
|
||||
if (!gate.TryAcquire())
|
||||
{
|
||||
gate.Dispose();
|
||||
Shutdown();
|
||||
return;
|
||||
}
|
||||
|
||||
_instanceGate = gate;
|
||||
_instanceGate.ActivationRequested += OnActivationRequested;
|
||||
|
||||
var services = new ServiceCollection();
|
||||
ConfigureServices(services);
|
||||
_services = services.BuildServiceProvider();
|
||||
|
||||
_services.GetRequiredService<ThemeService>();
|
||||
|
||||
_placement = _services.GetRequiredService<MainWindowPlacement>();
|
||||
MainWindow = _services.GetRequiredService<MainWindow>();
|
||||
MainWindow.Show();
|
||||
|
||||
_ = _services.GetRequiredService<SettingsViewModel>().InitializeAsync();
|
||||
_ = _services.GetRequiredService<UpdateViewModel>().StartAsync();
|
||||
_services.GetRequiredService<LayoutNotificationCoordinator>().Start();
|
||||
_services.GetRequiredService<CapsLockSwitchCoordinator>().Start();
|
||||
}
|
||||
|
||||
protected override void OnExit(ExitEventArgs e)
|
||||
{
|
||||
// Контейнер сам остановит таймеры, сохранит настройки
|
||||
// и закроет окно подсказки
|
||||
_services?.Dispose();
|
||||
|
||||
if (_instanceGate is not null)
|
||||
{
|
||||
_instanceGate.ActivationRequested -= OnActivationRequested;
|
||||
_instanceGate.Dispose();
|
||||
}
|
||||
|
||||
base.OnExit(e);
|
||||
}
|
||||
|
||||
private static void ConfigureServices(IServiceCollection services)
|
||||
private void OnActivationRequested(object? sender, EventArgs e)
|
||||
{
|
||||
if (MainWindow is not { IsVisible: true })
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (MainWindow.WindowState == WindowState.Minimized)
|
||||
{
|
||||
MainWindow.WindowState = WindowState.Normal;
|
||||
}
|
||||
|
||||
_placement?.Apply(MainWindow);
|
||||
|
||||
MainWindow.Activate();
|
||||
}
|
||||
|
||||
internal static void ConfigureServices(IServiceCollection services)
|
||||
{
|
||||
services.AddSingleton(new KeyboardLayoutOptions());
|
||||
services.AddSingleton(new UpdateOptions());
|
||||
|
||||
services.AddSingleton<SettingsService>();
|
||||
services.AddSingleton(provider => provider.GetRequiredService<SettingsService>().Load());
|
||||
|
||||
services.AddSingleton<ThemeService>();
|
||||
services.AddSingleton<IThemeService>(provider => provider.GetRequiredService<ThemeService>());
|
||||
|
||||
services.AddSingleton<MainWindowPlacement>();
|
||||
|
||||
services.AddSingleton<ILocalizationService, LocalizationService>();
|
||||
services.AddSingleton<IStartupService, StartupService>();
|
||||
services.AddSingleton<IUpdateService, UpdateService>();
|
||||
services.AddSingleton<IKeyboardLayoutService, KeyboardLayoutService>();
|
||||
services.AddSingleton<ILayoutPopupService, LayoutPopupService>();
|
||||
services.AddSingleton<ICapsLockHotkeyService, CapsLockHotkeyService>();
|
||||
services.AddSingleton<LayoutNotificationCoordinator>();
|
||||
services.AddSingleton<CapsLockSwitchCoordinator>();
|
||||
|
||||
services.AddSingleton<LayoutPopupViewModel>();
|
||||
services.AddSingleton<UpdateViewModel>();
|
||||
services.AddSingleton<SettingsViewModel>();
|
||||
|
||||
services.AddSingleton<LayoutPopupWindow>();
|
||||
services.AddSingleton<ILayoutPopupWindow>(provider => provider.GetRequiredService<LayoutPopupWindow>());
|
||||
services.AddSingleton<MainWindow>();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Windows;
|
||||
|
||||
[assembly: ThemeInfo(
|
||||
ResourceDictionaryLocation.None, //where theme specific resource dictionaries are located
|
||||
//(used if a resource is not found in the page,
|
||||
// or application resource dictionaries)
|
||||
ResourceDictionaryLocation.SourceAssembly //where the generic resource dictionary is located
|
||||
//(used if a resource is not found in the page,
|
||||
// app, or any theme specific resource dictionaries)
|
||||
)]
|
||||
[assembly: ThemeInfo(ResourceDictionaryLocation.None, ResourceDictionaryLocation.SourceAssembly)]
|
||||
[assembly: InternalsVisibleTo("CursorLang.Tests")]
|
||||
|
||||
@@ -1,11 +1,30 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>WinExe</OutputType>
|
||||
<TargetFramework>net10.0-windows</TargetFramework>
|
||||
<TargetFramework>net10.0-windows10.0.19041.0</TargetFramework>
|
||||
<SupportedOSPlatformVersion>10.0.17763.0</SupportedOSPlatformVersion>
|
||||
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
<NoWarn>$(NoWarn);CS1591</NoWarn>
|
||||
<UseWPF>true</UseWPF>
|
||||
<ApplicationManifest>app.manifest</ApplicationManifest>
|
||||
<ApplicationIcon>Resources\CursorLang.ico</ApplicationIcon>
|
||||
<RuntimeIdentifiers>win-x64;win-arm64</RuntimeIdentifiers>
|
||||
<PlatformTarget>AnyCPU</PlatformTarget>
|
||||
<SelfContained Condition="'$(RuntimeIdentifier)' != ''">true</SelfContained>
|
||||
<PublishTrimmed>false</PublishTrimmed>
|
||||
<PublishReadyToRun Condition="'$(RuntimeIdentifier)' == 'win-x64'">true</PublishReadyToRun>
|
||||
<Version>1.0.0</Version>
|
||||
<AssemblyVersion>1.0.0.0</AssemblyVersion>
|
||||
<FileVersion>1.0.0.0</FileVersion>
|
||||
<Product>CursorLang</Product>
|
||||
<Company>Aleksandr Neychev</Company>
|
||||
<Description>Shows the keyboard layout at the cursor</Description>
|
||||
<Copyright>Copyright (c) 2026</Copyright>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -6,12 +6,12 @@ using Accessibility;
|
||||
namespace CursorLang.Interop;
|
||||
|
||||
/// <summary>
|
||||
/// Определяет положение каретки в активном поле ввода — в том числе в чужом приложении.
|
||||
/// Locates the caret in the active input field — including one in another application.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Единого способа нет: классические Win32-приложения заводят системную каретку,
|
||||
/// а Chrome, Electron и прочие рисуют её сами и сообщают положение только через
|
||||
/// средства доступности. Поэтому сначала спрашиваем систему, затем — приложение.
|
||||
/// There is no single way to do it: classic Win32 applications create a system caret,
|
||||
/// while Chrome, Electron and others draw it themselves and report its position only
|
||||
/// through accessibility interfaces. So we ask the system first, then the application.
|
||||
/// </remarks>
|
||||
internal static class CaretNative
|
||||
{
|
||||
@@ -32,8 +32,8 @@ internal static class CaretNative
|
||||
private const int CHILDID_SELF = 0;
|
||||
|
||||
/// <summary>
|
||||
/// Прямоугольник каретки в пикселях экрана или <c>null</c>, если активное
|
||||
/// приложение не сообщает её положение.
|
||||
/// The caret rectangle in screen pixels, or <c>null</c> when the active
|
||||
/// application does not report its position.
|
||||
/// </summary>
|
||||
internal static PopupWindowNative.Rect? TryGetCaretRect()
|
||||
{
|
||||
@@ -50,13 +50,13 @@ internal static class CaretNative
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Отсеивает заведомо неверные координаты.
|
||||
/// Filters out obviously wrong coordinates.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Часть приложений отдаёт положение каретки в своей системе координат либо
|
||||
/// без учёта масштаба экрана, и подсказка уезжает далеко от поля ввода.
|
||||
/// Каретка обязана находиться внутри окна ввода — это и проверяем, а перед
|
||||
/// отказом пробуем истолковать координаты как немасштабированные.
|
||||
/// Some applications report the caret position in their own coordinate system or
|
||||
/// without accounting for display scaling, and the popup ends up far from the input
|
||||
/// field. The caret must be inside the input window — that is what we check, and
|
||||
/// before giving up we try to read the coordinates as unscaled ones.
|
||||
/// </remarks>
|
||||
private static PopupWindowNative.Rect? Validate(PopupWindowNative.Rect caret, IntPtr hwndFocus)
|
||||
{
|
||||
@@ -65,12 +65,21 @@ internal static class CaretNative
|
||||
return caret;
|
||||
}
|
||||
|
||||
return Validate(caret, window, GetDpiForWindow(hwndFocus) / 96.0);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The same check over plain numbers: the input window bounds and the scale of
|
||||
/// its monitor are already known.
|
||||
/// </summary>
|
||||
internal static PopupWindowNative.Rect? Validate(
|
||||
PopupWindowNative.Rect caret, PopupWindowNative.Rect window, double scale)
|
||||
{
|
||||
if (IsInside(caret, window))
|
||||
{
|
||||
return caret;
|
||||
}
|
||||
|
||||
double scale = GetDpiForWindow(hwndFocus) / 96.0;
|
||||
var scaled = new PopupWindowNative.Rect
|
||||
{
|
||||
Left = (int)(caret.Left * scale),
|
||||
@@ -82,20 +91,20 @@ internal static class CaretNative
|
||||
return IsInside(scaled, window) ? scaled : null;
|
||||
}
|
||||
|
||||
private static bool IsInside(PopupWindowNative.Rect inner, PopupWindowNative.Rect outer) =>
|
||||
internal static bool IsInside(PopupWindowNative.Rect inner, PopupWindowNative.Rect outer) =>
|
||||
inner.Left >= outer.Left && inner.Right <= outer.Right &&
|
||||
inner.Top >= outer.Top && inner.Bottom <= outer.Bottom;
|
||||
|
||||
/// <summary>Сколько ждём ответа от чужого приложения по UI Automation.</summary>
|
||||
/// <summary>How long we wait for another application to answer over UI Automation.</summary>
|
||||
private static readonly TimeSpan AutomationTimeout = TimeSpan.FromMilliseconds(150);
|
||||
|
||||
// Браузеры и другие приложения на своих движках рисуют каретку сами и
|
||||
// сообщают её положение только через UI Automation. Запрос идёт в чужой
|
||||
// процесс, поэтому он самый медленный и стоит последним
|
||||
// Browsers and other applications with their own rendering engines draw the caret
|
||||
// themselves and report its position only through UI Automation. The request goes
|
||||
// into another process, so it is the slowest one and comes last
|
||||
private static PopupWindowNative.Rect? TryGetAutomationCaret()
|
||||
{
|
||||
// Зависшее приложение не должно подвешивать подсказку вместе с собой:
|
||||
// ждём ответ ограниченное время, иначе показываем у курсора
|
||||
// A hung application must not hang the popup along with it: we wait for the
|
||||
// answer for a limited time, otherwise we show the popup at the cursor
|
||||
Task<PopupWindowNative.Rect?> query = Task.Run(QueryAutomationCaret);
|
||||
return query.Wait(AutomationTimeout) ? query.Result : null;
|
||||
}
|
||||
@@ -117,8 +126,8 @@ internal static class CaretNative
|
||||
return null;
|
||||
}
|
||||
|
||||
// У каретки выделение пустое и прямоугольника не имеет,
|
||||
// поэтому расширяем его до ближайшего символа
|
||||
// The caret has an empty selection and therefore no rectangle,
|
||||
// so we expand it to the nearest character
|
||||
TextPatternRange range = selection[0].Clone();
|
||||
range.ExpandToEnclosingUnit(TextUnit.Character);
|
||||
|
||||
@@ -141,12 +150,12 @@ internal static class CaretNative
|
||||
or InvalidOperationException
|
||||
or COMException)
|
||||
{
|
||||
// Приложение закрылось или не отвечает — подсказку это ронять не должно
|
||||
// The application closed or stopped responding — that must not take the popup down
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Системная каретка: координаты приходят относительно окна, которому она принадлежит
|
||||
// The system caret: its coordinates come relative to the window that owns it
|
||||
private static PopupWindowNative.Rect? TryGetSystemCaret(ForegroundInputNative.GuiThreadInfo info)
|
||||
{
|
||||
if (info.hwndCaret == IntPtr.Zero || IsEmpty(info.rcCaret))
|
||||
@@ -170,7 +179,7 @@ internal static class CaretNative
|
||||
};
|
||||
}
|
||||
|
||||
// Каретка через средства доступности: сюда попадают браузеры и Electron
|
||||
// The caret through accessibility interfaces: this is where browsers and Electron land
|
||||
private static PopupWindowNative.Rect? TryGetAccessibleCaret(IntPtr hwndFocus)
|
||||
{
|
||||
if (hwndFocus == IntPtr.Zero)
|
||||
@@ -199,7 +208,7 @@ internal static class CaretNative
|
||||
}
|
||||
catch (COMException)
|
||||
{
|
||||
// Приложение объявило поддержку, но положение не отдало
|
||||
// The application declared support but did not report the position
|
||||
return null;
|
||||
}
|
||||
finally
|
||||
@@ -208,7 +217,7 @@ internal static class CaretNative
|
||||
}
|
||||
}
|
||||
|
||||
// Когда каретки нет, её прямоугольник приходит нулевой высоты.
|
||||
// Судим только по высоте: нулевые координаты — это обычное начало пустого поля
|
||||
private static bool IsEmpty(PopupWindowNative.Rect rect) => rect.Bottom - rect.Top <= 0;
|
||||
// When there is no caret, its rectangle comes back with zero height.
|
||||
// We judge by height alone: zero coordinates are a normal start of an empty field
|
||||
internal static bool IsEmpty(PopupWindowNative.Rect rect) => rect.Bottom - rect.Top <= 0;
|
||||
}
|
||||
|
||||
@@ -3,8 +3,8 @@ using System.Runtime.InteropServices;
|
||||
namespace CursorLang.Interop;
|
||||
|
||||
/// <summary>
|
||||
/// Сведения о вводе в активном приложении: какое окно держит фокус клавиатуры
|
||||
/// и где находится каретка.
|
||||
/// Input details of the active application: which window holds keyboard focus
|
||||
/// and where the caret is.
|
||||
/// </summary>
|
||||
internal static class ForegroundInputNative
|
||||
{
|
||||
@@ -26,12 +26,12 @@ internal static class ForegroundInputNative
|
||||
private static extern bool GetGUIThreadInfo(uint idThread, ref GuiThreadInfo lpgui);
|
||||
|
||||
/// <summary>
|
||||
/// Состояние ввода потока переднего плана.
|
||||
/// The input state of the foreground thread.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Нулевой идентификатор потока не случаен: у современных приложений окно
|
||||
/// верхнего уровня и окно ввода живут в разных потоках, и спрашивать нужно
|
||||
/// именно про передний план целиком.
|
||||
/// The zero thread identifier is not accidental: in modern applications the
|
||||
/// top-level window and the input window live in different threads, and the
|
||||
/// question has to be about the foreground as a whole.
|
||||
/// </remarks>
|
||||
internal static bool TryGetInfo(out GuiThreadInfo info)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace CursorLang.Interop;
|
||||
|
||||
/// <summary>
|
||||
/// The right to bring a window to the foreground.
|
||||
/// </summary>
|
||||
internal static class ForegroundPermissionNative
|
||||
{
|
||||
/// <summary>ASFW_ANY — any process gets the right.</summary>
|
||||
private const uint AnyProcess = 0xFFFFFFFF;
|
||||
|
||||
/// <summary>
|
||||
/// Gives up our right to bring a window to the foreground in favour of other processes.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Windows does not let just anyone change the foreground window: you have to be
|
||||
/// the process the user interacted with last. An instance started long ago is not
|
||||
/// one of those, and its window will come up only if the right is shared by the
|
||||
/// process the user has just launched.
|
||||
/// </remarks>
|
||||
internal static void GrantToAnyProcess() => AllowSetForegroundWindow(AnyProcess);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
private static extern bool AllowSetForegroundWindow(uint dwProcessId);
|
||||
}
|
||||
@@ -3,10 +3,18 @@ using System.Runtime.InteropServices;
|
||||
namespace CursorLang.Interop;
|
||||
|
||||
/// <summary>
|
||||
/// Win32 API для определения раскладки активного приложения.
|
||||
/// Win32 API for reading the layout of the active application.
|
||||
/// </summary>
|
||||
internal static class KeyboardLayoutNative
|
||||
{
|
||||
private const uint WmInputLangChangeRequest = 0x0050;
|
||||
|
||||
/// <summary>Take the next layout from the system list.</summary>
|
||||
private static readonly IntPtr InputLangChangeForward = new(0x0002);
|
||||
|
||||
/// <summary>HKL_NEXT — the same request in the language of older Windows versions.</summary>
|
||||
private static readonly IntPtr HklNext = new(1);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
internal static extern IntPtr GetForegroundWindow();
|
||||
|
||||
@@ -16,29 +24,58 @@ internal static class KeyboardLayoutNative
|
||||
[DllImport("user32.dll")]
|
||||
private static extern IntPtr GetKeyboardLayout(uint idThread);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
private static extern bool PostMessage(IntPtr hWnd, uint msg, IntPtr wParam, IntPtr lParam);
|
||||
|
||||
/// <summary>
|
||||
/// Раскладка, которой сейчас печатает пользователь.
|
||||
/// The layout the user is currently typing with.
|
||||
/// </summary>
|
||||
internal static int GetActiveLocaleId() => GetLocaleIdOf(GetInputWindow());
|
||||
|
||||
/// <summary>
|
||||
/// Asks the active application to switch to the next layout from the system list.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Спрашиваем окно с фокусом клавиатуры, а не окно верхнего плана: у Блокнота
|
||||
/// Windows 11, меню «Пуск» и прочих приложений на WinUI поле ввода живёт в
|
||||
/// отдельном потоке, и раскладка меняется только у него. У потока главного
|
||||
/// окна она остаётся прежней, и переключение проходит незамеченным.
|
||||
/// Synthesizing a system shortcut such as Alt+Shift will not do: the user can
|
||||
/// reassign it in the Windows settings or turn it off entirely. A request sent as
|
||||
/// a message does not depend on those settings and works in another process.
|
||||
/// </remarks>
|
||||
internal static int GetActiveLocaleId()
|
||||
internal static void RequestNextLayout()
|
||||
{
|
||||
IntPtr target = GetInputWindow();
|
||||
if (target == IntPtr.Zero)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Both parameters mean the same thing: different Windows versions and different
|
||||
// UI frameworks look either at the flag or at lParam
|
||||
PostMessage(target, WmInputLangChangeRequest, InputLangChangeForward, HklNext);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The window that owns keyboard input.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// We ask the window with keyboard focus rather than the foreground window: in
|
||||
/// Windows 11 Notepad, the Start menu and other WinUI applications the input field
|
||||
/// lives in a separate thread, and the layout changes only for that thread. For the
|
||||
/// main window's thread it stays the same, and the switch goes unnoticed.
|
||||
/// </remarks>
|
||||
private static IntPtr GetInputWindow()
|
||||
{
|
||||
if (ForegroundInputNative.TryGetInfo(out ForegroundInputNative.GuiThreadInfo info) &&
|
||||
info.hwndFocus != IntPtr.Zero)
|
||||
{
|
||||
return GetLocaleIdOf(info.hwndFocus);
|
||||
return info.hwndFocus;
|
||||
}
|
||||
|
||||
return GetLocaleIdOf(GetForegroundWindow());
|
||||
return GetForegroundWindow();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Идентификатор локали для окна. Раскладка в Windows привязана к потоку,
|
||||
/// поэтому так её видно у любого приложения, а не только у своего.
|
||||
/// The locale identifier for a window. In Windows the layout is bound to a thread,
|
||||
/// so this reveals it for any application, not only for our own.
|
||||
/// </summary>
|
||||
internal static int GetLocaleIdOf(IntPtr hWnd)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace CursorLang.Interop;
|
||||
|
||||
/// <summary>
|
||||
/// A system keyboard hook (WH_KEYBOARD_LL): sees key presses in every application
|
||||
/// and can keep them from going any further.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Only the low-level hook is installed: a regular WH_KEYBOARD requires injecting a
|
||||
/// DLL into other processes, which is impossible for managed code. The callback
|
||||
/// arrives on the thread that installed the hook, and that thread must pump a message
|
||||
/// loop — hence the requirement to install the hook from the user interface thread.
|
||||
/// Returning control must not be delayed: once the system timeout expires, Windows
|
||||
/// silently removes the hook.
|
||||
/// </remarks>
|
||||
internal sealed class LowLevelKeyboardHook : IDisposable
|
||||
{
|
||||
/// <summary>
|
||||
/// A key event handler. Returns <c>true</c> when the event must be swallowed —
|
||||
/// then the foreground application will not see it.
|
||||
/// </summary>
|
||||
internal delegate bool KeyFilter(int virtualKey, bool isKeyDown);
|
||||
|
||||
private const int WhKeyboardLowLevel = 13;
|
||||
private const int HcAction = 0;
|
||||
|
||||
private const int WmKeyDown = 0x0100;
|
||||
private const int WmKeyUp = 0x0101;
|
||||
private const int WmSysKeyDown = 0x0104;
|
||||
private const int WmSysKeyUp = 0x0105;
|
||||
|
||||
/// <summary>The event came from SendInput rather than from a real key press.</summary>
|
||||
private const uint LowLevelKeyHookFlagInjected = 0x10;
|
||||
|
||||
private readonly KeyFilter _filter;
|
||||
|
||||
// The delegate lives in a field not for convenience: the only reference to it is
|
||||
// held by Win32, which the garbage collector knows nothing about, and without the
|
||||
// field the hook stops working after a random amount of time
|
||||
private readonly HookProc _callback;
|
||||
|
||||
private IntPtr _handle;
|
||||
|
||||
internal LowLevelKeyboardHook(KeyFilter filter)
|
||||
{
|
||||
_filter = filter;
|
||||
_callback = OnHookEvent;
|
||||
}
|
||||
|
||||
private delegate IntPtr HookProc(int code, IntPtr wParam, IntPtr lParam);
|
||||
|
||||
internal bool IsInstalled => _handle != IntPtr.Zero;
|
||||
|
||||
/// <summary>
|
||||
/// Installs the hook. Returns <c>false</c> when the system refuses.
|
||||
/// </summary>
|
||||
internal bool Install()
|
||||
{
|
||||
if (_handle != IntPtr.Zero)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
_handle = SetWindowsHookEx(WhKeyboardLowLevel, _callback, GetModuleHandle(null), 0);
|
||||
return _handle != IntPtr.Zero;
|
||||
}
|
||||
|
||||
internal void Uninstall()
|
||||
{
|
||||
if (_handle == IntPtr.Zero)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
UnhookWindowsHookEx(_handle);
|
||||
_handle = IntPtr.Zero;
|
||||
}
|
||||
|
||||
public void Dispose() => Uninstall();
|
||||
|
||||
[DllImport("user32.dll", SetLastError = true)]
|
||||
private static extern IntPtr SetWindowsHookEx(int idHook, HookProc lpfn, IntPtr hMod, uint dwThreadId);
|
||||
|
||||
[DllImport("user32.dll", SetLastError = true)]
|
||||
private static extern bool UnhookWindowsHookEx(IntPtr hhk);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
private static extern IntPtr CallNextHookEx(IntPtr hhk, int code, IntPtr wParam, IntPtr lParam);
|
||||
|
||||
[DllImport("kernel32.dll", CharSet = CharSet.Unicode)]
|
||||
private static extern IntPtr GetModuleHandle(string? lpModuleName);
|
||||
|
||||
private IntPtr OnHookEvent(int code, IntPtr wParam, IntPtr lParam)
|
||||
{
|
||||
if (code != HcAction)
|
||||
{
|
||||
return CallNextHookEx(_handle, code, wParam, lParam);
|
||||
}
|
||||
|
||||
var message = (int)wParam;
|
||||
bool isKeyDown = message is WmKeyDown or WmSysKeyDown;
|
||||
bool isKeyUp = message is WmKeyUp or WmSysKeyUp;
|
||||
|
||||
var data = Marshal.PtrToStructure<KeyboardHookData>(lParam);
|
||||
|
||||
// Synthetic input comes from on-screen keyboards, text expanders and
|
||||
// automation tools: overriding what they do is none of our business
|
||||
bool injected = (data.flags & LowLevelKeyHookFlagInjected) != 0;
|
||||
|
||||
if ((isKeyDown || isKeyUp) && !injected && _filter((int)data.vkCode, isKeyDown))
|
||||
{
|
||||
// A non-zero result instead of CallNextHookEx breaks the chain: the event
|
||||
// will reach neither the application nor the Windows caps-lock handler
|
||||
return 1;
|
||||
}
|
||||
|
||||
return CallNextHookEx(_handle, code, wParam, lParam);
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
private struct KeyboardHookData
|
||||
{
|
||||
public uint vkCode;
|
||||
public uint scanCode;
|
||||
public uint flags;
|
||||
public uint time;
|
||||
public IntPtr dwExtraInfo;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace CursorLang.Interop;
|
||||
|
||||
/// <summary>
|
||||
/// Answers whether the application runs from an MSIX package.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The same application runs both installed from the Store and simply unpacked
|
||||
/// into a folder. Some Windows features — startup through <c>StartupTask</c>, for
|
||||
/// instance — are available only to a package, and reaching for them without a
|
||||
/// check is not allowed: outside a package they throw.
|
||||
/// </remarks>
|
||||
internal static class PackageIdentityNative
|
||||
{
|
||||
/// <summary>APPMODEL_ERROR_NO_PACKAGE — the process runs outside a package.</summary>
|
||||
private const int NoPackage = 15700;
|
||||
|
||||
/// <summary>
|
||||
/// The application runs from an MSIX package.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The value is computed once: it cannot change during the lifetime of
|
||||
/// the process.
|
||||
/// </remarks>
|
||||
internal static bool IsPackaged { get; } = DetectPackage();
|
||||
|
||||
private static bool DetectPackage()
|
||||
{
|
||||
// The answer comes from the return code rather than from the name itself, so
|
||||
// no buffer is needed: with zero length a package replies complaining about space
|
||||
uint length = 0;
|
||||
return GetCurrentPackageFullName(ref length, IntPtr.Zero) != NoPackage;
|
||||
}
|
||||
|
||||
[DllImport("kernel32.dll", CharSet = CharSet.Unicode)]
|
||||
private static extern int GetCurrentPackageFullName(ref uint packageFullNameLength, IntPtr packageFullName);
|
||||
}
|
||||
@@ -3,8 +3,8 @@ using System.Runtime.InteropServices;
|
||||
namespace CursorLang.Interop;
|
||||
|
||||
/// <summary>
|
||||
/// Win32 API для окна-подсказки: стили, позиционирование у курсора
|
||||
/// и масштаб монитора, на котором курсор находится.
|
||||
/// Win32 API for the popup window: styles, positioning near the cursor
|
||||
/// and the scale of the monitor the cursor is on.
|
||||
/// </summary>
|
||||
internal static class PopupWindowNative
|
||||
{
|
||||
@@ -62,9 +62,9 @@ internal static class PopupWindowNative
|
||||
}
|
||||
|
||||
private const int GWL_EXSTYLE = -20;
|
||||
// Окно не забирает фокус у активного приложения
|
||||
// The window does not take focus away from the active application
|
||||
private const int WS_EX_NOACTIVATE = 0x08000000;
|
||||
// И не попадает в Alt+Tab
|
||||
// And does not show up in Alt+Tab
|
||||
private const int WS_EX_TOOLWINDOW = 0x00000080;
|
||||
|
||||
private const uint SWP_NOSIZE = 0x0001;
|
||||
@@ -81,8 +81,8 @@ internal static class PopupWindowNative
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Подсказка всплывает поверх чужих приложений, поэтому она не должна
|
||||
/// ни активироваться сама, ни отбирать фокус ввода у активного окна.
|
||||
/// The popup shows up on top of other applications, so it must neither
|
||||
/// activate itself nor steal input focus from the active window.
|
||||
/// </summary>
|
||||
internal static void MakePassive(IntPtr hWnd)
|
||||
{
|
||||
@@ -91,10 +91,10 @@ internal static class PopupWindowNative
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Двигает окно в точку экрана, не меняя размер и порядок окон.
|
||||
/// Координаты — физические пиксели: у мониторов разный масштаб, а
|
||||
/// Window.Left/Top пересчитываются по DPI того монитора, где окно сейчас,
|
||||
/// и на соседнем мониторе дают промах.
|
||||
/// Moves the window to a screen point without changing its size or z-order.
|
||||
/// The coordinates are physical pixels: monitors have different scaling, while
|
||||
/// Window.Left/Top are converted using the DPI of the monitor the window is on
|
||||
/// right now, which misses the target on a neighbouring monitor.
|
||||
/// </summary>
|
||||
internal static void MoveTo(IntPtr hWnd, int x, int y)
|
||||
{
|
||||
@@ -102,26 +102,27 @@ internal static class PopupWindowNative
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Задаёт положение и размер окна в физических пикселях.
|
||||
/// Sets the window position and size in physical pixels.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Размер выставляется именно так, а не через Width/Height: при первом показе
|
||||
/// окно ещё подчиняется системному минимальному размеру окна (SM_CXMIN×SM_CYMIN)
|
||||
/// и подсказка выходит заметно крупнее текста. К моменту вызова окно уже
|
||||
/// показано и стало popup-окном, на которое это ограничение не действует.
|
||||
/// The size is set this way rather than through Width/Height: on the first show the
|
||||
/// window is still subject to the system minimum window size (SM_CXMIN×SM_CYMIN)
|
||||
/// and the popup comes out noticeably larger than its text. By the time this is
|
||||
/// called the window is already shown and has become a popup window, which that
|
||||
/// restriction does not apply to.
|
||||
/// </remarks>
|
||||
internal static void SetBounds(IntPtr hWnd, int x, int y, int width, int height)
|
||||
{
|
||||
SetWindowPos(hWnd, IntPtr.Zero, x, y, width, height, SWP_NOZORDER | SWP_NOACTIVATE);
|
||||
}
|
||||
|
||||
/// <summary>Масштаб монитора, на котором находится точка (1.0 при 96 DPI).</summary>
|
||||
/// <summary>The scale of the monitor the point is on (1.0 at 96 DPI).</summary>
|
||||
internal static double GetScaleAt(Point point) =>
|
||||
GetScaleOf(MonitorFromPoint(point, MONITOR_DEFAULTTONEAREST));
|
||||
|
||||
/// <summary>
|
||||
/// Рабочая область монитора с активным окном — без панели задач — и его масштаб.
|
||||
/// Именно на этом мониторе пользователь сейчас работает.
|
||||
/// The work area of the monitor holding the active window — without the taskbar —
|
||||
/// and its scale. That is the monitor the user is working on right now.
|
||||
/// </summary>
|
||||
internal static (Rect WorkArea, double Scale) GetActiveMonitorWorkArea()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace CursorLang.Interop;
|
||||
|
||||
/// <summary>
|
||||
/// Win32 API for placing the settings window: its own bounds and the work area
|
||||
/// of the monitor the window is asked to be put on.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The bounds are taken from the system rather than from <c>Window.Left/Top/Width/Height</c>:
|
||||
/// the window height adapts to its content, and WPF converts those properties using the
|
||||
/// monitor DPI, while the monitor work area comes in pixels. Computing the centre in a
|
||||
/// single unit is simpler than converting back and forth.
|
||||
/// </remarks>
|
||||
internal static class WindowPlacementNative
|
||||
{
|
||||
[DllImport("user32.dll")]
|
||||
private static extern bool GetWindowRect(IntPtr hWnd, out PopupWindowNative.Rect lpRect);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
private static extern IntPtr MonitorFromRect(ref PopupWindowNative.Rect lprc, uint dwFlags);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
private static extern bool GetMonitorInfo(IntPtr hMonitor, ref MonitorInfo lpmi);
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
private struct MonitorInfo
|
||||
{
|
||||
public int cbSize;
|
||||
public PopupWindowNative.Rect rcMonitor;
|
||||
public PopupWindowNative.Rect rcWork;
|
||||
public uint dwFlags;
|
||||
}
|
||||
|
||||
private const uint MONITOR_DEFAULTTONEAREST = 2;
|
||||
|
||||
/// <summary>The window bounds in screen pixels — including the frame and the title bar.</summary>
|
||||
internal static PopupWindowNative.Rect? TryGetBounds(IntPtr hWnd) =>
|
||||
GetWindowRect(hWnd, out PopupWindowNative.Rect bounds) ? bounds : null;
|
||||
|
||||
/// <summary>
|
||||
/// The work area — without the taskbar — of the monitor that holds the
|
||||
/// rectangle entirely, or at least most of it.
|
||||
/// </summary>
|
||||
internal static PopupWindowNative.Rect? TryGetWorkAreaNear(PopupWindowNative.Rect rect)
|
||||
{
|
||||
IntPtr monitor = MonitorFromRect(ref rect, MONITOR_DEFAULTTONEAREST);
|
||||
|
||||
var info = new MonitorInfo { cbSize = Marshal.SizeOf<MonitorInfo>() };
|
||||
return GetMonitorInfo(monitor, ref info) ? info.rcWork : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace CursorLang.Interop;
|
||||
|
||||
/// <summary>
|
||||
/// Window frame styling by the system: the title bar is drawn by Windows,
|
||||
/// and in the dark theme it has to be switched separately from the window content.
|
||||
/// </summary>
|
||||
internal static class WindowThemeNative
|
||||
{
|
||||
[DllImport("dwmapi.dll")]
|
||||
private static extern int DwmSetWindowAttribute(IntPtr hWnd, int attribute, ref int value, int size);
|
||||
|
||||
private const int DWMWA_USE_IMMERSIVE_DARK_MODE = 20;
|
||||
|
||||
/// <summary>
|
||||
/// Recolours the window title bar. On Windows 10 builds before 2004 the attribute
|
||||
/// is not supported — the title bar simply stays light.
|
||||
/// </summary>
|
||||
internal static void SetDarkTitleBar(IntPtr hWnd, bool isDark)
|
||||
{
|
||||
if (hWnd == IntPtr.Zero)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
int value = isDark ? 1 : 0;
|
||||
DwmSetWindowAttribute(hWnd, DWMWA_USE_IMMERSIVE_DARK_MODE, ref value, sizeof(int));
|
||||
}
|
||||
}
|
||||
@@ -5,46 +5,106 @@ using CommunityToolkit.Mvvm.ComponentModel;
|
||||
namespace CursorLang.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Настройки приложения. Все изменения применяются на лету: подсказка и окно
|
||||
/// настроек привязаны к этим свойствам, а <c>SettingsService</c> сохраняет их на диск.
|
||||
/// The application settings. Every change applies on the fly: the popup and the
|
||||
/// settings window are bound to these properties, and <c>SettingsService</c> saves
|
||||
/// them to disk.
|
||||
/// </summary>
|
||||
public sealed partial class AppSettings : ObservableObject
|
||||
{
|
||||
/// <summary>Язык интерфейса в виде кода культуры: «ru», «en».</summary>
|
||||
/// <summary>The interface language as a culture code: "ru", "en".</summary>
|
||||
[ObservableProperty]
|
||||
private string _language = "en";
|
||||
|
||||
/// <summary>The look of the settings window.</summary>
|
||||
[ObservableProperty]
|
||||
private AppTheme _theme = AppTheme.System;
|
||||
|
||||
/// <summary>Where the popup is shown: at the cursor, at the caret or at a fixed point.</summary>
|
||||
[ObservableProperty]
|
||||
private PopupPlacementMode _placementMode = PopupPlacementMode.AtCursor;
|
||||
|
||||
[ObservableProperty]
|
||||
private CursorCorner _cursorCorner = CursorCorner.BottomRight;
|
||||
// The side and the offset are stored per mode: the cursor and the caret call for
|
||||
// different settings, and switching the mode does not reset them
|
||||
|
||||
/// <summary>Отступ от курсора в единицах WPF.</summary>
|
||||
/// <summary>The side of the cursor the popup is put on.</summary>
|
||||
[ObservableProperty]
|
||||
private AnchorSide _cursorSide = AnchorSide.BottomRight;
|
||||
|
||||
/// <summary>The offset from the cursor in WPF units.</summary>
|
||||
[ObservableProperty]
|
||||
private double _cursorOffset = 16;
|
||||
|
||||
/// <summary>The side of the caret the popup is put on.</summary>
|
||||
[ObservableProperty]
|
||||
private AnchorSide _caretSide = AnchorSide.BottomRight;
|
||||
|
||||
/// <summary>The offset from the caret in WPF units.</summary>
|
||||
[ObservableProperty]
|
||||
private double _caretOffset = 16;
|
||||
|
||||
/// <summary>
|
||||
/// The place on the monitor for the <see cref="PopupPlacementMode.FixedPoint"/> mode.
|
||||
/// </summary>
|
||||
[ObservableProperty]
|
||||
private ScreenPosition _screenPosition = ScreenPosition.BottomRight;
|
||||
|
||||
/// <summary>Отступ от края монитора в единицах WPF.</summary>
|
||||
/// <summary>The offset from the monitor edge in WPF units.</summary>
|
||||
[ObservableProperty]
|
||||
private double _screenMargin = 24;
|
||||
|
||||
/// <summary>The size of the layout name in the popup, in WPF units.</summary>
|
||||
[ObservableProperty]
|
||||
private double _fontSize = 20;
|
||||
|
||||
/// <summary>Непрозрачность подсказки: 1.0 — полностью непрозрачная.</summary>
|
||||
/// <summary>The popup opacity: 1.0 is fully opaque.</summary>
|
||||
[ObservableProperty]
|
||||
private double _opacity = 0.9;
|
||||
|
||||
/// <summary>Сколько подсказка держится на экране, в миллисекундах.</summary>
|
||||
/// <summary>How long the popup stays on screen, in milliseconds.</summary>
|
||||
[ObservableProperty]
|
||||
private double _durationMilliseconds = 500;
|
||||
|
||||
/// <summary>
|
||||
/// Intercept Caps Lock and switch the layout with it instead of changing the case.
|
||||
/// Off by default: the application must not change the behaviour of the system
|
||||
/// until it is asked to.
|
||||
/// </summary>
|
||||
[ObservableProperty]
|
||||
private bool _useCapsLockHotkey;
|
||||
|
||||
/// <summary>
|
||||
/// After how long a Caps Lock hold cancels the switch, in milliseconds.
|
||||
/// </summary>
|
||||
[ObservableProperty]
|
||||
private double _capsLockHoldMilliseconds = 300;
|
||||
|
||||
/// <summary>
|
||||
/// Ask the repository about new versions on startup. A check can always be
|
||||
/// started by hand — this setting turns off only the automatic one.
|
||||
/// </summary>
|
||||
[ObservableProperty]
|
||||
private bool _checkForUpdates = true;
|
||||
|
||||
/// <summary>
|
||||
/// When the application last asked about new versions successfully.
|
||||
/// Stored so as not to go to the network on every startup.
|
||||
/// </summary>
|
||||
[ObservableProperty]
|
||||
private DateTimeOffset? _lastUpdateCheck;
|
||||
|
||||
/// <summary>The fill of the popup. The opacity is set by <see cref="Opacity"/>.</summary>
|
||||
[ObservableProperty]
|
||||
private Color _backgroundColor = Color.FromRgb(0x20, 0x20, 0x20);
|
||||
|
||||
/// <summary>The colour of the layout name in the popup.</summary>
|
||||
[ObservableProperty]
|
||||
private Color _foregroundColor = Color.FromRgb(0xFF, 0xFF, 0xFF);
|
||||
|
||||
/// <summary><see cref="DurationMilliseconds"/> as a <see cref="TimeSpan"/>.</summary>
|
||||
[JsonIgnore]
|
||||
public TimeSpan Duration => TimeSpan.FromMilliseconds(DurationMilliseconds);
|
||||
|
||||
/// <summary><see cref="CapsLockHoldMilliseconds"/> as a <see cref="TimeSpan"/>.</summary>
|
||||
[JsonIgnore]
|
||||
public TimeSpan CapsLockHoldDelay => TimeSpan.FromMilliseconds(CapsLockHoldMilliseconds);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace CursorLang.Models;
|
||||
|
||||
/// <summary>
|
||||
/// The look of the settings window. By default the application follows the Windows
|
||||
/// theme, but the user can pin the light or the dark one.
|
||||
/// </summary>
|
||||
public enum AppTheme
|
||||
{
|
||||
System,
|
||||
Light,
|
||||
Dark
|
||||
}
|
||||
@@ -3,16 +3,16 @@ using System.Globalization;
|
||||
namespace CursorLang.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Раскладка клавиатуры в удобном для отображения виде.
|
||||
/// A keyboard layout in a form convenient for display.
|
||||
/// </summary>
|
||||
/// <param name="LocaleId">Идентификатор локали (младшее слово HKL).</param>
|
||||
/// <param name="ShortName">Короткое имя для подсказки у курсора, например «RU».</param>
|
||||
/// <param name="DisplayName">Полное имя, например «RU — русский (Россия)».</param>
|
||||
/// <param name="LocaleId">The locale identifier (the low word of HKL).</param>
|
||||
/// <param name="ShortName">A short name for the popup at the cursor, "RU" for instance.</param>
|
||||
/// <param name="DisplayName">The full name, "RU — русский (Россия)" for instance.</param>
|
||||
public sealed record KeyboardLayout(int LocaleId, string ShortName, string DisplayName)
|
||||
{
|
||||
/// <summary>
|
||||
/// Строит модель по идентификатору локали. Неизвестные локали не являются
|
||||
/// ошибкой: для них показываем сам идентификатор.
|
||||
/// Builds the model from a locale identifier. Unknown locales are not an
|
||||
/// error: for them we show the identifier itself.
|
||||
/// </summary>
|
||||
public static KeyboardLayout FromLocaleId(int localeId)
|
||||
{
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
namespace CursorLang.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Почему изменилась текущая раскладка.
|
||||
/// Why the current layout has changed.
|
||||
/// </summary>
|
||||
public enum LayoutChangeReason
|
||||
{
|
||||
/// <summary>Пользователь переключил раскладку в активном приложении.</summary>
|
||||
/// <summary>The user switched the layout in the active application.</summary>
|
||||
UserSwitched,
|
||||
|
||||
/// <summary>Пользователь перешёл в другое приложение, где своя раскладка.</summary>
|
||||
/// <summary>The user moved to another application that has a layout of its own.</summary>
|
||||
ApplicationSwitched,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Данные события смены раскладки.
|
||||
/// The data of a layout change event.
|
||||
/// </summary>
|
||||
public sealed class LayoutChangedEventArgs(KeyboardLayout layout, LayoutChangeReason reason) : EventArgs
|
||||
{
|
||||
|
||||
@@ -1,43 +1,46 @@
|
||||
namespace CursorLang.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Способ выбора места для подсказки.
|
||||
/// How the place for the popup is chosen.
|
||||
/// </summary>
|
||||
public enum PopupPlacementMode
|
||||
{
|
||||
/// <summary>Рядом с курсором мыши.</summary>
|
||||
/// <summary>Next to the mouse cursor.</summary>
|
||||
AtCursor,
|
||||
|
||||
/// <summary>
|
||||
/// Рядом с кареткой в активном поле ввода. Если приложение не сообщает
|
||||
/// её положение, подсказка показывается у курсора мыши.
|
||||
/// Next to the caret in the active input field. When the application does not
|
||||
/// report its position, the popup is shown at the mouse cursor.
|
||||
/// </summary>
|
||||
AtCaret,
|
||||
|
||||
/// <summary>В заданной точке монитора с активным окном.</summary>
|
||||
/// <summary>At a fixed point of the monitor holding the active window.</summary>
|
||||
FixedPoint,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// С какой стороны от курсора или каретки показывать подсказку.
|
||||
/// Which side of the cursor or the caret to show the popup on.
|
||||
/// </summary>
|
||||
|
||||
public enum CursorCorner
|
||||
public enum AnchorSide
|
||||
{
|
||||
BottomRight,
|
||||
BottomLeft,
|
||||
TopRight,
|
||||
TopLeft,
|
||||
TopRight,
|
||||
Left,
|
||||
Right,
|
||||
BottomLeft,
|
||||
BottomRight,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Место на мониторе для режима <see cref="PopupPlacementMode.FixedPoint"/>.
|
||||
/// The place on the monitor for the <see cref="PopupPlacementMode.FixedPoint"/> mode.
|
||||
/// </summary>
|
||||
public enum ScreenPosition
|
||||
{
|
||||
TopLeft,
|
||||
Top,
|
||||
TopRight,
|
||||
BottomLeft,
|
||||
BottomRight,
|
||||
Center,
|
||||
BottomLeft,
|
||||
Bottom,
|
||||
BottomRight,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace CursorLang.Models;
|
||||
|
||||
/// <summary>
|
||||
/// A file attached to a release.
|
||||
/// </summary>
|
||||
/// <param name="FileName">The name the file is saved to disk under.</param>
|
||||
/// <param name="Url">A direct link to the content.</param>
|
||||
/// <param name="Size">The size in bytes; zero when the hosting did not report it.</param>
|
||||
public sealed record ReleaseAsset(string FileName, Uri Url, long Size);
|
||||
|
||||
/// <summary>
|
||||
/// A release found in the repository.
|
||||
/// </summary>
|
||||
/// <param name="Version">The version parsed from the tag.</param>
|
||||
/// <param name="Tag">The tag as is — that is what the interface shows.</param>
|
||||
/// <param name="PageUrl">The release page: the release notes live there too.</param>
|
||||
/// <param name="Package">The MSIX package the application updates itself with.</param>
|
||||
public sealed record ReleaseInfo(Version Version, string Tag, Uri? PageUrl, ReleaseAsset Package);
|
||||
@@ -0,0 +1,30 @@
|
||||
namespace CursorLang.Models;
|
||||
|
||||
/// <summary>
|
||||
/// The state of startup. Follows <c>StartupTaskState</c> of Windows: the user and
|
||||
/// the administrator each have their own way of forbidding startup, and the app has
|
||||
/// to tell them apart so as not to promise what it cannot do.
|
||||
/// </summary>
|
||||
public enum StartupState
|
||||
{
|
||||
/// <summary>Windows will not answer about startup — the setting is not shown.</summary>
|
||||
Unavailable,
|
||||
|
||||
/// <summary>Off, and the app can switch it on.</summary>
|
||||
Disabled,
|
||||
|
||||
/// <summary>On.</summary>
|
||||
Enabled,
|
||||
|
||||
/// <summary>
|
||||
/// Switched off by the user in the settings of Windows. It goes back on only
|
||||
/// there: a ban by the user is not for the app to overrule.
|
||||
/// </summary>
|
||||
DisabledByUser,
|
||||
|
||||
/// <summary>Forbidden by the policy of the organisation.</summary>
|
||||
DisabledByPolicy,
|
||||
|
||||
/// <summary>Switched on by the policy of the organisation and not to be switched off.</summary>
|
||||
EnabledByPolicy,
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
namespace CursorLang.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Which step the update is at. One value — one state of the interface:
|
||||
/// the caption, the button and the progress bar are shown from it.
|
||||
/// </summary>
|
||||
public enum UpdateStatus
|
||||
{
|
||||
/// <summary>There have been no checks yet.</summary>
|
||||
Idle,
|
||||
|
||||
/// <summary>A request to the repository is in flight.</summary>
|
||||
Checking,
|
||||
|
||||
/// <summary>The latest version is installed.</summary>
|
||||
UpToDate,
|
||||
|
||||
/// <summary>There is a newer version — it can be downloaded.</summary>
|
||||
Available,
|
||||
|
||||
/// <summary>The package is downloading.</summary>
|
||||
Downloading,
|
||||
|
||||
/// <summary>The package is downloaded and ready to install.</summary>
|
||||
Ready,
|
||||
|
||||
/// <summary>The check or the download failed.</summary>
|
||||
Failed,
|
||||
}
|
||||
Binary file not shown.
@@ -67,6 +67,18 @@
|
||||
<data name="LanguageLabel" xml:space="preserve">
|
||||
<value>Interface language</value>
|
||||
</data>
|
||||
<data name="ThemeLabel" xml:space="preserve">
|
||||
<value>Theme</value>
|
||||
</data>
|
||||
<data name="AppTheme_System" xml:space="preserve">
|
||||
<value>Same as Windows</value>
|
||||
</data>
|
||||
<data name="AppTheme_Light" xml:space="preserve">
|
||||
<value>Light</value>
|
||||
</data>
|
||||
<data name="AppTheme_Dark" xml:space="preserve">
|
||||
<value>Dark</value>
|
||||
</data>
|
||||
<data name="SectionPlacement" xml:space="preserve">
|
||||
<value>Placement</value>
|
||||
</data>
|
||||
@@ -85,18 +97,24 @@
|
||||
<data name="CursorCornerLabel" xml:space="preserve">
|
||||
<value>Side</value>
|
||||
</data>
|
||||
<data name="CursorCorner_BottomRight" xml:space="preserve">
|
||||
<data name="AnchorSide_BottomRight" xml:space="preserve">
|
||||
<value>Bottom right</value>
|
||||
</data>
|
||||
<data name="CursorCorner_BottomLeft" xml:space="preserve">
|
||||
<data name="AnchorSide_BottomLeft" xml:space="preserve">
|
||||
<value>Bottom left</value>
|
||||
</data>
|
||||
<data name="CursorCorner_TopRight" xml:space="preserve">
|
||||
<data name="AnchorSide_TopRight" xml:space="preserve">
|
||||
<value>Top right</value>
|
||||
</data>
|
||||
<data name="CursorCorner_TopLeft" xml:space="preserve">
|
||||
<data name="AnchorSide_TopLeft" xml:space="preserve">
|
||||
<value>Top left</value>
|
||||
</data>
|
||||
<data name="AnchorSide_Left" xml:space="preserve">
|
||||
<value>Left</value>
|
||||
</data>
|
||||
<data name="AnchorSide_Right" xml:space="preserve">
|
||||
<value>Right</value>
|
||||
</data>
|
||||
<data name="CursorOffsetLabel" xml:space="preserve">
|
||||
<value>Offset</value>
|
||||
</data>
|
||||
@@ -106,6 +124,12 @@
|
||||
<data name="ScreenPosition_TopLeft" xml:space="preserve">
|
||||
<value>Top left</value>
|
||||
</data>
|
||||
<data name="ScreenPosition_Top" xml:space="preserve">
|
||||
<value>Top center</value>
|
||||
</data>
|
||||
<data name="ScreenPosition_Bottom" xml:space="preserve">
|
||||
<value>Bottom center</value>
|
||||
</data>
|
||||
<data name="ScreenPosition_TopRight" xml:space="preserve">
|
||||
<value>Top right</value>
|
||||
</data>
|
||||
@@ -133,6 +157,9 @@
|
||||
<data name="BackgroundColorLabel" xml:space="preserve">
|
||||
<value>Background color</value>
|
||||
</data>
|
||||
<data name="TextColorLabel" xml:space="preserve">
|
||||
<value>Text color</value>
|
||||
</data>
|
||||
<data name="SectionBehavior" xml:space="preserve">
|
||||
<value>Behavior</value>
|
||||
</data>
|
||||
@@ -145,4 +172,73 @@
|
||||
<data name="MillisecondsSuffix" xml:space="preserve">
|
||||
<value>ms</value>
|
||||
</data>
|
||||
<data name="CapsLockLabel" xml:space="preserve">
|
||||
<value>Caps Lock</value>
|
||||
</data>
|
||||
<data name="CapsLockHotkeyCheck" xml:space="preserve">
|
||||
<value>Switch the layout instead of changing case</value>
|
||||
</data>
|
||||
<data name="CapsLockHoldLabel" xml:space="preserve">
|
||||
<value>Hold threshold</value>
|
||||
</data>
|
||||
<data name="CapsLockHoldHint" xml:space="preserve">
|
||||
<value>Holding the key longer shows the tooltip without switching the layout.</value>
|
||||
</data>
|
||||
<data name="MoreInfoLink" xml:space="preserve">
|
||||
<value>More info</value>
|
||||
</data>
|
||||
<data name="CapsLockElevationHint" xml:space="preserve">
|
||||
<value>The shortcut has no effect while a window running as administrator is in focus — Task Manager, Registry Editor, UAC prompts. Windows does not deliver keystrokes there to ordinary applications. The tooltip itself keeps working everywhere.</value>
|
||||
</data>
|
||||
<data name="StartupLabel" xml:space="preserve">
|
||||
<value>Startup</value>
|
||||
</data>
|
||||
<data name="StartupCheck" xml:space="preserve">
|
||||
<value>Start with Windows</value>
|
||||
</data>
|
||||
<data name="StartupLockedHint" xml:space="preserve">
|
||||
<value>Startup for this app is now controlled by Windows: Settings — Apps — Startup.</value>
|
||||
</data>
|
||||
<data name="SectionUpdates" xml:space="preserve">
|
||||
<value>Updates</value>
|
||||
</data>
|
||||
<data name="CurrentVersionLabel" xml:space="preserve">
|
||||
<value>Installed version</value>
|
||||
</data>
|
||||
<data name="CheckUpdatesButton" xml:space="preserve">
|
||||
<value>Check for updates</value>
|
||||
</data>
|
||||
<data name="UpdateAutoCheck" xml:space="preserve">
|
||||
<value>Check for updates at startup</value>
|
||||
</data>
|
||||
<data name="UpdateChecking" xml:space="preserve">
|
||||
<value>Checking for updates…</value>
|
||||
</data>
|
||||
<data name="UpdateUpToDate" xml:space="preserve">
|
||||
<value>The installed version is the latest one.</value>
|
||||
</data>
|
||||
<data name="UpdateAvailable" xml:space="preserve">
|
||||
<value>Version {0} is available.</value>
|
||||
</data>
|
||||
<data name="UpdateDownloading" xml:space="preserve">
|
||||
<value>Downloading the package…</value>
|
||||
</data>
|
||||
<data name="UpdateReady" xml:space="preserve">
|
||||
<value>The package has been downloaded.</value>
|
||||
</data>
|
||||
<data name="UpdateFailed" xml:space="preserve">
|
||||
<value>Could not reach the releases. Check the connection and try again.</value>
|
||||
</data>
|
||||
<data name="DownloadUpdateButton" xml:space="preserve">
|
||||
<value>Download</value>
|
||||
</data>
|
||||
<data name="InstallUpdateButton" xml:space="preserve">
|
||||
<value>Install</value>
|
||||
</data>
|
||||
<data name="ReleasePageLink" xml:space="preserve">
|
||||
<value>Release page</value>
|
||||
</data>
|
||||
<data name="UpdateInstallHint" xml:space="preserve">
|
||||
<value>Windows will show the package and ask to confirm the installation. The new version takes over once the app is restarted.</value>
|
||||
</data>
|
||||
</root>
|
||||
|
||||
@@ -67,6 +67,18 @@
|
||||
<data name="LanguageLabel" xml:space="preserve">
|
||||
<value>Язык интерфейса</value>
|
||||
</data>
|
||||
<data name="ThemeLabel" xml:space="preserve">
|
||||
<value>Тема</value>
|
||||
</data>
|
||||
<data name="AppTheme_System" xml:space="preserve">
|
||||
<value>Как в Windows</value>
|
||||
</data>
|
||||
<data name="AppTheme_Light" xml:space="preserve">
|
||||
<value>Светлая</value>
|
||||
</data>
|
||||
<data name="AppTheme_Dark" xml:space="preserve">
|
||||
<value>Тёмная</value>
|
||||
</data>
|
||||
<data name="SectionPlacement" xml:space="preserve">
|
||||
<value>Расположение</value>
|
||||
</data>
|
||||
@@ -85,18 +97,24 @@
|
||||
<data name="CursorCornerLabel" xml:space="preserve">
|
||||
<value>Сторона</value>
|
||||
</data>
|
||||
<data name="CursorCorner_BottomRight" xml:space="preserve">
|
||||
<data name="AnchorSide_BottomRight" xml:space="preserve">
|
||||
<value>Справа снизу</value>
|
||||
</data>
|
||||
<data name="CursorCorner_BottomLeft" xml:space="preserve">
|
||||
<data name="AnchorSide_BottomLeft" xml:space="preserve">
|
||||
<value>Слева снизу</value>
|
||||
</data>
|
||||
<data name="CursorCorner_TopRight" xml:space="preserve">
|
||||
<data name="AnchorSide_TopRight" xml:space="preserve">
|
||||
<value>Справа сверху</value>
|
||||
</data>
|
||||
<data name="CursorCorner_TopLeft" xml:space="preserve">
|
||||
<data name="AnchorSide_TopLeft" xml:space="preserve">
|
||||
<value>Слева сверху</value>
|
||||
</data>
|
||||
<data name="AnchorSide_Left" xml:space="preserve">
|
||||
<value>Слева</value>
|
||||
</data>
|
||||
<data name="AnchorSide_Right" xml:space="preserve">
|
||||
<value>Справа</value>
|
||||
</data>
|
||||
<data name="CursorOffsetLabel" xml:space="preserve">
|
||||
<value>Отступ</value>
|
||||
</data>
|
||||
@@ -106,6 +124,12 @@
|
||||
<data name="ScreenPosition_TopLeft" xml:space="preserve">
|
||||
<value>Слева сверху</value>
|
||||
</data>
|
||||
<data name="ScreenPosition_Top" xml:space="preserve">
|
||||
<value>Сверху по центру</value>
|
||||
</data>
|
||||
<data name="ScreenPosition_Bottom" xml:space="preserve">
|
||||
<value>Снизу по центру</value>
|
||||
</data>
|
||||
<data name="ScreenPosition_TopRight" xml:space="preserve">
|
||||
<value>Справа сверху</value>
|
||||
</data>
|
||||
@@ -133,6 +157,9 @@
|
||||
<data name="BackgroundColorLabel" xml:space="preserve">
|
||||
<value>Цвет фона</value>
|
||||
</data>
|
||||
<data name="TextColorLabel" xml:space="preserve">
|
||||
<value>Цвет текста</value>
|
||||
</data>
|
||||
<data name="SectionBehavior" xml:space="preserve">
|
||||
<value>Поведение</value>
|
||||
</data>
|
||||
@@ -145,4 +172,73 @@
|
||||
<data name="MillisecondsSuffix" xml:space="preserve">
|
||||
<value>мс</value>
|
||||
</data>
|
||||
<data name="CapsLockLabel" xml:space="preserve">
|
||||
<value>Caps Lock</value>
|
||||
</data>
|
||||
<data name="CapsLockHotkeyCheck" xml:space="preserve">
|
||||
<value>Переключать раскладку вместо смены регистра</value>
|
||||
</data>
|
||||
<data name="CapsLockHoldLabel" xml:space="preserve">
|
||||
<value>Порог удержания</value>
|
||||
</data>
|
||||
<data name="CapsLockHoldHint" xml:space="preserve">
|
||||
<value>Более долгое нажатие показывает подсказку и не переключает раскладку.</value>
|
||||
</data>
|
||||
<data name="MoreInfoLink" xml:space="preserve">
|
||||
<value>Подробнее</value>
|
||||
</data>
|
||||
<data name="CapsLockElevationHint" xml:space="preserve">
|
||||
<value>Пока в фокусе окно, запущенное от имени администратора, — диспетчер задач, редактор реестра, запрос UAC — сочетание не сработает: там Windows не отдаёт нажатия обычным приложениям. Сама подсказка показывается везде.</value>
|
||||
</data>
|
||||
<data name="StartupLabel" xml:space="preserve">
|
||||
<value>Автозапуск</value>
|
||||
</data>
|
||||
<data name="StartupCheck" xml:space="preserve">
|
||||
<value>Запускать вместе с Windows</value>
|
||||
</data>
|
||||
<data name="StartupLockedHint" xml:space="preserve">
|
||||
<value>Автозапуском этого приложения теперь распоряжается Windows: «Параметры» — «Приложения» — «Автозагрузка».</value>
|
||||
</data>
|
||||
<data name="SectionUpdates" xml:space="preserve">
|
||||
<value>Обновления</value>
|
||||
</data>
|
||||
<data name="CurrentVersionLabel" xml:space="preserve">
|
||||
<value>Установленная версия</value>
|
||||
</data>
|
||||
<data name="CheckUpdatesButton" xml:space="preserve">
|
||||
<value>Проверить обновления</value>
|
||||
</data>
|
||||
<data name="UpdateAutoCheck" xml:space="preserve">
|
||||
<value>Проверять обновления при запуске</value>
|
||||
</data>
|
||||
<data name="UpdateChecking" xml:space="preserve">
|
||||
<value>Идёт проверка обновлений…</value>
|
||||
</data>
|
||||
<data name="UpdateUpToDate" xml:space="preserve">
|
||||
<value>Установлена последняя версия.</value>
|
||||
</data>
|
||||
<data name="UpdateAvailable" xml:space="preserve">
|
||||
<value>Доступна версия {0}.</value>
|
||||
</data>
|
||||
<data name="UpdateDownloading" xml:space="preserve">
|
||||
<value>Идёт загрузка пакета…</value>
|
||||
</data>
|
||||
<data name="UpdateReady" xml:space="preserve">
|
||||
<value>Пакет скачан.</value>
|
||||
</data>
|
||||
<data name="UpdateFailed" xml:space="preserve">
|
||||
<value>Не удалось обратиться к выпускам. Проверьте подключение и повторите попытку.</value>
|
||||
</data>
|
||||
<data name="DownloadUpdateButton" xml:space="preserve">
|
||||
<value>Скачать</value>
|
||||
</data>
|
||||
<data name="InstallUpdateButton" xml:space="preserve">
|
||||
<value>Установить</value>
|
||||
</data>
|
||||
<data name="ReleasePageLink" xml:space="preserve">
|
||||
<value>Страница выпуска</value>
|
||||
</data>
|
||||
<data name="UpdateInstallHint" xml:space="preserve">
|
||||
<value>Windows покажет пакет и попросит подтвердить установку. Новая версия начнёт работать после перезапуска приложения.</value>
|
||||
</data>
|
||||
</root>
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
using System.Windows.Threading;
|
||||
using CursorLang.Interop;
|
||||
using CursorLang.Models;
|
||||
|
||||
namespace CursorLang.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Holds the system Caps Lock hook and tells a short tap from a hold.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// They can be told apart only by the key being released, so both events are
|
||||
/// intercepted — the press and the release. That is also the only way to cancel the
|
||||
/// case change: Windows toggles Caps Lock on the press event, and letting it through
|
||||
/// "just in case" is not an option.
|
||||
/// </remarks>
|
||||
public sealed class CapsLockHotkeyService : ICapsLockHotkeyService, IDisposable
|
||||
{
|
||||
private const int VirtualKeyCapsLock = 0x14;
|
||||
|
||||
private readonly AppSettings _settings;
|
||||
private readonly LowLevelKeyboardHook _hook;
|
||||
private readonly DispatcherTimer _holdTimer = new();
|
||||
private readonly Dispatcher _dispatcher = Dispatcher.CurrentDispatcher;
|
||||
|
||||
private bool _isPressed;
|
||||
private bool _isHolding;
|
||||
|
||||
public CapsLockHotkeyService(AppSettings settings)
|
||||
{
|
||||
_settings = settings;
|
||||
_hook = new LowLevelKeyboardHook(HandleKeyEvent);
|
||||
_holdTimer.Tick += OnHoldTimerTick;
|
||||
}
|
||||
|
||||
public event EventHandler? Tapped;
|
||||
|
||||
public event EventHandler? HoldStarted;
|
||||
|
||||
public event EventHandler? HoldEnded;
|
||||
|
||||
public bool IsRunning => _hook.IsInstalled;
|
||||
|
||||
public void Start() => _hook.Install();
|
||||
|
||||
public void Stop()
|
||||
{
|
||||
_hook.Uninstall();
|
||||
ResetPress();
|
||||
}
|
||||
|
||||
// When the application is closing, nobody is waiting for events any more, so
|
||||
// unlike in Stop the state is reset quietly: the dispatcher queue may be shut
|
||||
// down by that moment
|
||||
public void Dispose()
|
||||
{
|
||||
_holdTimer.Tick -= OnHoldTimerTick;
|
||||
_holdTimer.Stop();
|
||||
_isPressed = false;
|
||||
_isHolding = false;
|
||||
_hook.Dispose();
|
||||
}
|
||||
|
||||
// Called by the system hook, that is, inside message queue processing. Only state
|
||||
// tracking belongs here: showing windows and raising events from here is not
|
||||
// allowed — the handler must return control within a few milliseconds.
|
||||
// In tests the key presses are fed here as well: there is no need to install a
|
||||
// real keyboard hook just to check how presses are interpreted
|
||||
internal bool HandleKeyEvent(int virtualKey, bool isKeyDown)
|
||||
{
|
||||
if (virtualKey != VirtualKeyCapsLock)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isKeyDown)
|
||||
{
|
||||
// While the key is held, Windows repeats the press: the hold is counted
|
||||
// from the first event and the repeats are ignored
|
||||
if (!_isPressed)
|
||||
{
|
||||
_isPressed = true;
|
||||
|
||||
// The threshold is read on every press: it is changed in the settings on the fly
|
||||
_holdTimer.Interval = _settings.CapsLockHoldDelay;
|
||||
_holdTimer.Start();
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
_isPressed = false;
|
||||
_holdTimer.Stop();
|
||||
|
||||
if (_isHolding)
|
||||
{
|
||||
_isHolding = false;
|
||||
Notify(HoldEnded);
|
||||
}
|
||||
else
|
||||
{
|
||||
Notify(Tapped);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private void OnHoldTimerTick(object? sender, EventArgs e)
|
||||
{
|
||||
_holdTimer.Stop();
|
||||
_isHolding = true;
|
||||
HoldStarted?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
|
||||
// The event reaches the subscribers after the hook returns: they are free to show
|
||||
// windows and do anything else without holding up the handling of the key press
|
||||
private void Notify(EventHandler? handler)
|
||||
{
|
||||
if (handler is not null)
|
||||
{
|
||||
_dispatcher.BeginInvoke(() => handler(this, EventArgs.Empty));
|
||||
}
|
||||
}
|
||||
|
||||
// The hook may have been removed with the key held down — by clearing the
|
||||
// checkbox in the settings, for instance. The popup has to be taken down then
|
||||
private void ResetPress()
|
||||
{
|
||||
_isPressed = false;
|
||||
_holdTimer.Stop();
|
||||
|
||||
if (_isHolding)
|
||||
{
|
||||
_isHolding = false;
|
||||
Notify(HoldEnded);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
using System.ComponentModel;
|
||||
using CursorLang.Models;
|
||||
|
||||
namespace CursorLang.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Turns Caps Lock presses into actions: a short one switches the layout, a long one
|
||||
/// only shows the popup. It also turns the hook on and off following the checkbox in
|
||||
/// the settings.
|
||||
/// </summary>
|
||||
public sealed class CapsLockSwitchCoordinator : IDisposable
|
||||
{
|
||||
private readonly ICapsLockHotkeyService _hotkeyService;
|
||||
private readonly IKeyboardLayoutService _layoutService;
|
||||
private readonly ILayoutPopupService _popupService;
|
||||
private readonly AppSettings _settings;
|
||||
|
||||
public CapsLockSwitchCoordinator(
|
||||
ICapsLockHotkeyService hotkeyService,
|
||||
IKeyboardLayoutService layoutService,
|
||||
ILayoutPopupService popupService,
|
||||
AppSettings settings)
|
||||
{
|
||||
_hotkeyService = hotkeyService;
|
||||
_layoutService = layoutService;
|
||||
_popupService = popupService;
|
||||
_settings = settings;
|
||||
}
|
||||
|
||||
public void Start()
|
||||
{
|
||||
_hotkeyService.Tapped += OnTapped;
|
||||
_hotkeyService.HoldStarted += OnHoldStarted;
|
||||
_hotkeyService.HoldEnded += OnHoldEnded;
|
||||
_settings.PropertyChanged += OnSettingsChanged;
|
||||
|
||||
ApplySetting();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_settings.PropertyChanged -= OnSettingsChanged;
|
||||
_hotkeyService.Tapped -= OnTapped;
|
||||
_hotkeyService.HoldStarted -= OnHoldStarted;
|
||||
_hotkeyService.HoldEnded -= OnHoldEnded;
|
||||
_hotkeyService.Stop();
|
||||
}
|
||||
|
||||
private void OnSettingsChanged(object? sender, PropertyChangedEventArgs e)
|
||||
{
|
||||
if (e.PropertyName == nameof(AppSettings.UseCapsLockHotkey))
|
||||
{
|
||||
ApplySetting();
|
||||
}
|
||||
}
|
||||
|
||||
private void ApplySetting()
|
||||
{
|
||||
if (_settings.UseCapsLockHotkey)
|
||||
{
|
||||
_hotkeyService.Start();
|
||||
}
|
||||
else
|
||||
{
|
||||
_hotkeyService.Stop();
|
||||
}
|
||||
}
|
||||
|
||||
private void OnTapped(object? sender, EventArgs e) => _layoutService.SwitchToNext();
|
||||
|
||||
// We do not change the layout, but staying silent will not do either: without the
|
||||
// popup a long press looks as if the key simply did not work
|
||||
private void OnHoldStarted(object? sender, EventArgs e) =>
|
||||
_popupService.ShowUntilHidden(_layoutService.Current);
|
||||
|
||||
private void OnHoldEnded(object? sender, EventArgs e) => _popupService.Hide();
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
using System.IO;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text.Json;
|
||||
using CursorLang.Models;
|
||||
|
||||
namespace CursorLang.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Gitea releases.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The service address is the address of the server itself — "https://git.example.com/":
|
||||
/// the Gitea API lives on the same host as the repository pages.
|
||||
/// </remarks>
|
||||
internal sealed class GiteaReleaseFeed : IReleaseFeed
|
||||
{
|
||||
/// <summary>
|
||||
/// How many releases to ask the server for. It returns them newest first, but the
|
||||
/// newest one may turn out to have no package — when the build has not been
|
||||
/// published yet, for instance — so a small reserve is taken.
|
||||
/// </summary>
|
||||
private const int PageSize = 10;
|
||||
|
||||
/// <summary>
|
||||
/// A response with the release list is a few kilobytes of text. There is no point
|
||||
/// waiting longer: the check runs in the background, and a failed one bothers nobody.
|
||||
/// </summary>
|
||||
private static readonly TimeSpan RequestTimeout = TimeSpan.FromSeconds(20);
|
||||
|
||||
private readonly HttpClient _client;
|
||||
private readonly UpdateOptions _options;
|
||||
|
||||
public GiteaReleaseFeed(HttpClient client, UpdateOptions options)
|
||||
{
|
||||
_client = client;
|
||||
_options = options;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// How the architecture is spelled in the file names built by
|
||||
/// <c>build-msix.ps1</c>: <c>CursorLang-1.0.0.0-x64.msix</c>.
|
||||
/// </summary>
|
||||
private static string ArchitectureName => RuntimeInformation.ProcessArchitecture == Architecture.Arm64
|
||||
? "arm64"
|
||||
: "x64";
|
||||
|
||||
public async Task<ReleaseInfo?> GetLatestAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
timeout.CancelAfter(RequestTimeout);
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, BuildReleasesUri());
|
||||
Authorize(request);
|
||||
|
||||
using HttpResponseMessage response = await _client.SendAsync(
|
||||
request, HttpCompletionOption.ResponseHeadersRead, timeout.Token);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
await using Stream stream = await response.Content.ReadAsStreamAsync(timeout.Token);
|
||||
using JsonDocument document = await JsonDocument.ParseAsync(stream, cancellationToken: timeout.Token);
|
||||
|
||||
if (document.RootElement.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// The order of the releases is up to the server, while what we need is the
|
||||
// highest version number: a fix released for an old branch may well be the newest one
|
||||
return document.RootElement.EnumerateArray()
|
||||
.Select(Read)
|
||||
.OfType<ReleaseInfo>()
|
||||
.MaxBy(release => release.Version);
|
||||
}
|
||||
|
||||
public void Authorize(HttpRequestMessage request)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(_options.AccessToken))
|
||||
{
|
||||
// "token" is the Gitea scheme of its own for access keys; "Bearer" is not
|
||||
// understood by every version, while this one has been there since the API appeared
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("token", _options.AccessToken);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses a single release. <c>null</c> means the release will not do: a draft,
|
||||
/// a prerelease or a release without a package.
|
||||
/// </summary>
|
||||
private static ReleaseInfo? Read(JsonElement release)
|
||||
{
|
||||
// A draft is visible only to whoever created it, and the application does not
|
||||
// offer a prerelease: those are sought out deliberately
|
||||
if (ReadFlag(release, "draft") || ReadFlag(release, "prerelease"))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
Version? version = ParseTag(ReadString(release, "tag_name"));
|
||||
if (version is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!release.TryGetProperty("assets", out JsonElement assets) || assets.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
ReleaseAsset? package = PickPackage(assets.EnumerateArray().Select(ReadAsset).OfType<ReleaseAsset>());
|
||||
if (package is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new ReleaseInfo(
|
||||
version,
|
||||
ReadString(release, "tag_name") ?? version.ToString(),
|
||||
ReadUri(release, "html_url"),
|
||||
package);
|
||||
}
|
||||
|
||||
private static ReleaseAsset? ReadAsset(JsonElement asset)
|
||||
{
|
||||
string? name = ReadString(asset, "name");
|
||||
Uri? url = ReadUri(asset, "browser_download_url");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(name) || url is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
long size = asset.TryGetProperty("size", out JsonElement value) && value.TryGetInt64(out long bytes)
|
||||
? bytes
|
||||
: 0;
|
||||
|
||||
return new ReleaseAsset(name, url, size);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The version from a tag. "1.2.3" and "v1.2.3" are understood; a tag with
|
||||
/// anything besides numbers — "v1.2.3-beta" — counts as a prerelease and is
|
||||
/// skipped: the application does not offer such versions on its own.
|
||||
/// </summary>
|
||||
private static Version? ParseTag(string? tag)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tag))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
ReadOnlySpan<char> numbers = tag.AsSpan().Trim().TrimStart("vV");
|
||||
|
||||
foreach (char symbol in numbers)
|
||||
{
|
||||
if (!char.IsAsciiDigit(symbol) && symbol != '.')
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
if (!Version.TryParse(numbers, out Version? version))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// In a tag such as "v1.2" the lower parts are not set at all, yet comparing
|
||||
// them with the version of the installed package calls for zeros
|
||||
return new Version(
|
||||
version.Major,
|
||||
version.Minor,
|
||||
Math.Max(version.Build, 0),
|
||||
Math.Max(version.Revision, 0));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Picks the attached file the application updates itself with.
|
||||
/// </summary>
|
||||
private static ReleaseAsset? PickPackage(IEnumerable<ReleaseAsset> assets)
|
||||
{
|
||||
// An unencrypted connection is out right away: Windows will check the package
|
||||
// signature by itself, but a substituted file is not even worth downloading
|
||||
ReleaseAsset[] packages = [.. assets.Where(asset => asset.Url.Scheme == Uri.UriSchemeHttps)];
|
||||
|
||||
ReleaseAsset? bundle = packages.FirstOrDefault(
|
||||
asset => asset.FileName.EndsWith(".msixbundle", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (bundle is not null)
|
||||
{
|
||||
// A bundle carries both architectures, so there is nothing to choose between
|
||||
return bundle;
|
||||
}
|
||||
|
||||
ReleaseAsset[] single = [.. packages.Where(
|
||||
asset => asset.FileName.EndsWith(".msix", StringComparison.OrdinalIgnoreCase))];
|
||||
|
||||
ReleaseAsset? matching = single.FirstOrDefault(
|
||||
asset => asset.FileName.Contains(ArchitectureName, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
// A package without an architecture in its name will do only when it is the
|
||||
// only one: otherwise it is unclear which of them is for this machine
|
||||
return matching ?? (single.Length == 1 ? single[0] : null);
|
||||
}
|
||||
|
||||
private static string? ReadString(JsonElement element, string name) =>
|
||||
element.TryGetProperty(name, out JsonElement value) && value.ValueKind == JsonValueKind.String
|
||||
? value.GetString()
|
||||
: null;
|
||||
|
||||
private static Uri? ReadUri(JsonElement element, string name) =>
|
||||
Uri.TryCreate(ReadString(element, name), UriKind.Absolute, out Uri? uri) ? uri : null;
|
||||
|
||||
private static bool ReadFlag(JsonElement element, string name) =>
|
||||
element.TryGetProperty(name, out JsonElement value) && value.ValueKind == JsonValueKind.True;
|
||||
|
||||
/// <summary>
|
||||
/// The address the server returns the release list at. The trailing slash matters:
|
||||
/// without it <c>Uri</c> drops the last part of the address, and
|
||||
/// "https://host/gitea" would have turned into "https://host/api/...".
|
||||
/// </summary>
|
||||
private Uri BuildReleasesUri()
|
||||
{
|
||||
string service = _options.ServiceUri.AbsoluteUri;
|
||||
string path = $"api/v1/repos/{_options.Project.Trim('/')}/releases?limit={PageSize}";
|
||||
|
||||
return new Uri(service.EndsWith('/') ? service + path : $"{service}/{path}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
namespace CursorLang.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Intercepts Caps Lock at the system level and splits the presses into short and
|
||||
/// long ones. What to do with them is up to the subscribers.
|
||||
/// </summary>
|
||||
public interface ICapsLockHotkeyService
|
||||
{
|
||||
/// <summary>A short press: the key was released before the hold threshold.</summary>
|
||||
event EventHandler? Tapped;
|
||||
|
||||
/// <summary>The hold threshold has passed, the key is still held.</summary>
|
||||
event EventHandler? HoldStarted;
|
||||
|
||||
/// <summary>The hold is over: the key was released.</summary>
|
||||
event EventHandler? HoldEnded;
|
||||
|
||||
/// <summary>Whether the hook is installed right now.</summary>
|
||||
bool IsRunning { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Starts intercepting. Must be called from the user interface thread:
|
||||
/// a system keyboard hook works only on a thread with a message loop.
|
||||
/// </summary>
|
||||
void Start();
|
||||
|
||||
/// <summary>Removes the hook, giving the key its usual behaviour back.</summary>
|
||||
void Stop();
|
||||
}
|
||||
@@ -3,11 +3,12 @@ using CursorLang.Models;
|
||||
namespace CursorLang.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Следит за раскладкой активного окна — в том числе в чужих приложениях.
|
||||
/// Tracks the layout of the active window — including in other applications —
|
||||
/// and can switch it.
|
||||
/// </summary>
|
||||
public interface IKeyboardLayoutService
|
||||
{
|
||||
/// <summary>Раскладка активного окна на текущий момент.</summary>
|
||||
/// <summary>The layout of the active window at the moment.</summary>
|
||||
KeyboardLayout Current { get; }
|
||||
|
||||
event EventHandler<LayoutChangedEventArgs>? LayoutChanged;
|
||||
@@ -15,4 +16,6 @@ public interface IKeyboardLayoutService
|
||||
void Start();
|
||||
|
||||
void Stop();
|
||||
|
||||
void SwitchToNext();
|
||||
}
|
||||
|
||||
@@ -3,9 +3,18 @@ using CursorLang.Models;
|
||||
namespace CursorLang.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Показывает подсказку с раскладкой у курсора.
|
||||
/// Shows the layout popup at the cursor.
|
||||
/// </summary>
|
||||
public interface ILayoutPopupService
|
||||
{
|
||||
/// <summary>Shows the popup and takes it down after the time set in the settings.</summary>
|
||||
void Show(KeyboardLayout layout);
|
||||
|
||||
/// <summary>
|
||||
/// Shows the popup until <see cref="Hide"/> is called explicitly: needed where the
|
||||
/// show time is set by a user action rather than by a timer.
|
||||
/// </summary>
|
||||
void ShowUntilHidden(KeyboardLayout layout);
|
||||
|
||||
void Hide();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
namespace CursorLang.Services;
|
||||
|
||||
/// <summary>
|
||||
/// The popup window as seen by whoever decides when it is shown.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The window picks its place and size itself, and only three actions are needed from
|
||||
/// it on the outside. The tests check the work of the popup service through the same
|
||||
/// interface: there is no point bringing up a real window to check a timer.
|
||||
/// </remarks>
|
||||
public interface ILayoutPopupWindow
|
||||
{
|
||||
/// <summary>Shows the window at the place set by the settings.</summary>
|
||||
void ShowPopup();
|
||||
|
||||
/// <summary>Takes the window off the screen without destroying it.</summary>
|
||||
void Hide();
|
||||
|
||||
/// <summary>Closes the window for good.</summary>
|
||||
void Close();
|
||||
}
|
||||
@@ -2,21 +2,21 @@ using System.ComponentModel;
|
||||
|
||||
namespace CursorLang.Services;
|
||||
|
||||
/// <summary>Язык интерфейса для выбора в настройках.</summary>
|
||||
/// <param name="Code">Код культуры: «ru», «en».</param>
|
||||
/// <param name="DisplayName">Название на самом этом языке.</param>
|
||||
/// <summary>An interface language to choose from in the settings.</summary>
|
||||
/// <param name="Code">The culture code: "ru", "en".</param>
|
||||
/// <param name="DisplayName">The name in that very language.</param>
|
||||
public sealed record LanguageOption(string Code, string DisplayName)
|
||||
{
|
||||
// Средства доступности берут имя элемента списка отсюда
|
||||
// Accessibility tools take the name of the list item from here
|
||||
public override string ToString() => DisplayName;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Даёт строки интерфейса и умеет менять язык без перезапуска.
|
||||
/// Provides the interface strings and can change the language without a restart.
|
||||
/// </summary>
|
||||
public interface ILocalizationService : INotifyPropertyChanged
|
||||
{
|
||||
/// <summary>Строка по ключу ресурса. Привязки обновляются при смене языка.</summary>
|
||||
/// <summary>The string for a resource key. Bindings update when the language changes.</summary>
|
||||
string this[string key] { get; }
|
||||
|
||||
IReadOnlyList<LanguageOption> AvailableLanguages { get; }
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
using System.Net.Http;
|
||||
using CursorLang.Models;
|
||||
|
||||
namespace CursorLang.Services;
|
||||
|
||||
/// <summary>
|
||||
/// The release list of the repository.
|
||||
/// </summary>
|
||||
public interface IReleaseFeed
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns the newest release carrying an MSIX package, or <c>null</c>
|
||||
/// when there is no suitable release.
|
||||
/// </summary>
|
||||
Task<ReleaseInfo?> GetLatestAsync(CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Adds to the request whatever a private repository needs. The package is
|
||||
/// downloaded not by the list itself, but access to it is closed just the same.
|
||||
/// </summary>
|
||||
void Authorize(HttpRequestMessage request);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
using CursorLang.Models;
|
||||
|
||||
namespace CursorLang.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Starting the app together with Windows.
|
||||
/// </summary>
|
||||
public interface IStartupService
|
||||
{
|
||||
/// <summary>Finds out the current state of startup.</summary>
|
||||
Task<StartupState> GetStateAsync();
|
||||
|
||||
/// <summary>
|
||||
/// Asks for startup to be switched on or off and answers with the state that
|
||||
/// came of it: the request to switch it on may well be turned down.
|
||||
/// </summary>
|
||||
Task<StartupState> SetEnabledAsync(bool enabled);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using System.Windows;
|
||||
using CursorLang.Models;
|
||||
|
||||
namespace CursorLang.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Applies the light or the dark look to the windows of the application.
|
||||
/// </summary>
|
||||
public interface IThemeService
|
||||
{
|
||||
/// <summary>The theme in effect right now.</summary>
|
||||
AppTheme CurrentTheme { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Hooks a window up to theme changes: the window title bar is drawn by Windows,
|
||||
/// and its colour has to be switched for each window separately.
|
||||
/// </summary>
|
||||
void Register(Window window);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
using CursorLang.Models;
|
||||
|
||||
namespace CursorLang.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Checking for and installing new versions of the application.
|
||||
/// </summary>
|
||||
public interface IUpdateService
|
||||
{
|
||||
/// <summary>
|
||||
/// It makes sense for this installation to update itself.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// An application installed from the Store is updated by the Store itself:
|
||||
/// offering a package from elsewhere on top of it will not do — Windows would
|
||||
/// not accept it anyway.
|
||||
/// </remarks>
|
||||
bool IsSupported { get; }
|
||||
|
||||
/// <summary>The version of the running application.</summary>
|
||||
Version CurrentVersion { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Looks for a release newer than the installed one. <c>null</c> means the latest
|
||||
/// version is installed or there is no suitable release in the repository.
|
||||
/// </summary>
|
||||
Task<ReleaseInfo?> CheckAsync(CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Downloads the release package and returns the path to it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <c>progress</c> receives the downloaded fraction from 0 to 1. While the file
|
||||
/// size is unknown — not every hosting reports it — there will be no calls at all.
|
||||
/// </remarks>
|
||||
Task<string> DownloadAsync(ReleaseInfo release, IProgress<double>? progress, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Hands the downloaded package over to the Windows app installer.</summary>
|
||||
void Install(string packagePath);
|
||||
}
|
||||
@@ -5,58 +5,91 @@ using CursorLang.Models;
|
||||
namespace CursorLang.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Настройки слежения за раскладкой.
|
||||
/// The settings of layout tracking.
|
||||
/// </summary>
|
||||
public sealed class KeyboardLayoutOptions
|
||||
{
|
||||
/// <summary>Как часто проверять раскладку активного окна.</summary>
|
||||
/// <summary>How often to check the layout of the active window.</summary>
|
||||
public TimeSpan PollInterval { get; init; } = TimeSpan.FromMilliseconds(150);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Опрашивает активное окно по таймеру.
|
||||
/// Polls the active window on a timer.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Опрос выбран не от простоты: событийных способов узнать о смене раскладки
|
||||
/// в чужом процессе из managed-кода нет. HSHELL_LANGUAGE от RegisterShellHookWindow
|
||||
/// в Windows 10/11 не приходит, а уведомления TSF (ITfLanguageProfileNotifySink)
|
||||
/// сообщают только о смене языка внутри своего процесса — оба варианта проверены
|
||||
/// и не сработали. Один тик — три вызова Win32, читающих данные из памяти ядра.
|
||||
/// Polling was chosen not for simplicity: there is no event-based way to learn about
|
||||
/// a layout change in another process from managed code. HSHELL_LANGUAGE from
|
||||
/// RegisterShellHookWindow does not arrive on Windows 10/11, and TSF notifications
|
||||
/// (ITfLanguageProfileNotifySink) report only language changes inside our own process
|
||||
/// — both options were tried and did not work. One tick is three Win32 calls reading
|
||||
/// data from kernel memory.
|
||||
/// </remarks>
|
||||
public sealed class KeyboardLayoutService : IKeyboardLayoutService, IDisposable
|
||||
{
|
||||
private readonly DispatcherTimer _pollTimer;
|
||||
private readonly Func<IntPtr> _getForegroundWindow;
|
||||
private readonly Func<int> _getActiveLocaleId;
|
||||
private readonly Action _requestNextLayout;
|
||||
|
||||
private int _lastLocaleId = -1;
|
||||
private IntPtr _lastForegroundWindow;
|
||||
|
||||
public KeyboardLayoutService(KeyboardLayoutOptions options)
|
||||
: this(
|
||||
options,
|
||||
KeyboardLayoutNative.GetForegroundWindow,
|
||||
KeyboardLayoutNative.GetActiveLocaleId,
|
||||
KeyboardLayoutNative.RequestNextLayout)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Takes the sources of system information explicitly: in tests the layout and the
|
||||
/// active window are not provided by Windows.
|
||||
/// </summary>
|
||||
internal KeyboardLayoutService(
|
||||
KeyboardLayoutOptions options,
|
||||
Func<IntPtr> getForegroundWindow,
|
||||
Func<int> getActiveLocaleId,
|
||||
Action requestNextLayout)
|
||||
{
|
||||
_getForegroundWindow = getForegroundWindow;
|
||||
_getActiveLocaleId = getActiveLocaleId;
|
||||
_requestNextLayout = requestNextLayout;
|
||||
|
||||
_pollTimer = new DispatcherTimer { Interval = options.PollInterval };
|
||||
_pollTimer.Tick += OnTick;
|
||||
}
|
||||
|
||||
public event EventHandler<LayoutChangedEventArgs>? LayoutChanged;
|
||||
|
||||
public KeyboardLayout Current => KeyboardLayout.FromLocaleId(KeyboardLayoutNative.GetActiveLocaleId());
|
||||
public KeyboardLayout Current => KeyboardLayout.FromLocaleId(_getActiveLocaleId());
|
||||
|
||||
public void Start()
|
||||
{
|
||||
_lastForegroundWindow = KeyboardLayoutNative.GetForegroundWindow();
|
||||
_lastLocaleId = KeyboardLayoutNative.GetActiveLocaleId();
|
||||
_lastForegroundWindow = _getForegroundWindow();
|
||||
_lastLocaleId = _getActiveLocaleId();
|
||||
_pollTimer.Start();
|
||||
}
|
||||
|
||||
public void Stop() => _pollTimer.Stop();
|
||||
|
||||
public void SwitchToNext() => _requestNextLayout();
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_pollTimer.Stop();
|
||||
_pollTimer.Tick -= OnTick;
|
||||
}
|
||||
|
||||
private void OnTick(object? sender, EventArgs e)
|
||||
private void OnTick(object? sender, EventArgs e) => Poll();
|
||||
|
||||
// A single poll step. Called by the timer, and in tests — directly:
|
||||
// there is no point waiting for a tick to check how the reason for a layout
|
||||
// change is decided
|
||||
internal void Poll()
|
||||
{
|
||||
IntPtr foreground = KeyboardLayoutNative.GetForegroundWindow();
|
||||
IntPtr foreground = _getForegroundWindow();
|
||||
if (foreground == IntPtr.Zero)
|
||||
{
|
||||
return;
|
||||
@@ -65,7 +98,7 @@ public sealed class KeyboardLayoutService : IKeyboardLayoutService, IDisposable
|
||||
bool appSwitched = foreground != _lastForegroundWindow;
|
||||
_lastForegroundWindow = foreground;
|
||||
|
||||
int localeId = KeyboardLayoutNative.GetActiveLocaleId();
|
||||
int localeId = _getActiveLocaleId();
|
||||
if (localeId == _lastLocaleId)
|
||||
{
|
||||
return;
|
||||
@@ -73,9 +106,9 @@ public sealed class KeyboardLayoutService : IKeyboardLayoutService, IDisposable
|
||||
|
||||
_lastLocaleId = localeId;
|
||||
|
||||
// Переход в другое приложение со своей раскладкой — не то же самое,
|
||||
// что переключение раскладки пользователем, и подписчики вправе
|
||||
// реагировать на эти случаи по-разному
|
||||
// Moving to another application with a layout of its own is not the same as
|
||||
// the user switching the layout, and the subscribers are free to react to
|
||||
// these cases differently
|
||||
LayoutChangeReason reason = appSwitched
|
||||
? LayoutChangeReason.ApplicationSwitched
|
||||
: LayoutChangeReason.UserSwitched;
|
||||
|
||||
@@ -3,8 +3,8 @@ using CursorLang.Models;
|
||||
namespace CursorLang.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Связывает слежение за раскладкой с показом подсказки.
|
||||
/// Живёт всё время работы приложения независимо от открытых окон.
|
||||
/// Ties layout tracking to showing the popup.
|
||||
/// Lives for as long as the application runs, regardless of the open windows.
|
||||
/// </summary>
|
||||
public sealed class LayoutNotificationCoordinator : IDisposable
|
||||
{
|
||||
@@ -31,8 +31,8 @@ public sealed class LayoutNotificationCoordinator : IDisposable
|
||||
|
||||
private void OnLayoutChanged(object? sender, LayoutChangedEventArgs e)
|
||||
{
|
||||
// При переходе в другое приложение раскладка меняется без участия
|
||||
// пользователя, и всплывающая подсказка была бы навязчивой
|
||||
// When moving to another application the layout changes without the user
|
||||
// taking part, and a popup would be intrusive
|
||||
if (e.Reason == LayoutChangeReason.UserSwitched)
|
||||
{
|
||||
_popupService.Show(e.Layout);
|
||||
|
||||
@@ -1,22 +1,21 @@
|
||||
using System.Windows.Threading;
|
||||
using CursorLang.Models;
|
||||
using CursorLang.ViewModels;
|
||||
using CursorLang.Views;
|
||||
|
||||
namespace CursorLang.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Управляет временем жизни подсказки: окно отвечает только за показ,
|
||||
/// а решение «когда показать и когда убрать» принимается здесь.
|
||||
/// Manages the lifetime of the popup: the window is only responsible for showing it,
|
||||
/// while the decision of when to show and when to take it down is made here.
|
||||
/// </summary>
|
||||
public sealed class LayoutPopupService : ILayoutPopupService, IDisposable
|
||||
{
|
||||
private readonly LayoutPopupWindow _window;
|
||||
private readonly ILayoutPopupWindow _window;
|
||||
private readonly LayoutPopupViewModel _viewModel;
|
||||
private readonly AppSettings _settings;
|
||||
private readonly DispatcherTimer _hideTimer = new();
|
||||
|
||||
public LayoutPopupService(LayoutPopupWindow window, LayoutPopupViewModel viewModel, AppSettings settings)
|
||||
public LayoutPopupService(ILayoutPopupWindow window, LayoutPopupViewModel viewModel, AppSettings settings)
|
||||
{
|
||||
_window = window;
|
||||
_viewModel = viewModel;
|
||||
@@ -27,16 +26,28 @@ public sealed class LayoutPopupService : ILayoutPopupService, IDisposable
|
||||
|
||||
public void Show(KeyboardLayout layout)
|
||||
{
|
||||
_viewModel.ShortName = layout.ShortName;
|
||||
_window.ShowPopup();
|
||||
ShowUntilHidden(layout);
|
||||
|
||||
// Длительность читаем при каждом показе: её меняют в настройках на лету.
|
||||
// Перезапуск таймера заодно продлевает показ при быстрых переключениях
|
||||
_hideTimer.Stop();
|
||||
// The duration is read on every show: it is changed in the settings on the fly.
|
||||
// Restarting the timer also prolongs the show on quick switches
|
||||
_hideTimer.Interval = _settings.Duration;
|
||||
_hideTimer.Start();
|
||||
}
|
||||
|
||||
public void ShowUntilHidden(KeyboardLayout layout)
|
||||
{
|
||||
_hideTimer.Stop();
|
||||
|
||||
_viewModel.ShortName = layout.ShortName;
|
||||
_window.ShowPopup();
|
||||
}
|
||||
|
||||
public void Hide()
|
||||
{
|
||||
_hideTimer.Stop();
|
||||
_window.Hide();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_hideTimer.Stop();
|
||||
|
||||
@@ -6,7 +6,8 @@ using CommunityToolkit.Mvvm.ComponentModel;
|
||||
namespace CursorLang.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Берёт строки из ресурсов и при смене языка просит WPF перечитать все привязки.
|
||||
/// Takes the strings from the resources and, when the language changes, asks WPF to
|
||||
/// re-read every binding.
|
||||
/// </summary>
|
||||
public sealed class LocalizationService : ObservableObject, ILocalizationService
|
||||
{
|
||||
@@ -38,8 +39,8 @@ public sealed class LocalizationService : ObservableObject, ILocalizationService
|
||||
|
||||
OnPropertyChanged(nameof(CurrentLanguage));
|
||||
|
||||
// Сообщаем об изменении индексатора: так обновляются все привязки
|
||||
// вида {Binding Localization[Key]}, то есть весь текст интерфейса
|
||||
// We report a change of the indexer: that is how every binding of the
|
||||
// {Binding Localization[Key]} kind updates, that is, all the interface text
|
||||
OnPropertyChanged(Binding.IndexerName);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
using System.Windows;
|
||||
using System.Windows.Interop;
|
||||
using CursorLang.Interop;
|
||||
|
||||
namespace CursorLang.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Decides where the settings window shows up: for the first time in a session — in
|
||||
/// the centre of the monitor the user is working on, and after that — where they
|
||||
/// left that window.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The position lives in memory only and is not kept between launches: the set of
|
||||
/// monitors may be different by the next launch, while "in the centre of the active
|
||||
/// one" is always right.
|
||||
/// </remarks>
|
||||
public sealed class MainWindowPlacement
|
||||
{
|
||||
private PopupWindowNative.Point? _position;
|
||||
|
||||
// The window reports a move when we move it ourselves as well;
|
||||
// what has to be remembered is only what the user chose
|
||||
private bool _isPlacing;
|
||||
|
||||
/// <summary>
|
||||
/// Takes over the placement of the window: puts it in place by the first show
|
||||
/// and follows where the user moves it.
|
||||
/// </summary>
|
||||
public void Attach(Window window)
|
||||
{
|
||||
window.SourceInitialized += OnSourceInitialized;
|
||||
window.LocationChanged += OnLocationChanged;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the window to the remembered place, and when it has not been shown
|
||||
/// yet during this session — puts it in the centre of the active monitor.
|
||||
/// </summary>
|
||||
public void Apply(Window window)
|
||||
{
|
||||
// A minimized window has no meaningful bounds: it is restored in its former
|
||||
// place, and it can be positioned only after that
|
||||
if (window.WindowState != WindowState.Normal)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
IntPtr handle = new WindowInteropHelper(window).Handle;
|
||||
if (handle == IntPtr.Zero || WindowPlacementNative.TryGetBounds(handle) is not { } bounds)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
PopupWindowNative.Point? wanted = _position ?? CenterOnActiveMonitor(bounds);
|
||||
if (wanted is null || KeepOnScreen(wanted.Value, bounds) is not { } target)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_isPlacing = true;
|
||||
try
|
||||
{
|
||||
PopupWindowNative.MoveTo(handle, target.X, target.Y);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_isPlacing = false;
|
||||
}
|
||||
|
||||
_position = target;
|
||||
}
|
||||
|
||||
// The window height adapts to its content and is unknown until the first layout
|
||||
// pass — an empty window frame would end up in the centre. So we ask for the
|
||||
// layout to be computed right away: by that moment the window is not shown yet,
|
||||
// so it will not flash in its former place
|
||||
private void OnSourceInitialized(object? sender, EventArgs e)
|
||||
{
|
||||
if (sender is not Window window)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
window.SourceInitialized -= OnSourceInitialized;
|
||||
window.UpdateLayout();
|
||||
Apply(window);
|
||||
}
|
||||
|
||||
private void OnLocationChanged(object? sender, EventArgs e)
|
||||
{
|
||||
if (_isPlacing || sender is not Window { WindowState: WindowState.Normal } window)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
IntPtr handle = new WindowInteropHelper(window).Handle;
|
||||
if (handle != IntPtr.Zero && WindowPlacementNative.TryGetBounds(handle) is { } bounds)
|
||||
{
|
||||
_position = new PopupWindowNative.Point { X = bounds.Left, Y = bounds.Top };
|
||||
}
|
||||
}
|
||||
|
||||
private static PopupWindowNative.Point? CenterOnActiveMonitor(PopupWindowNative.Rect bounds)
|
||||
{
|
||||
(PopupWindowNative.Rect work, _) = PopupWindowNative.GetActiveMonitorWorkArea();
|
||||
return IsEmpty(work) ? null : Center(bounds, work);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The point at which a window with the given bounds ends up in the centre of the work area.
|
||||
/// </summary>
|
||||
internal static PopupWindowNative.Point Center(
|
||||
PopupWindowNative.Rect bounds, PopupWindowNative.Rect work) => new()
|
||||
{
|
||||
X = work.Left + (((work.Right - work.Left) - Width(bounds)) / 2),
|
||||
Y = work.Top + (((work.Bottom - work.Top) - Height(bounds)) / 2),
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Pulls the window into the work area of the nearest monitor.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Needed in two cases. The monitor the user put the window on may be disconnected
|
||||
/// during the session — returning the window to its place would then leave the
|
||||
/// user without a window, so the remembered position is a wish here rather than an
|
||||
/// order. And the window height equals the height of its content and may exceed
|
||||
/// the work area on a short monitor — then the title bar of a window placed in the
|
||||
/// centre would go past the top edge.
|
||||
/// </remarks>
|
||||
private static PopupWindowNative.Point? KeepOnScreen(
|
||||
PopupWindowNative.Point position, PopupWindowNative.Rect bounds)
|
||||
{
|
||||
int width = Width(bounds);
|
||||
int height = Height(bounds);
|
||||
|
||||
var wanted = new PopupWindowNative.Rect
|
||||
{
|
||||
Left = position.X,
|
||||
Top = position.Y,
|
||||
Right = position.X + width,
|
||||
Bottom = position.Y + height,
|
||||
};
|
||||
|
||||
if (WindowPlacementNative.TryGetWorkAreaNear(wanted) is not { } work || IsEmpty(work))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return Clamp(position, bounds, work);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pulls the point so that a window with the given bounds fits into the work area
|
||||
/// entirely. A window taller than the work area gets its top edge: the title bar
|
||||
/// is needed more than the lower part of the window.
|
||||
/// </summary>
|
||||
internal static PopupWindowNative.Point Clamp(
|
||||
PopupWindowNative.Point position,
|
||||
PopupWindowNative.Rect bounds,
|
||||
PopupWindowNative.Rect work)
|
||||
{
|
||||
int width = Width(bounds);
|
||||
int height = Height(bounds);
|
||||
|
||||
return new PopupWindowNative.Point
|
||||
{
|
||||
X = Math.Clamp(position.X, work.Left, Math.Max(work.Left, work.Right - width)),
|
||||
Y = Math.Clamp(position.Y, work.Top, Math.Max(work.Top, work.Bottom - height)),
|
||||
};
|
||||
}
|
||||
|
||||
private static int Width(PopupWindowNative.Rect rect) => rect.Right - rect.Left;
|
||||
|
||||
private static int Height(PopupWindowNative.Rect rect) => rect.Bottom - rect.Top;
|
||||
|
||||
internal static bool IsEmpty(PopupWindowNative.Rect rect) =>
|
||||
rect.Right <= rect.Left || rect.Bottom <= rect.Top;
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
using CursorLang.Interop;
|
||||
using CursorLang.Models;
|
||||
|
||||
namespace CursorLang.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Computes the screen point to show the popup at.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// There is nothing but arithmetic here: where the cursor is, where the caret is and
|
||||
/// what the monitor bounds are is figured out by the window itself — it has a handle
|
||||
/// of its own for that. The computation is kept apart because it is exactly the place
|
||||
/// where a sign or half a size is easy to get wrong, and this way it can be checked
|
||||
/// without a single window on screen.
|
||||
///
|
||||
/// All the values are in physical pixels: monitors have different scaling, and
|
||||
/// converting to WPF units halfway would mean rounding twice.
|
||||
/// </remarks>
|
||||
internal static class PopupLayout
|
||||
{
|
||||
/// <summary>
|
||||
/// The popup position next to the anchor point — the cursor or the caret.
|
||||
/// The cursor arrives here as a rectangle of zero size.
|
||||
/// </summary>
|
||||
internal static PopupWindowNative.Point NearAnchor(
|
||||
PopupWindowNative.Rect anchor,
|
||||
AnchorSide side,
|
||||
int offset,
|
||||
int width,
|
||||
int height)
|
||||
{
|
||||
int toLeftOf = anchor.Left - offset - width;
|
||||
int toRightOf = anchor.Right + offset;
|
||||
int above = anchor.Top - offset - height;
|
||||
int below = anchor.Bottom + offset;
|
||||
|
||||
// For the "left" and "right" sides the popup lines up with the anchor point
|
||||
int middle = anchor.Top + (((anchor.Bottom - anchor.Top) - height) / 2);
|
||||
|
||||
(int x, int y) = side switch
|
||||
{
|
||||
AnchorSide.TopLeft => (toLeftOf, above),
|
||||
AnchorSide.TopRight => (toRightOf, above),
|
||||
AnchorSide.Left => (toLeftOf, middle),
|
||||
AnchorSide.Right => (toRightOf, middle),
|
||||
AnchorSide.BottomLeft => (toLeftOf, below),
|
||||
_ => (toRightOf, below),
|
||||
};
|
||||
|
||||
return new PopupWindowNative.Point { X = x, Y = y };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The popup position in the given corner of the monitor work area.
|
||||
/// </summary>
|
||||
internal static PopupWindowNative.Point OnScreen(
|
||||
PopupWindowNative.Rect work,
|
||||
ScreenPosition position,
|
||||
int margin,
|
||||
int width,
|
||||
int height)
|
||||
{
|
||||
int left = work.Left + margin;
|
||||
int right = work.Right - margin - width;
|
||||
int top = work.Top + margin;
|
||||
int bottom = work.Bottom - margin - height;
|
||||
int centerX = work.Left + ((work.Right - work.Left - width) / 2);
|
||||
int centerY = work.Top + ((work.Bottom - work.Top - height) / 2);
|
||||
|
||||
(int x, int y) = position switch
|
||||
{
|
||||
ScreenPosition.TopLeft => (left, top),
|
||||
ScreenPosition.Top => (centerX, top),
|
||||
ScreenPosition.TopRight => (right, top),
|
||||
ScreenPosition.BottomLeft => (left, bottom),
|
||||
ScreenPosition.Bottom => (centerX, bottom),
|
||||
ScreenPosition.BottomRight => (right, bottom),
|
||||
_ => (centerX, centerY),
|
||||
};
|
||||
|
||||
return new PopupWindowNative.Point { X = x, Y = y };
|
||||
}
|
||||
|
||||
/// <summary>A rectangle of zero size at a point — the mouse cursor as an anchor.</summary>
|
||||
internal static PopupWindowNative.Rect AsAnchor(PopupWindowNative.Point point) => new()
|
||||
{
|
||||
Left = point.X,
|
||||
Top = point.Y,
|
||||
Right = point.X,
|
||||
Bottom = point.Y,
|
||||
};
|
||||
|
||||
/// <summary>WPF units into physical pixels of a monitor with the given scale.</summary>
|
||||
internal static int ToPixels(double wpfUnits, double scale) => (int)Math.Round(wpfUnits * scale);
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
using System.IO;
|
||||
using System.Security;
|
||||
using CursorLang.Models;
|
||||
using Microsoft.Win32;
|
||||
|
||||
namespace CursorLang.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Startup for a build that is not a package: a value under the Run key.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A package declares its startup task in the manifest and asks Windows to switch
|
||||
/// it on. A build unpacked into a folder has no manifest, so it registers itself
|
||||
/// the way desktop programs always have — under the Run key of the current user.
|
||||
/// Administrator rights are not needed for that: the key belongs to the user.
|
||||
///
|
||||
/// Windows keeps the user's own verdict apart from the entry itself. Turning the
|
||||
/// app off in Settings — Apps — Startup leaves the Run value where it is and marks
|
||||
/// it disabled under StartupApproved. The mark is obeyed here the same way a
|
||||
/// package obeys DisabledByUser: the app does not argue with the user.
|
||||
/// </remarks>
|
||||
internal sealed class RegistryStartup
|
||||
{
|
||||
private const string RunPath = @"Software\Microsoft\Windows\CurrentVersion\Run";
|
||||
|
||||
private const string ApprovedPath =
|
||||
@"Software\Microsoft\Windows\CurrentVersion\Explorer\StartupApproved\Run";
|
||||
|
||||
/// <summary>The name of the value — Windows shows it in the startup list.</summary>
|
||||
private const string ValueName = "CursorLang";
|
||||
|
||||
private readonly RegistryKey _root;
|
||||
private readonly string? _command;
|
||||
|
||||
internal RegistryStartup()
|
||||
: this(Registry.CurrentUser, GetCommand())
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>A root of the test's own, so that the real startup list is left alone.</summary>
|
||||
internal RegistryStartup(RegistryKey root, string? command)
|
||||
{
|
||||
_root = root;
|
||||
_command = command;
|
||||
}
|
||||
|
||||
internal StartupState GetState()
|
||||
{
|
||||
if (_command is null)
|
||||
{
|
||||
return StartupState.Unavailable;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using RegistryKey? run = _root.OpenSubKey(RunPath);
|
||||
|
||||
if (run?.GetValue(ValueName) is null)
|
||||
{
|
||||
return StartupState.Disabled;
|
||||
}
|
||||
|
||||
return IsApprovedByUser() ? StartupState.Enabled : StartupState.DisabledByUser;
|
||||
}
|
||||
catch (Exception e) when (e is SecurityException or UnauthorizedAccessException or IOException)
|
||||
{
|
||||
return StartupState.Unavailable;
|
||||
}
|
||||
}
|
||||
|
||||
internal StartupState SetEnabled(bool enabled)
|
||||
{
|
||||
if (_command is null)
|
||||
{
|
||||
return StartupState.Unavailable;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using RegistryKey run = _root.CreateSubKey(RunPath);
|
||||
|
||||
if (enabled)
|
||||
{
|
||||
// The path is written afresh every time: the app may have been moved
|
||||
run.SetValue(ValueName, _command, RegistryValueKind.String);
|
||||
}
|
||||
else
|
||||
{
|
||||
run.DeleteValue(ValueName, throwOnMissingValue: false);
|
||||
}
|
||||
}
|
||||
catch (Exception e) when (e is SecurityException or UnauthorizedAccessException or IOException)
|
||||
{
|
||||
return StartupState.Unavailable;
|
||||
}
|
||||
|
||||
// The answer is read back rather than assumed: an entry the user has
|
||||
// banned stays banned no matter what was just written next to it
|
||||
return GetState();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Whether the user has left the entry alone. The verdict is a blob whose
|
||||
/// lowest bit of the first byte stands for the ban; no value means untouched.
|
||||
/// </summary>
|
||||
private bool IsApprovedByUser()
|
||||
{
|
||||
using RegistryKey? approved = _root.OpenSubKey(ApprovedPath);
|
||||
|
||||
return approved?.GetValue(ValueName) is not byte[] { Length: > 0 } verdict
|
||||
|| (verdict[0] & 1) == 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// What Windows is to run. <c>null</c> — the path of the running program is
|
||||
/// unknown, and there is nothing to write down.
|
||||
/// </summary>
|
||||
private static string? GetCommand() =>
|
||||
Environment.ProcessPath is { Length: > 0 } path ? $"\"{path}\"" : null;
|
||||
}
|
||||
@@ -4,13 +4,27 @@ using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Windows.Media;
|
||||
using System.Windows.Threading;
|
||||
using CursorLang.Interop;
|
||||
using CursorLang.Models;
|
||||
using Windows.Storage;
|
||||
|
||||
namespace CursorLang.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Хранит настройки в %APPDATA%\CursorLang\settings.json.
|
||||
/// Keeps the settings in the settings.json file.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The location of the file depends on how the application is installed. A package
|
||||
/// from the Store keeps its settings in a folder of its own: Windows removes it
|
||||
/// together with the application, and after the removal nothing superfluous is left
|
||||
/// in the system — that is what Store applications are expected to do. A separately
|
||||
/// installed application keeps its settings in %APPDATA%, as before.
|
||||
///
|
||||
/// Settings left over from a separately installed application are picked up by the
|
||||
/// package on the first launch and moved over. The original file stays where it is:
|
||||
/// both versions can be installed side by side, and the application has no right to
|
||||
/// delete settings that are not its own.
|
||||
/// </remarks>
|
||||
public sealed class SettingsService : IDisposable
|
||||
{
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new()
|
||||
@@ -19,31 +33,68 @@ public sealed class SettingsService : IDisposable
|
||||
Converters = { new ColorJsonConverter(), new JsonStringEnumConverter() },
|
||||
};
|
||||
|
||||
private const string FileName = "settings.json";
|
||||
|
||||
private readonly string _filePath;
|
||||
private readonly string _inheritedFilePath;
|
||||
private readonly DispatcherTimer _saveTimer;
|
||||
private AppSettings? _settings;
|
||||
|
||||
public SettingsService()
|
||||
{
|
||||
string folder = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
||||
"CursorLang");
|
||||
_filePath = Path.Combine(folder, "settings.json");
|
||||
// Sliders change their values continuously, so writing to disk
|
||||
// is postponed until there is a pause in the changes
|
||||
private static readonly TimeSpan SaveDelay = TimeSpan.FromMilliseconds(500);
|
||||
|
||||
// Ползунки меняют значения непрерывно, поэтому запись на диск
|
||||
// откладывается до паузы в изменениях
|
||||
_saveTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(500) };
|
||||
public SettingsService()
|
||||
: this(
|
||||
Path.Combine(GetSettingsFolder(), FileName),
|
||||
Path.Combine(GetSeparateInstallFolder(), FileName),
|
||||
SaveDelay)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the storage locations and the save delay explicitly — thereby making it
|
||||
/// possible to check the work with the file without touching the settings of the
|
||||
/// user themselves.
|
||||
/// </summary>
|
||||
internal SettingsService(string filePath, string inheritedFilePath, TimeSpan saveDelay)
|
||||
{
|
||||
_filePath = filePath;
|
||||
_inheritedFilePath = inheritedFilePath;
|
||||
|
||||
_saveTimer = new DispatcherTimer { Interval = saveDelay };
|
||||
_saveTimer.Tick += OnSaveTimerTick;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Читает настройки с диска либо отдаёт значения по умолчанию,
|
||||
/// и дальше сам сохраняет любые изменения.
|
||||
/// Reads the settings from disk or returns the default values,
|
||||
/// and from then on saves any changes by itself.
|
||||
/// </summary>
|
||||
public AppSettings Load()
|
||||
{
|
||||
_settings = ReadFile() ?? CreateDefault();
|
||||
AppSettings? stored = ReadFile(_filePath);
|
||||
|
||||
// There is no file of our own — the application may well have been configured
|
||||
// before the move to a package. Taking the settings from there beats starting
|
||||
// from a blank slate
|
||||
bool inherited = stored is null && _filePath != _inheritedFilePath;
|
||||
if (inherited)
|
||||
{
|
||||
stored = ReadFile(_inheritedFilePath);
|
||||
inherited = stored is not null;
|
||||
}
|
||||
|
||||
_settings = stored ?? CreateDefault();
|
||||
_settings.PropertyChanged += OnSettingsChanged;
|
||||
|
||||
// Moved settings are fixed in the new place right away rather than on the
|
||||
// first edit: otherwise the application would read someone else's file every
|
||||
// time until then
|
||||
if (inherited)
|
||||
{
|
||||
Save();
|
||||
}
|
||||
|
||||
return _settings;
|
||||
}
|
||||
|
||||
@@ -61,7 +112,7 @@ public sealed class SettingsService : IDisposable
|
||||
}
|
||||
catch (Exception e) when (e is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
// Настройки — не тот случай, ради которого стоит ронять приложение
|
||||
// The settings are not the kind of thing worth bringing the application down for
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,25 +128,46 @@ public sealed class SettingsService : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
private AppSettings? ReadFile()
|
||||
/// <summary>
|
||||
/// The folder the application writes its settings to.
|
||||
/// </summary>
|
||||
private static string GetSettingsFolder()
|
||||
{
|
||||
if (!PackageIdentityNative.IsPackaged)
|
||||
{
|
||||
return GetSeparateInstallFolder();
|
||||
}
|
||||
|
||||
// A package has a data folder of its own, which Windows creates and removes
|
||||
// itself. The application name is not appended to it: the folder belongs to it alone anyway
|
||||
return ApplicationData.Current.LocalFolder.Path;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The settings folder of a separately installed application — the same source
|
||||
/// the package inherits the settings from on the first launch.
|
||||
/// </summary>
|
||||
private static string GetSeparateInstallFolder() => Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
||||
"CursorLang");
|
||||
|
||||
private static AppSettings? ReadFile(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!File.Exists(_filePath))
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return JsonSerializer.Deserialize<AppSettings>(File.ReadAllText(_filePath), SerializerOptions);
|
||||
return JsonSerializer.Deserialize<AppSettings>(File.ReadAllText(path), SerializerOptions);
|
||||
}
|
||||
catch (Exception e) when (e is IOException or UnauthorizedAccessException or JsonException)
|
||||
{
|
||||
// Испорченный файл не должен мешать запуску: начнём с умолчаний
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Язык по умолчанию берём системный, если он поддерживается
|
||||
private static AppSettings CreateDefault()
|
||||
{
|
||||
string uiLanguage = System.Globalization.CultureInfo.CurrentUICulture.TwoLetterISOLanguageName;
|
||||
@@ -114,7 +186,7 @@ public sealed class SettingsService : IDisposable
|
||||
Save();
|
||||
}
|
||||
|
||||
// Color не сериализуется штатно, а хранить его читаемым в файле удобно
|
||||
// Color is not serialized out of the box, and keeping it readable in the file is handy
|
||||
private sealed class ColorJsonConverter : JsonConverter<Color>
|
||||
{
|
||||
public override Color Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
using System.Windows.Threading;
|
||||
using CursorLang.Interop;
|
||||
|
||||
namespace CursorLang.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Lets only one instance of the application run: a second launch does not bring up
|
||||
/// a second window but shows the window of the one already running.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The kernel object names are left without the Global prefix, that is, they live in
|
||||
/// the session namespace. A single instance for the whole machine would make for an
|
||||
/// odd picture with fast user switching: the second user would be left without the
|
||||
/// application, and showing them the window of the first one is impossible anyway —
|
||||
/// windows belong to a session.
|
||||
/// </remarks>
|
||||
public sealed class SingleInstanceGate : IDisposable
|
||||
{
|
||||
private const string MutexName = "CursorLang.SingleInstance";
|
||||
private const string ActivationEventName = "CursorLang.ActivationRequest";
|
||||
|
||||
private readonly Dispatcher _dispatcher = Dispatcher.CurrentDispatcher;
|
||||
private readonly string _mutexName;
|
||||
private readonly string _activationEventName;
|
||||
|
||||
private Mutex? _mutex;
|
||||
private EventWaitHandle? _activationRequest;
|
||||
private RegisteredWaitHandle? _activationWait;
|
||||
private bool _isOwner;
|
||||
|
||||
public SingleInstanceGate()
|
||||
: this(string.Empty)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a distinguishing part to the kernel object names. Needed by the tests:
|
||||
/// otherwise they would share the single-instance slot with the running
|
||||
/// application and get in its way.
|
||||
/// </summary>
|
||||
internal SingleInstanceGate(string nameSuffix)
|
||||
{
|
||||
_mutexName = MutexName + nameSuffix;
|
||||
_activationEventName = ActivationEventName + nameSuffix;
|
||||
}
|
||||
|
||||
/// <summary>Another launch asks for the window to be shown.</summary>
|
||||
public event EventHandler? ActivationRequested;
|
||||
|
||||
/// <summary>
|
||||
/// Takes the single-instance slot. When the application is already running, asks
|
||||
/// it to show itself and returns <c>false</c> — the caller is left to exit.
|
||||
/// </summary>
|
||||
public bool TryAcquire()
|
||||
{
|
||||
_mutex = new Mutex(initiallyOwned: false, _mutexName);
|
||||
|
||||
try
|
||||
{
|
||||
_isOwner = _mutex.WaitOne(TimeSpan.Zero, exitContext: false);
|
||||
}
|
||||
catch (AbandonedMutexException)
|
||||
{
|
||||
// The previous instance crashed and did not release the mutex.
|
||||
// It has no owner now, which means the slot is free
|
||||
_isOwner = true;
|
||||
}
|
||||
|
||||
// The event is opened by both instances: the first one to wait for a request,
|
||||
// the second one to make it. Which of them creates the object depends on who
|
||||
// came first and does not affect the work
|
||||
_activationRequest = new EventWaitHandle(false, EventResetMode.AutoReset, _activationEventName);
|
||||
|
||||
if (!_isOwner)
|
||||
{
|
||||
ForegroundPermissionNative.GrantToAnyProcess();
|
||||
_activationRequest.Set();
|
||||
return false;
|
||||
}
|
||||
|
||||
// The wait is handed over to the thread pool: there is no reason to hold a
|
||||
// thread of our own for it, and the request may never come
|
||||
_activationWait = ThreadPool.RegisterWaitForSingleObject(
|
||||
_activationRequest,
|
||||
OnActivationSignalled,
|
||||
state: null,
|
||||
Timeout.Infinite,
|
||||
executeOnlyOnce: false);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_activationWait?.Unregister(null);
|
||||
_activationWait = null;
|
||||
|
||||
_activationRequest?.Dispose();
|
||||
_activationRequest = null;
|
||||
|
||||
// The mutex is released by the same thread that took it: both happen
|
||||
// on the user interface thread
|
||||
if (_isOwner)
|
||||
{
|
||||
_mutex?.ReleaseMutex();
|
||||
_isOwner = false;
|
||||
}
|
||||
|
||||
_mutex?.Dispose();
|
||||
_mutex = null;
|
||||
}
|
||||
|
||||
// The thread pool reports the request from wherever it happens to be, while the
|
||||
// window obeys only its own thread
|
||||
private void OnActivationSignalled(object? state, bool timedOut) =>
|
||||
_dispatcher.BeginInvoke(() => ActivationRequested?.Invoke(this, EventArgs.Empty));
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
using System.Runtime.InteropServices;
|
||||
using CursorLang.Interop;
|
||||
using CursorLang.Models;
|
||||
using Windows.ApplicationModel;
|
||||
|
||||
namespace CursorLang.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Startup, arranged by whatever means the current build has.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Startup used to be a scheduled task with the highest rights — otherwise an app
|
||||
/// that wanted administrator rights would not start from the startup folder. The
|
||||
/// app needs no such rights any more, and the two ways left are simpler.
|
||||
///
|
||||
/// A package declares the task in its manifest, and Windows lists it for the user
|
||||
/// next to the rest under Settings — Apps — Startup. Turned off there, it can no
|
||||
/// longer be turned back on by the app. Outside a package the same setting is kept
|
||||
/// in the registry: see <see cref="RegistryStartup"/>.
|
||||
/// </remarks>
|
||||
public sealed class StartupService : IStartupService
|
||||
{
|
||||
/// <summary>Matches TaskId in the package manifest.</summary>
|
||||
private const string TaskId = "CursorLangStartup";
|
||||
|
||||
private readonly RegistryStartup _registry = new();
|
||||
|
||||
public async Task<StartupState> GetStateAsync()
|
||||
{
|
||||
if (!PackageIdentityNative.IsPackaged)
|
||||
{
|
||||
return _registry.GetState();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
StartupTask task = await StartupTask.GetAsync(TaskId);
|
||||
return Translate(task.State);
|
||||
}
|
||||
catch (Exception e) when (e is COMException or ArgumentException or InvalidOperationException)
|
||||
{
|
||||
// No task by that name in the manifest: that happens to a package put
|
||||
// together by hand. The setting simply will not show
|
||||
return StartupState.Unavailable;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<StartupState> SetEnabledAsync(bool enabled)
|
||||
{
|
||||
if (!PackageIdentityNative.IsPackaged)
|
||||
{
|
||||
return _registry.SetEnabled(enabled);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
StartupTask task = await StartupTask.GetAsync(TaskId);
|
||||
|
||||
if (!enabled)
|
||||
{
|
||||
task.Disable();
|
||||
return Translate(task.State);
|
||||
}
|
||||
|
||||
// Windows answers with a state rather than with success: once the user
|
||||
// has forbidden startup, the ban stays
|
||||
return Translate(await task.RequestEnableAsync());
|
||||
}
|
||||
catch (Exception e) when (e is COMException or ArgumentException or InvalidOperationException)
|
||||
{
|
||||
return StartupState.Unavailable;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The state of a Windows task in the app's own terms.</summary>
|
||||
internal static StartupState Translate(StartupTaskState state) => state switch
|
||||
{
|
||||
StartupTaskState.Enabled => StartupState.Enabled,
|
||||
StartupTaskState.EnabledByPolicy => StartupState.EnabledByPolicy,
|
||||
StartupTaskState.Disabled => StartupState.Disabled,
|
||||
StartupTaskState.DisabledByUser => StartupState.DisabledByUser,
|
||||
StartupTaskState.DisabledByPolicy => StartupState.DisabledByPolicy,
|
||||
_ => StartupState.Unavailable,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
using System.ComponentModel;
|
||||
using System.Windows;
|
||||
using System.Windows.Interop;
|
||||
using CursorLang.Interop;
|
||||
using CursorLang.Models;
|
||||
using Microsoft.Win32;
|
||||
|
||||
namespace CursorLang.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Keeps the palette of the chosen theme in the application resources and swaps it
|
||||
/// when the setting changes — the windows are recoloured without a restart.
|
||||
/// </summary>
|
||||
public sealed class ThemeService : IThemeService, IDisposable
|
||||
{
|
||||
private const string PersonalizeKey =
|
||||
@"Software\Microsoft\Windows\CurrentVersion\Themes\Personalize";
|
||||
|
||||
private readonly AppSettings _settings;
|
||||
private readonly Func<AppTheme> _detectSystemTheme;
|
||||
private readonly List<Window> _windows = [];
|
||||
private ResourceDictionary? _palette;
|
||||
private AppTheme _current;
|
||||
|
||||
public ThemeService(AppSettings settings)
|
||||
: this(settings, DetectSystemTheme)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Takes the source of the system theme explicitly: in tests it is not the registry
|
||||
/// that provides it.
|
||||
/// </summary>
|
||||
internal ThemeService(AppSettings settings, Func<AppTheme> detectSystemTheme)
|
||||
{
|
||||
_settings = settings;
|
||||
_detectSystemTheme = detectSystemTheme;
|
||||
_settings.PropertyChanged += OnSettingsChanged;
|
||||
SystemEvents.UserPreferenceChanged += OnUserPreferenceChanged;
|
||||
Apply();
|
||||
}
|
||||
|
||||
/// <summary>The theme the user sees: <c>System</c> is already resolved here.</summary>
|
||||
public AppTheme CurrentTheme => _current;
|
||||
|
||||
public void Register(Window window)
|
||||
{
|
||||
_windows.Add(window);
|
||||
window.Closed += OnWindowClosed;
|
||||
|
||||
if (new WindowInteropHelper(window).Handle == IntPtr.Zero)
|
||||
{
|
||||
window.SourceInitialized += OnWindowSourceInitialized;
|
||||
return;
|
||||
}
|
||||
|
||||
ApplyTitleBar(window);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_settings.PropertyChanged -= OnSettingsChanged;
|
||||
SystemEvents.UserPreferenceChanged -= OnUserPreferenceChanged;
|
||||
|
||||
foreach (Window window in _windows)
|
||||
{
|
||||
window.Closed -= OnWindowClosed;
|
||||
window.SourceInitialized -= OnWindowSourceInitialized;
|
||||
}
|
||||
|
||||
_windows.Clear();
|
||||
}
|
||||
|
||||
/// <summary>The app theme from the Windows settings.</summary>
|
||||
internal static AppTheme DetectSystemTheme()
|
||||
{
|
||||
try
|
||||
{
|
||||
using RegistryKey? key = Registry.CurrentUser.OpenSubKey(PersonalizeKey);
|
||||
return key?.GetValue("AppsUseLightTheme") is int light && light == 0
|
||||
? AppTheme.Dark
|
||||
: AppTheme.Light;
|
||||
}
|
||||
catch (Exception e) when (e is System.Security.SecurityException or UnauthorizedAccessException)
|
||||
{
|
||||
return AppTheme.Light;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnSettingsChanged(object? sender, PropertyChangedEventArgs e)
|
||||
{
|
||||
if (e.PropertyName == nameof(AppSettings.Theme))
|
||||
{
|
||||
Apply();
|
||||
}
|
||||
}
|
||||
|
||||
// Windows reports a theme change from outside the interface thread
|
||||
private void OnUserPreferenceChanged(object? sender, UserPreferenceChangedEventArgs e) =>
|
||||
Application.Current?.Dispatcher.InvokeAsync(Apply);
|
||||
|
||||
private void Apply()
|
||||
{
|
||||
AppTheme theme = _settings.Theme == AppTheme.System ? _detectSystemTheme() : _settings.Theme;
|
||||
if (_palette is not null && theme == _current)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_current = theme;
|
||||
|
||||
var next = new ResourceDictionary
|
||||
{
|
||||
Source = PaletteUri(theme),
|
||||
};
|
||||
|
||||
ICollection<ResourceDictionary> dictionaries = Application.Current.Resources.MergedDictionaries;
|
||||
dictionaries.Add(next);
|
||||
|
||||
if (_palette is not null)
|
||||
{
|
||||
dictionaries.Remove(_palette);
|
||||
}
|
||||
|
||||
_palette = next;
|
||||
|
||||
foreach (Window window in _windows)
|
||||
{
|
||||
ApplyTitleBar(window);
|
||||
}
|
||||
}
|
||||
|
||||
// The assembly name in the address is there on purpose: without it the dictionary
|
||||
// is looked up in the assembly the process started from, which is not always the
|
||||
// application itself
|
||||
internal static Uri PaletteUri(AppTheme theme) =>
|
||||
new($"pack://application:,,,/CursorLang;component/Themes/{theme}.xaml", UriKind.Absolute);
|
||||
|
||||
private void ApplyTitleBar(Window window) =>
|
||||
WindowThemeNative.SetDarkTitleBar(
|
||||
new WindowInteropHelper(window).Handle,
|
||||
_current == AppTheme.Dark);
|
||||
|
||||
private void OnWindowSourceInitialized(object? sender, EventArgs e)
|
||||
{
|
||||
if (sender is Window window)
|
||||
{
|
||||
window.SourceInitialized -= OnWindowSourceInitialized;
|
||||
ApplyTitleBar(window);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnWindowClosed(object? sender, EventArgs e)
|
||||
{
|
||||
if (sender is Window window)
|
||||
{
|
||||
window.Closed -= OnWindowClosed;
|
||||
_windows.Remove(window);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
namespace CursorLang.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Where the application learns about new versions from.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// These settings belong to the build rather than to the user: the repository is
|
||||
/// chosen by whoever releases the application, and these values have no business
|
||||
/// being in <c>settings.json</c>. The defaults point at the repository the
|
||||
/// application is built from.
|
||||
/// </remarks>
|
||||
public sealed class UpdateOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// The address of the Gitea server. Its API lives on the same host as the
|
||||
/// repository pages, so this is the same address the repository is opened at
|
||||
/// in a browser.
|
||||
/// </summary>
|
||||
public Uri ServiceUri { get; init; } = new("https://git.alrakis.kz/");
|
||||
|
||||
/// <summary>The project: <c>owner/repository</c>.</summary>
|
||||
public string Project { get; init; } = "alrakis/cursor-lang";
|
||||
|
||||
/// <summary>How often the application checks the releases on its own.</summary>
|
||||
public TimeSpan CheckInterval { get; init; } = TimeSpan.FromDays(1);
|
||||
|
||||
/// <summary>
|
||||
/// An access token for a private repository.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Taken from an environment variable rather than from a file in the repository:
|
||||
/// a secret that gets into a build gets to everyone who received it as well.
|
||||
/// A public repository needs no token at all.
|
||||
/// </remarks>
|
||||
public string? AccessToken { get; init; } =
|
||||
Environment.GetEnvironmentVariable("CURSORLANG_UPDATE_TOKEN");
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Reflection;
|
||||
using System.Runtime.InteropServices;
|
||||
using CursorLang.Interop;
|
||||
using CursorLang.Models;
|
||||
using Windows.ApplicationModel;
|
||||
|
||||
namespace CursorLang.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Learns about new versions from the repository and hands the downloaded package
|
||||
/// over to the installer.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The package is installed by the Windows app installer, not by the application on
|
||||
/// its own. Through <c>PackageManager</c> the update would go without a single
|
||||
/// window, but then the application would have to explain both an untrusted signature
|
||||
/// and a policy ban to the user itself — the installer already knows how to do all
|
||||
/// that and shows the package publisher before the installation, not after.
|
||||
/// </remarks>
|
||||
public sealed class UpdateService : IUpdateService, IDisposable
|
||||
{
|
||||
/// <summary>The package is large and the network can be slow: the buffer is taken with room to spare.</summary>
|
||||
private const int BufferSize = 81920;
|
||||
|
||||
/// <summary>The version of the running application — it does not change while it runs.</summary>
|
||||
private static readonly Version Current = DetectCurrentVersion();
|
||||
|
||||
private readonly IReleaseFeed _feed;
|
||||
private readonly HttpClient _client;
|
||||
private readonly bool _ownsClient;
|
||||
private readonly string _downloadFolder;
|
||||
|
||||
public UpdateService(UpdateOptions options)
|
||||
{
|
||||
_client = CreateClient();
|
||||
_ownsClient = true;
|
||||
_downloadFolder = Path.Combine(Path.GetTempPath(), "CursorLang");
|
||||
_feed = new GiteaReleaseFeed(_client, options);
|
||||
|
||||
CurrentVersion = Current;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Takes the releases, the network and the version explicitly: in tests they are
|
||||
/// not provided by Windows.
|
||||
/// </summary>
|
||||
internal UpdateService(IReleaseFeed feed, HttpClient client, Version current, string downloadFolder)
|
||||
{
|
||||
_feed = feed;
|
||||
_client = client;
|
||||
_ownsClient = false;
|
||||
_downloadFolder = downloadFolder;
|
||||
|
||||
CurrentVersion = current;
|
||||
}
|
||||
|
||||
public bool IsSupported { get; } = DetectSupport();
|
||||
|
||||
public Version CurrentVersion { get; }
|
||||
|
||||
public async Task<ReleaseInfo?> CheckAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
ReleaseInfo? release = await _feed.GetLatestAsync(cancellationToken);
|
||||
return release is not null && release.Version > CurrentVersion ? release : null;
|
||||
}
|
||||
|
||||
public async Task<string> DownloadAsync(
|
||||
ReleaseInfo release,
|
||||
IProgress<double>? progress,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
string path = Path.Combine(_downloadFolder, BuildFileName(release));
|
||||
string partial = path + ".part";
|
||||
|
||||
Directory.CreateDirectory(_downloadFolder);
|
||||
RemoveLeftovers(path);
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, release.Package.Url);
|
||||
_feed.Authorize(request);
|
||||
|
||||
using HttpResponseMessage response = await _client.SendAsync(
|
||||
request, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
long total = response.Content.Headers.ContentLength ?? release.Package.Size;
|
||||
|
||||
await using (Stream source = await response.Content.ReadAsStreamAsync(cancellationToken))
|
||||
await using (FileStream target = File.Create(partial))
|
||||
{
|
||||
byte[] buffer = new byte[BufferSize];
|
||||
long copied = 0;
|
||||
int reported = -1;
|
||||
int read;
|
||||
|
||||
while ((read = await source.ReadAsync(buffer, cancellationToken)) > 0)
|
||||
{
|
||||
await target.WriteAsync(buffer.AsMemory(0, read), cancellationToken);
|
||||
copied += read;
|
||||
|
||||
if (total <= 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// The progress bar cannot tell fractions of a percent apart, and
|
||||
// redrawing on every chunk read would cost more than the download itself
|
||||
int percent = (int)(copied * 100 / total);
|
||||
if (percent != reported)
|
||||
{
|
||||
reported = percent;
|
||||
progress?.Report(percent / 100d);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A file becomes ready only once downloaded in full: an interrupted download
|
||||
// must not stay on disk under the package name
|
||||
File.Move(partial, path, overwrite: true);
|
||||
return path;
|
||||
}
|
||||
|
||||
public void Install(string packagePath) =>
|
||||
Process.Start(new ProcessStartInfo(packagePath) { UseShellExecute = true })?.Dispose();
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_ownsClient)
|
||||
{
|
||||
_client.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private static HttpClient CreateClient()
|
||||
{
|
||||
// The check and the download have different deadlines: seconds are enough for
|
||||
// the first one, while the second one takes minutes on a slow network. So the
|
||||
// client has no shared timeout, and every operation allots time for itself
|
||||
var handler = new SocketsHttpHandler { ConnectTimeout = TimeSpan.FromSeconds(15) };
|
||||
var client = new HttpClient(handler) { Timeout = Timeout.InfiniteTimeSpan };
|
||||
|
||||
// The User-Agent shows who came: a request without one may well be taken
|
||||
// for a robot and rejected by the server
|
||||
client.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("CursorLang", Current.ToString()));
|
||||
|
||||
return client;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Whether to check for updates at all: a package from the Store gets them from the Store.
|
||||
/// </summary>
|
||||
private static bool DetectSupport()
|
||||
{
|
||||
if (!PackageIdentityNative.IsPackaged)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return Package.Current.SignatureKind != PackageSignatureKind.Store;
|
||||
}
|
||||
catch (Exception e) when (e is COMException or InvalidOperationException)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private static Version DetectCurrentVersion()
|
||||
{
|
||||
if (PackageIdentityNative.IsPackaged)
|
||||
{
|
||||
try
|
||||
{
|
||||
// A package has a version of its own — the one from the manifest. That
|
||||
// is also the one in the release tag, while the assembly version may differ
|
||||
PackageVersion version = Package.Current.Id.Version;
|
||||
return new Version(version.Major, version.Minor, version.Build, version.Revision);
|
||||
}
|
||||
catch (Exception e) when (e is COMException or InvalidOperationException)
|
||||
{
|
||||
// The package was built without a version in the manifest — the assembly version is left
|
||||
}
|
||||
}
|
||||
|
||||
return Assembly.GetEntryAssembly()?.GetName().Version ?? new Version(0, 0, 0, 0);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The file name on disk. Only the extension is taken from the hosting response:
|
||||
/// the name itself comes from the outside, and a file is created with it.
|
||||
/// </summary>
|
||||
private static string BuildFileName(ReleaseInfo release)
|
||||
{
|
||||
string extension = release.Package.FileName.EndsWith(".msixbundle", StringComparison.OrdinalIgnoreCase)
|
||||
? ".msixbundle"
|
||||
: ".msix";
|
||||
|
||||
return $"CursorLang-{release.Version}{extension}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes packages downloaded earlier: they take up a noticeable amount of
|
||||
/// space and are needed only until the installation.
|
||||
/// </summary>
|
||||
private void RemoveLeftovers(string keep)
|
||||
{
|
||||
try
|
||||
{
|
||||
foreach (string file in Directory.EnumerateFiles(_downloadFolder))
|
||||
{
|
||||
if (!string.Equals(file, keep, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
File.Delete(file);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception e) when (e is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
// The file is held by another installer — that does not get in the way of the update
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,480 @@
|
||||
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||
|
||||
<!-- ======================= GroupBox ======================= -->
|
||||
|
||||
<Style TargetType="GroupBox">
|
||||
<Setter Property="Padding" Value="12" />
|
||||
<Setter Property="Margin" Value="0,0,0,12" />
|
||||
<Setter Property="Foreground" Value="{DynamicResource Theme.Foreground}" />
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="GroupBox">
|
||||
<Grid>
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="*" />
|
||||
</Grid.RowDefinitions>
|
||||
<ContentPresenter ContentSource="Header"
|
||||
Margin="2,0,0,6"
|
||||
TextBlock.FontWeight="SemiBold" />
|
||||
<Border Grid.Row="1"
|
||||
Background="{DynamicResource Theme.Surface}"
|
||||
BorderBrush="{DynamicResource Theme.ControlBorder}"
|
||||
BorderThickness="1"
|
||||
CornerRadius="6"
|
||||
Padding="{TemplateBinding Padding}"
|
||||
SnapsToDevicePixels="True">
|
||||
<ContentPresenter />
|
||||
</Border>
|
||||
</Grid>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<!-- ======================= Button ======================= -->
|
||||
|
||||
<Style TargetType="Button">
|
||||
<Setter Property="Foreground" Value="{DynamicResource Theme.Foreground}" />
|
||||
<Setter Property="Padding" Value="12,5" />
|
||||
<Setter Property="MinHeight" Value="28" />
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="Button">
|
||||
<Border x:Name="Border"
|
||||
Background="{DynamicResource Theme.ControlBackground}"
|
||||
BorderBrush="{DynamicResource Theme.ControlBorder}"
|
||||
BorderThickness="1"
|
||||
CornerRadius="4"
|
||||
Padding="{TemplateBinding Padding}"
|
||||
SnapsToDevicePixels="True">
|
||||
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center" />
|
||||
</Border>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsMouseOver" Value="True">
|
||||
<Setter TargetName="Border" Property="Background"
|
||||
Value="{DynamicResource Theme.ControlHoverBackground}" />
|
||||
</Trigger>
|
||||
<Trigger Property="IsKeyboardFocused" Value="True">
|
||||
<Setter TargetName="Border" Property="BorderBrush"
|
||||
Value="{DynamicResource Theme.Accent}" />
|
||||
</Trigger>
|
||||
<Trigger Property="IsPressed" Value="True">
|
||||
<Setter TargetName="Border" Property="Background"
|
||||
Value="{DynamicResource Theme.SelectionBackground}" />
|
||||
<Setter TargetName="Border" Property="BorderBrush"
|
||||
Value="{DynamicResource Theme.Accent}" />
|
||||
</Trigger>
|
||||
<Trigger Property="IsEnabled" Value="False">
|
||||
<Setter Property="Opacity" Value="0.5" />
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<!-- ======================= ComboBox ======================= -->
|
||||
|
||||
<Style x:Key="ComboBoxToggleStyle" TargetType="ToggleButton">
|
||||
<Setter Property="OverridesDefaultStyle" Value="True" />
|
||||
<Setter Property="Focusable" Value="False" />
|
||||
<Setter Property="IsTabStop" Value="False" />
|
||||
<Setter Property="ClickMode" Value="Press" />
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="ToggleButton">
|
||||
<Border x:Name="Border"
|
||||
Background="{DynamicResource Theme.ControlBackground}"
|
||||
BorderBrush="{DynamicResource Theme.ControlBorder}"
|
||||
BorderThickness="1"
|
||||
CornerRadius="4"
|
||||
SnapsToDevicePixels="True">
|
||||
<Path x:Name="Arrow"
|
||||
HorizontalAlignment="Right" VerticalAlignment="Center"
|
||||
Margin="0,0,10,0"
|
||||
Data="M0,0 L4,4 L8,0"
|
||||
Stroke="{DynamicResource Theme.SecondaryForeground}"
|
||||
StrokeThickness="1.4" />
|
||||
</Border>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsMouseOver" Value="True">
|
||||
<Setter TargetName="Border" Property="Background"
|
||||
Value="{DynamicResource Theme.ControlHoverBackground}" />
|
||||
</Trigger>
|
||||
<Trigger Property="IsChecked" Value="True">
|
||||
<Setter TargetName="Border" Property="BorderBrush"
|
||||
Value="{DynamicResource Theme.Accent}" />
|
||||
<Setter TargetName="Arrow" Property="Stroke"
|
||||
Value="{DynamicResource Theme.Accent}" />
|
||||
</Trigger>
|
||||
<Trigger Property="IsEnabled" Value="False">
|
||||
<Setter TargetName="Border" Property="Opacity" Value="0.5" />
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<Style TargetType="ComboBoxItem">
|
||||
<Setter Property="Foreground" Value="{DynamicResource Theme.Foreground}" />
|
||||
<Setter Property="Padding" Value="8,5" />
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="ComboBoxItem">
|
||||
<Border x:Name="Border"
|
||||
Background="Transparent"
|
||||
CornerRadius="3"
|
||||
Margin="3,1"
|
||||
Padding="{TemplateBinding Padding}">
|
||||
<ContentPresenter />
|
||||
</Border>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsHighlighted" Value="True">
|
||||
<Setter TargetName="Border" Property="Background"
|
||||
Value="{DynamicResource Theme.SelectionBackground}" />
|
||||
</Trigger>
|
||||
<Trigger Property="IsSelected" Value="True">
|
||||
<Setter TargetName="Border" Property="Background"
|
||||
Value="{DynamicResource Theme.SelectionBackground}" />
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<Style TargetType="ComboBox">
|
||||
<Setter Property="VerticalAlignment" Value="Center" />
|
||||
<Setter Property="Foreground" Value="{DynamicResource Theme.Foreground}" />
|
||||
<!-- Right padding leaves space for the arrow -->
|
||||
<Setter Property="Padding" Value="9,5,28,5" />
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="ComboBox">
|
||||
<Grid>
|
||||
<ToggleButton Style="{StaticResource ComboBoxToggleStyle}"
|
||||
IsChecked="{Binding IsDropDownOpen, Mode=TwoWay,
|
||||
RelativeSource={RelativeSource TemplatedParent}}" />
|
||||
<ContentPresenter Margin="{TemplateBinding Padding}"
|
||||
Content="{TemplateBinding SelectionBoxItem}"
|
||||
ContentTemplate="{TemplateBinding SelectionBoxItemTemplate}"
|
||||
ContentStringFormat="{TemplateBinding SelectionBoxItemStringFormat}"
|
||||
HorizontalAlignment="Left" VerticalAlignment="Center"
|
||||
IsHitTestVisible="False" />
|
||||
<Popup x:Name="PART_Popup"
|
||||
Placement="Bottom"
|
||||
IsOpen="{TemplateBinding IsDropDownOpen}"
|
||||
AllowsTransparency="True"
|
||||
Focusable="False"
|
||||
PopupAnimation="Fade">
|
||||
<Border Background="{DynamicResource Theme.PopupBackground}"
|
||||
BorderBrush="{DynamicResource Theme.ControlBorder}"
|
||||
BorderThickness="1"
|
||||
CornerRadius="4"
|
||||
Margin="0,2,0,0"
|
||||
MinWidth="{Binding ActualWidth,
|
||||
RelativeSource={RelativeSource TemplatedParent}}"
|
||||
MaxHeight="{TemplateBinding MaxDropDownHeight}">
|
||||
<ScrollViewer>
|
||||
<ItemsPresenter KeyboardNavigation.DirectionalNavigation="Contained" />
|
||||
</ScrollViewer>
|
||||
</Border>
|
||||
</Popup>
|
||||
</Grid>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsEnabled" Value="False">
|
||||
<Setter Property="Opacity" Value="0.5" />
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<!-- ======================= CheckBox ======================= -->
|
||||
|
||||
<Style TargetType="CheckBox">
|
||||
<Setter Property="VerticalAlignment" Value="Center" />
|
||||
<Setter Property="Foreground" Value="{DynamicResource Theme.Foreground}" />
|
||||
<Setter Property="MinHeight" Value="24" />
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="CheckBox">
|
||||
<!-- Transparent background makes clicking on the label register as a click on the checkbox.
|
||||
The label lies in a column of limited width: long translations wrap by words
|
||||
instead of being cut off -->
|
||||
<Grid Background="Transparent">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="Auto" />
|
||||
<ColumnDefinition Width="*" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<Border x:Name="Box"
|
||||
Width="18" Height="18"
|
||||
CornerRadius="4"
|
||||
VerticalAlignment="Center"
|
||||
Background="{DynamicResource Theme.ControlBackground}"
|
||||
BorderBrush="{DynamicResource Theme.ControlBorder}"
|
||||
BorderThickness="1"
|
||||
SnapsToDevicePixels="True">
|
||||
<Path x:Name="Check"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center"
|
||||
Data="M0,4 L3.5,7.5 L9.5,0.5"
|
||||
Stroke="White"
|
||||
StrokeThickness="1.8"
|
||||
StrokeStartLineCap="Round" StrokeEndLineCap="Round"
|
||||
Visibility="Collapsed" />
|
||||
</Border>
|
||||
<ContentPresenter Grid.Column="1" Margin="8,0,0,0"
|
||||
VerticalAlignment="Center">
|
||||
<ContentPresenter.Resources>
|
||||
<Style TargetType="TextBlock">
|
||||
<Setter Property="TextWrapping" Value="Wrap" />
|
||||
</Style>
|
||||
</ContentPresenter.Resources>
|
||||
</ContentPresenter>
|
||||
</Grid>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsMouseOver" Value="True">
|
||||
<Setter TargetName="Box" Property="Background"
|
||||
Value="{DynamicResource Theme.ControlHoverBackground}" />
|
||||
</Trigger>
|
||||
<Trigger Property="IsChecked" Value="True">
|
||||
<Setter TargetName="Box" Property="Background"
|
||||
Value="{DynamicResource Theme.Accent}" />
|
||||
<Setter TargetName="Box" Property="BorderBrush"
|
||||
Value="{DynamicResource Theme.Accent}" />
|
||||
<Setter TargetName="Check" Property="Visibility" Value="Visible" />
|
||||
</Trigger>
|
||||
<Trigger Property="IsEnabled" Value="False">
|
||||
<Setter Property="Opacity" Value="0.5" />
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<!-- ======================= Slider ======================= -->
|
||||
|
||||
<Style x:Key="SliderThumbStyle" TargetType="Thumb">
|
||||
<Setter Property="OverridesDefaultStyle" Value="True" />
|
||||
<Setter Property="Width" Value="14" />
|
||||
<Setter Property="Height" Value="14" />
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="Thumb">
|
||||
<Ellipse x:Name="Circle"
|
||||
Fill="{DynamicResource Theme.Accent}"
|
||||
Stroke="{DynamicResource Theme.ControlBackground}"
|
||||
StrokeThickness="2" />
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsMouseOver" Value="True">
|
||||
<Setter TargetName="Circle" Property="StrokeThickness" Value="1" />
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<!-- Completed part of the scale: filled with accent color -->
|
||||
<Style x:Key="SliderDecreaseStyle" TargetType="RepeatButton">
|
||||
<Setter Property="OverridesDefaultStyle" Value="True" />
|
||||
<Setter Property="Focusable" Value="False" />
|
||||
<Setter Property="IsTabStop" Value="False" />
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="RepeatButton">
|
||||
<Border Height="4" CornerRadius="2"
|
||||
VerticalAlignment="Center"
|
||||
Background="{DynamicResource Theme.Accent}" />
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<Style x:Key="TransparentRepeatStyle" TargetType="RepeatButton">
|
||||
<Setter Property="OverridesDefaultStyle" Value="True" />
|
||||
<Setter Property="Focusable" Value="False" />
|
||||
<Setter Property="IsTabStop" Value="False" />
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="RepeatButton">
|
||||
<Border Background="Transparent" />
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<Style TargetType="Slider">
|
||||
<Setter Property="VerticalAlignment" Value="Center" />
|
||||
<Setter Property="IsSnapToTickEnabled" Value="True" />
|
||||
<Setter Property="MinHeight" Value="24" />
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="Slider">
|
||||
<Grid Background="Transparent">
|
||||
<Border Height="4" CornerRadius="2"
|
||||
VerticalAlignment="Center"
|
||||
Background="{DynamicResource Theme.SliderTrack}" />
|
||||
<Track x:Name="PART_Track">
|
||||
<Track.DecreaseRepeatButton>
|
||||
<RepeatButton Command="Slider.DecreaseLarge"
|
||||
Style="{StaticResource SliderDecreaseStyle}" />
|
||||
</Track.DecreaseRepeatButton>
|
||||
<Track.Thumb>
|
||||
<Thumb Style="{StaticResource SliderThumbStyle}" />
|
||||
</Track.Thumb>
|
||||
<Track.IncreaseRepeatButton>
|
||||
<RepeatButton Command="Slider.IncreaseLarge"
|
||||
Style="{StaticResource TransparentRepeatStyle}" />
|
||||
</Track.IncreaseRepeatButton>
|
||||
</Track>
|
||||
</Grid>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<!-- ======================= ProgressBar ======================= -->
|
||||
|
||||
<Style TargetType="ProgressBar">
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="ProgressBar">
|
||||
<Border Background="{DynamicResource Theme.SliderTrack}"
|
||||
CornerRadius="2"
|
||||
SnapsToDevicePixels="True">
|
||||
<Grid x:Name="PART_Track" ClipToBounds="True">
|
||||
<Border x:Name="PART_Indicator"
|
||||
HorizontalAlignment="Left"
|
||||
Background="{DynamicResource Theme.Accent}"
|
||||
CornerRadius="2"
|
||||
RenderTransformOrigin="0,0">
|
||||
<Border.RenderTransform>
|
||||
<ScaleTransform ScaleX="1" />
|
||||
</Border.RenderTransform>
|
||||
</Border>
|
||||
</Grid>
|
||||
</Border>
|
||||
<ControlTemplate.Triggers>
|
||||
<!-- The share downloaded is unknown: the bar fills over and over instead -->
|
||||
<Trigger Property="IsIndeterminate" Value="True">
|
||||
<Trigger.EnterActions>
|
||||
<BeginStoryboard x:Name="Running">
|
||||
<Storyboard>
|
||||
<DoubleAnimation
|
||||
Storyboard.TargetName="PART_Indicator"
|
||||
Storyboard.TargetProperty="(UIElement.RenderTransform).(ScaleTransform.ScaleX)"
|
||||
From="0" To="1"
|
||||
Duration="0:0:1.2"
|
||||
RepeatBehavior="Forever" />
|
||||
</Storyboard>
|
||||
</BeginStoryboard>
|
||||
</Trigger.EnterActions>
|
||||
<Trigger.ExitActions>
|
||||
<StopStoryboard BeginStoryboardName="Running" />
|
||||
</Trigger.ExitActions>
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<!-- ======================= Hyperlink ======================= -->
|
||||
|
||||
<!-- The underline is left for the pointer: a line under every link is noise -->
|
||||
<Style TargetType="Hyperlink">
|
||||
<Setter Property="Foreground" Value="{DynamicResource Theme.Accent}" />
|
||||
<Setter Property="TextDecorations" Value="{x:Null}" />
|
||||
<Style.Triggers>
|
||||
<Trigger Property="IsMouseOver" Value="True">
|
||||
<Setter Property="TextDecorations" Value="Underline" />
|
||||
</Trigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
|
||||
<!-- ======================= ScrollBar ======================= -->
|
||||
|
||||
<Style x:Key="ScrollBarThumbStyle" TargetType="Thumb">
|
||||
<Setter Property="OverridesDefaultStyle" Value="True" />
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="Thumb">
|
||||
<Border Background="{DynamicResource Theme.ScrollBarThumb}"
|
||||
CornerRadius="3" Margin="3" />
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<Style TargetType="ScrollBar">
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="Width" Value="12" />
|
||||
<Setter Property="MinWidth" Value="12" />
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="ScrollBar">
|
||||
<Grid Background="{TemplateBinding Background}">
|
||||
<Track x:Name="PART_Track"
|
||||
Orientation="{TemplateBinding Orientation}"
|
||||
IsDirectionReversed="True">
|
||||
<Track.DecreaseRepeatButton>
|
||||
<RepeatButton Command="ScrollBar.PageUpCommand"
|
||||
Style="{StaticResource TransparentRepeatStyle}" />
|
||||
</Track.DecreaseRepeatButton>
|
||||
<Track.Thumb>
|
||||
<Thumb Style="{StaticResource ScrollBarThumbStyle}" />
|
||||
</Track.Thumb>
|
||||
<Track.IncreaseRepeatButton>
|
||||
<RepeatButton Command="ScrollBar.PageDownCommand"
|
||||
Style="{StaticResource TransparentRepeatStyle}" />
|
||||
</Track.IncreaseRepeatButton>
|
||||
</Track>
|
||||
</Grid>
|
||||
<ControlTemplate.Triggers>
|
||||
<!-- For the horizontal bar, the track direction is normal -->
|
||||
<Trigger Property="Orientation" Value="Horizontal">
|
||||
<Setter TargetName="PART_Track" Property="IsDirectionReversed" Value="False" />
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
<Style.Triggers>
|
||||
<Trigger Property="Orientation" Value="Horizontal">
|
||||
<Setter Property="Width" Value="Auto" />
|
||||
<Setter Property="MinWidth" Value="0" />
|
||||
<Setter Property="Height" Value="12" />
|
||||
<Setter Property="MinHeight" Value="12" />
|
||||
</Trigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
|
||||
<!-- ======================= ToolTip ======================= -->
|
||||
|
||||
<Style TargetType="ToolTip">
|
||||
<Setter Property="Foreground" Value="{DynamicResource Theme.Foreground}" />
|
||||
<Setter Property="MaxWidth" Value="360" />
|
||||
<Setter Property="HasDropShadow" Value="False" />
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="ToolTip">
|
||||
<Border Background="{DynamicResource Theme.PopupBackground}"
|
||||
BorderBrush="{DynamicResource Theme.ControlBorder}"
|
||||
BorderThickness="1"
|
||||
CornerRadius="4"
|
||||
Padding="10,6"
|
||||
SnapsToDevicePixels="True">
|
||||
<ContentPresenter />
|
||||
</Border>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
</ResourceDictionary>
|
||||
@@ -0,0 +1,21 @@
|
||||
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||
|
||||
<SolidColorBrush x:Key="Theme.WindowBackground" Color="#1E1E21" />
|
||||
<SolidColorBrush x:Key="Theme.Foreground" Color="#E8E8EA" />
|
||||
<SolidColorBrush x:Key="Theme.SecondaryForeground" Color="#9A9AA2" />
|
||||
|
||||
<SolidColorBrush x:Key="Theme.Surface" Color="#26262A" />
|
||||
<SolidColorBrush x:Key="Theme.SurfaceStrong" Color="#303036" />
|
||||
|
||||
<SolidColorBrush x:Key="Theme.ControlBackground" Color="#2C2C31" />
|
||||
<SolidColorBrush x:Key="Theme.ControlHoverBackground" Color="#38383E" />
|
||||
<SolidColorBrush x:Key="Theme.ControlBorder" Color="#46464C" />
|
||||
<SolidColorBrush x:Key="Theme.PopupBackground" Color="#2A2A2F" />
|
||||
<SolidColorBrush x:Key="Theme.SelectionBackground" Color="#3A4665" />
|
||||
|
||||
<SolidColorBrush x:Key="Theme.Accent" Color="#5B8CF7" />
|
||||
<SolidColorBrush x:Key="Theme.SliderTrack" Color="#46464C" />
|
||||
<SolidColorBrush x:Key="Theme.ScrollBarThumb" Color="#55555C" />
|
||||
|
||||
</ResourceDictionary>
|
||||
@@ -0,0 +1,21 @@
|
||||
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||
|
||||
<SolidColorBrush x:Key="Theme.WindowBackground" Color="#FFFFFF" />
|
||||
<SolidColorBrush x:Key="Theme.Foreground" Color="#1B1B1F" />
|
||||
<SolidColorBrush x:Key="Theme.SecondaryForeground" Color="#6B6B70" />
|
||||
|
||||
<SolidColorBrush x:Key="Theme.Surface" Color="#FAFAFB" />
|
||||
<SolidColorBrush x:Key="Theme.SurfaceStrong" Color="#EFEFF1" />
|
||||
|
||||
<SolidColorBrush x:Key="Theme.ControlBackground" Color="#FFFFFF" />
|
||||
<SolidColorBrush x:Key="Theme.ControlHoverBackground" Color="#F2F2F4" />
|
||||
<SolidColorBrush x:Key="Theme.ControlBorder" Color="#D0D0D4" />
|
||||
<SolidColorBrush x:Key="Theme.PopupBackground" Color="#FFFFFF" />
|
||||
<SolidColorBrush x:Key="Theme.SelectionBackground" Color="#E4EBFB" />
|
||||
|
||||
<SolidColorBrush x:Key="Theme.Accent" Color="#2563EB" />
|
||||
<SolidColorBrush x:Key="Theme.SliderTrack" Color="#DCDCE0" />
|
||||
<SolidColorBrush x:Key="Theme.ScrollBarThumb" Color="#C2C2C7" />
|
||||
|
||||
</ResourceDictionary>
|
||||
@@ -4,8 +4,8 @@ using CursorLang.Models;
|
||||
namespace CursorLang.ViewModels;
|
||||
|
||||
/// <summary>
|
||||
/// Содержимое подсказки у курсора. Внешний вид берётся прямо из настроек,
|
||||
/// поэтому их правка применяется без перезапуска.
|
||||
/// The content of the popup at the cursor. The look is taken straight from the
|
||||
/// settings, so editing them applies without a restart.
|
||||
/// </summary>
|
||||
public sealed partial class LayoutPopupViewModel : ObservableObject
|
||||
{
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user