Compare commits
57
Commits
3ab110aa91
..
v1.1.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
743974a0c1 | ||
|
|
9c6ec489e1 | ||
|
|
b2c7b31eb5 | ||
|
|
5670ad0e64 | ||
|
|
1540be3b79 | ||
|
|
88dd931cb4 | ||
|
|
70b83a5fec | ||
|
|
0682f2b875 | ||
|
|
5dea510bbc | ||
|
|
03a3dd4c6e | ||
|
|
42974ecc8f | ||
|
|
5e11b16758 | ||
|
|
68b3d544d2 | ||
|
|
55ac8e6556 | ||
|
|
a0d3098fe4 | ||
|
|
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 | ||
|
|
5ecc46f7a3 | ||
|
|
ba0748920b | ||
|
|
240ffe7687 | ||
|
|
1fdfe77d78 | ||
|
|
d146fd442a | ||
|
|
3d614255d8 | ||
|
|
a1ff8cc923 |
+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 coverage.runsettings
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
# 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 package goes to the Store and nowhere else, so it leaves the run as an
|
||||||
|
# artifact: someone picks it up and uploads it to Partner Center, which puts its
|
||||||
|
# own signature on it. Nothing is signed here and nothing is attached to the
|
||||||
|
# release — a publicly trusted code signing certificate is not to be had, and an
|
||||||
|
# unsigned package would look like something to install and install nowhere.
|
||||||
|
# Gitea makes the release for the tag itself, and it carries the tag alone.
|
||||||
|
#
|
||||||
|
# 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
|
||||||
|
|
||||||
|
# The installer answers to nobody about a fourth number and takes the
|
||||||
|
# tag as it is
|
||||||
|
"plain=$($tag.Substring(1))" | 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 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
|
||||||
|
|
||||||
|
# The other half of the release: the same application as an ordinary
|
||||||
|
# installer, for handing round outside the Store. Nobody signs it, so
|
||||||
|
# SmartScreen warns about it — see Packaging\installer.iss
|
||||||
|
- name: Build the installer
|
||||||
|
run: ./Packaging/build-installer.ps1 -Version ${{ steps.version.outputs.plain }}
|
||||||
|
|
||||||
|
# The artifact is where the package waits to be uploaded to Partner Center.
|
||||||
|
#
|
||||||
|
# The action comes from this Gitea rather than from github.com, and it is
|
||||||
|
# a fork of actions/upload-artifact with one line changed. Upstream reads
|
||||||
|
# GITHUB_SERVER_URL, takes every host but github.com and its enterprise
|
||||||
|
# ones for a GitHub Enterprise Server — where the v4 artifact backend does
|
||||||
|
# not exist — and throws before it makes a request. Here that backend does
|
||||||
|
# exist and answers, but the check runs on the runner ahead of the network
|
||||||
|
# and never lets it prove itself; nor can the name be corrected from this
|
||||||
|
# file, as the runner sets the GITHUB_ ones over the env of a step. The
|
||||||
|
# fork carries the reasoning in full in its GITEA-PATCH.md.
|
||||||
|
#
|
||||||
|
# The tag is a fixed one. A floating tag is how this broke before: the
|
||||||
|
# action is fetched at run time, and what arrives can change on its own
|
||||||
|
- name: Keep the package
|
||||||
|
uses: https://git.alrakis.kz/actions/upload-artifact@v4-gitea
|
||||||
|
with:
|
||||||
|
name: msix-${{ steps.version.outputs.version }}
|
||||||
|
path: artifacts/packages/
|
||||||
|
if-no-files-found: error
|
||||||
|
|
||||||
|
# The .wixpdb next to each installer is left out on purpose: it is of use
|
||||||
|
# only when something has to be traced back to the WiX source
|
||||||
|
- name: Keep the installer
|
||||||
|
uses: https://git.alrakis.kz/actions/upload-artifact@v4-gitea
|
||||||
|
with:
|
||||||
|
name: installer-${{ steps.version.outputs.plain }}
|
||||||
|
path: artifacts/installers/*.msi
|
||||||
|
if-no-files-found: error
|
||||||
|
|
||||||
|
# Only the installers go into the release. The MSIX stays in the artifacts
|
||||||
|
# of the run: unsigned, it installs nowhere, and its one destination is
|
||||||
|
# Partner Center
|
||||||
|
- 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
|
||||||
|
$api = "$env:GITHUB_API_URL/repos/$env:GITHUB_REPOSITORY/releases"
|
||||||
|
$headers = @{ Authorization = "token $env:GITEA_TOKEN" }
|
||||||
|
|
||||||
|
# Gitea makes a release of its own for a pushed tag, so the release is
|
||||||
|
# looked up first and only made when it is not there
|
||||||
|
$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/installers -File -Filter *.msi) {
|
||||||
|
# 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,43 @@
|
|||||||
|
<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>
|
||||||
|
<OutputType>Exe</OutputType>
|
||||||
|
<PlatformTarget>AnyCPU</PlatformTarget>
|
||||||
|
<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
|
||||||
|
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||||
|
<NoWarn>$(NoWarn);CS1591</NoWarn>
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
<IsTestProject>true</IsTestProject>
|
||||||
|
<RootNamespace>CursorLang.Agent.Tests</RootNamespace>
|
||||||
|
<AssemblyName>CursorLang.Agent.Tests</AssemblyName>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Using Include="Xunit"/>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<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.Core\CursorLang.Core.csproj"/>
|
||||||
|
<ProjectReference Include="..\CursorLang.Tests.Shared\CursorLang.Tests.Shared.csproj"/>
|
||||||
|
<ProjectReference Include="..\CursorLang.Agent\CursorLang.Agent.csproj"/>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<AssemblyAttribute Include="System.Reflection.AssemblyMetadataAttribute">
|
||||||
|
<_Parameter1>CursorLangExecutable</_Parameter1>
|
||||||
|
<_Parameter2>$(MSBuildThisFileDirectory)..\CursorLang.Agent\bin\$(Configuration)\$(TargetFramework)\CursorLang.exe</_Parameter2>
|
||||||
|
</AssemblyAttribute>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,317 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
|
using System.Reflection;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using CursorLang.Core.Services;
|
||||||
|
|
||||||
|
namespace CursorLang.Agent.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The application as a whole: the agent starting, the settings window it opens, the
|
||||||
|
/// single instance and the exit.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// None of this can be built inside the tests — the agent installs a system hook 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 partial class EndToEndTests
|
||||||
|
{
|
||||||
|
private static readonly TimeSpan StartTimeout = TimeSpan.FromSeconds(30);
|
||||||
|
private static readonly TimeSpan ExitTimeout = TimeSpan.FromSeconds(15);
|
||||||
|
|
||||||
|
/// <summary>How long "the application went on working" is worth watching for.</summary>
|
||||||
|
private static readonly TimeSpan StayTimeout = TimeSpan.FromSeconds(3);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The whole point of the background process, as a test rather than as a promise.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The agent must not load the WPF rendering stack. It is checked on the running
|
||||||
|
/// process, not on its references: a reference costs nothing, a load costs the
|
||||||
|
/// hundred megabytes the split exists to avoid.
|
||||||
|
///
|
||||||
|
/// UI Automation and the assemblies behind it — WindowsBase and PresentationCore —
|
||||||
|
/// are deliberately not in the pattern: the caret fallback pulls them in on purpose
|
||||||
|
/// and only when it runs. What must never appear is the renderer itself.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public void The_agent_runs_without_the_rendering_stack()
|
||||||
|
{
|
||||||
|
using Launch launch = Launch.Start(StartupLaunch.Argument);
|
||||||
|
|
||||||
|
Assert.False(launch.Process.WaitForExit(StayTimeout), "the agent ended by itself");
|
||||||
|
launch.Process.Refresh();
|
||||||
|
|
||||||
|
var loaded = launch.Process.Modules
|
||||||
|
.Cast<ProcessModule>()
|
||||||
|
.Select(module => module.ModuleName)
|
||||||
|
.Where(name => RenderingStack().IsMatch(name))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
Assert.True(loaded.Count == 0, $"the agent loaded {string.Join(", ", loaded)}");
|
||||||
|
}
|
||||||
|
|
||||||
|
[GeneratedRegex("PresentationFramework|wpfgfx|milcore|PresentationNative", RegexOptions.IgnoreCase)]
|
||||||
|
private static partial Regex RenderingStack();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Started by the user, the application shows the settings window — which lives in
|
||||||
|
/// a process of its own and is started by the agent.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void A_launch_by_the_user_opens_the_settings_window()
|
||||||
|
{
|
||||||
|
using Launch launch = Launch.Start();
|
||||||
|
|
||||||
|
Assert.NotEqual(IntPtr.Zero, launch.WaitForSettingsWindow());
|
||||||
|
Assert.False(launch.Process.HasExited);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Started by Windows itself, the application goes straight to the tray: the
|
||||||
|
/// user asked for it to be there, not for a window to greet them.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void A_launch_by_Windows_shows_no_window()
|
||||||
|
{
|
||||||
|
using Launch launch = Launch.Start(StartupLaunch.Argument);
|
||||||
|
|
||||||
|
// Nothing is expected to appear, so the wait is for the whole time
|
||||||
|
Assert.False(launch.Process.WaitForExit(StayTimeout), "the agent ended by itself");
|
||||||
|
Assert.Empty(Launch.SettingsProcesses());
|
||||||
|
}
|
||||||
|
|
||||||
|
// A second run raises no second agent but asks the running one for the window
|
||||||
|
[Fact]
|
||||||
|
public void The_second_run_ends_by_itself()
|
||||||
|
{
|
||||||
|
using Launch launch = Launch.Start(StartupLaunch.Argument);
|
||||||
|
Assert.False(launch.Process.WaitForExit(StayTimeout), "the agent ended by itself");
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Closing the settings window ends that process and leaves the agent alone.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// This is the behaviour the split was for. The window used to hide itself into the
|
||||||
|
/// tray, because closing it would have thrown away a visual tree the background half
|
||||||
|
/// was still using; now there is nothing shared to throw away, and the memory the
|
||||||
|
/// window took goes back to the system.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public void Closing_the_settings_window_leaves_the_agent_running()
|
||||||
|
{
|
||||||
|
using Launch launch = Launch.Start();
|
||||||
|
launch.WaitForSettingsWindow();
|
||||||
|
|
||||||
|
Process settings = Launch.SettingsProcesses().Single();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Assert.True(settings.CloseMainWindow(), "the window did not accept the request to close");
|
||||||
|
Assert.True(settings.WaitForExit(ExitTimeout), "the settings window outlived its own closing");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
settings.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert.False(launch.Process.HasExited);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// "Exit" in the tray menu ends the application whole: the settings window goes with
|
||||||
|
/// the agent instead of staying on the screen belonging to nothing.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The menu itself is out of reach of a test — it is a <c>TrackPopupMenuEx</c> menu
|
||||||
|
/// with a modal loop of its own — so what is checked is the request the menu makes.
|
||||||
|
/// The settings window here is the real one, started by the agent, and it must be
|
||||||
|
/// listening by the time it is on the screen.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public void The_agents_exit_closes_the_settings_window()
|
||||||
|
{
|
||||||
|
using Launch launch = Launch.Start();
|
||||||
|
launch.WaitForSettingsWindow();
|
||||||
|
|
||||||
|
Process settings = Launch.SettingsProcesses().Single();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Assert.True(SettingsCloseSignal.RequestClose(), "the settings window was not listening");
|
||||||
|
Assert.True(settings.WaitForExit(ExitTimeout), "the settings window outlived the agent's exit");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
settings.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <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 agent first — making sure the place is free.</summary>
|
||||||
|
internal static Launch Start(params string[] arguments)
|
||||||
|
{
|
||||||
|
if (!HasInteractiveDesktop())
|
||||||
|
{
|
||||||
|
Assert.Skip("There is no interactive desktop here — the application has nowhere to show its window");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Process.GetProcessesByName("CursorLang").Length > 0 || SettingsProcesses().Length > 0)
|
||||||
|
{
|
||||||
|
Assert.Skip("The application is already running — this check keeps out of someone else's run");
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Launch(StartProcess(arguments));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Starts the agent the way the user — or Windows — does.</summary>
|
||||||
|
internal static Process StartProcess(params string[] arguments)
|
||||||
|
{
|
||||||
|
string path = ExecutablePath();
|
||||||
|
|
||||||
|
if (!File.Exists(path))
|
||||||
|
{
|
||||||
|
Assert.Skip($"The application is not built: {path}");
|
||||||
|
}
|
||||||
|
|
||||||
|
var start = new ProcessStartInfo(path) { UseShellExecute = true };
|
||||||
|
|
||||||
|
foreach (string argument in arguments)
|
||||||
|
{
|
||||||
|
start.ArgumentList.Add(argument);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Process.Start(start)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The settings window processes running right now, if any.</summary>
|
||||||
|
internal static Process[] SettingsProcesses() => Process.GetProcessesByName("CursorLang.Settings");
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Waits for the settings window. It belongs to another process now, so the
|
||||||
|
/// wait is for that process to appear and put a window on the screen.
|
||||||
|
/// </summary>
|
||||||
|
internal IntPtr WaitForSettingsWindow()
|
||||||
|
{
|
||||||
|
DateTime deadline = DateTime.UtcNow + StartTimeout;
|
||||||
|
|
||||||
|
while (DateTime.UtcNow < deadline)
|
||||||
|
{
|
||||||
|
Process.Refresh();
|
||||||
|
|
||||||
|
if (Process.HasExited)
|
||||||
|
{
|
||||||
|
Assert.Fail($"The agent exited while starting with code {Process.ExitCode}");
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (Process settings in SettingsProcesses())
|
||||||
|
{
|
||||||
|
settings.Refresh();
|
||||||
|
IntPtr window = settings.MainWindowHandle;
|
||||||
|
settings.Dispose();
|
||||||
|
|
||||||
|
if (window != IntPtr.Zero)
|
||||||
|
{
|
||||||
|
return window;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
{
|
||||||
|
foreach (Process settings in SettingsProcesses())
|
||||||
|
{
|
||||||
|
Kill(settings);
|
||||||
|
}
|
||||||
|
|
||||||
|
Kill(Process);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The exit lives in a tray menu no test can reach, and the settings are saved
|
||||||
|
// as they change, so nothing is lost by ending the processes outright
|
||||||
|
private static void Kill(Process process)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!process.HasExited)
|
||||||
|
{
|
||||||
|
process.Kill(entireProcessTree: true);
|
||||||
|
process.WaitForExit(ExitTimeout);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException)
|
||||||
|
{
|
||||||
|
// The process has already ended on its own
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
process.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,305 @@
|
|||||||
|
using System.Collections.Concurrent;
|
||||||
|
using CursorLang.Agent.Services;
|
||||||
|
using CursorLang.Core.Models;
|
||||||
|
using CursorLang.Tests.Shared;
|
||||||
|
|
||||||
|
namespace CursorLang.Agent.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(Pump.Run(() => harness.Service.HandleKeyEvent(CapsLock, isKeyDown: true)));
|
||||||
|
Assert.True(Pump.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(Pump.Run(() => harness.Service.HandleKeyEvent(LetterA, isKeyDown: true)));
|
||||||
|
Assert.False(Pump.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();
|
||||||
|
|
||||||
|
Pump.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();
|
||||||
|
|
||||||
|
Pump.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();
|
||||||
|
Pump.WaitFor(() => harness.Events.Contains("hold-start"), "the hold threshold was passed");
|
||||||
|
|
||||||
|
harness.Release();
|
||||||
|
|
||||||
|
Pump.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++)
|
||||||
|
{
|
||||||
|
Pump.Pause(TimeSpan.FromMilliseconds(10));
|
||||||
|
harness.Press();
|
||||||
|
}
|
||||||
|
|
||||||
|
Pump.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();
|
||||||
|
|
||||||
|
Pump.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();
|
||||||
|
Pump.WaitFor(() => harness.Events.Contains("hold-start"), "the hold threshold was passed");
|
||||||
|
|
||||||
|
Pump.Run(harness.Service.Stop);
|
||||||
|
|
||||||
|
Pump.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();
|
||||||
|
Pump.Run(harness.Service.Stop);
|
||||||
|
Pump.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();
|
||||||
|
Pump.Run(harness.Service.Stop);
|
||||||
|
|
||||||
|
Pump.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();
|
||||||
|
Pump.Run(harness.Service.Stop);
|
||||||
|
|
||||||
|
harness.Press();
|
||||||
|
harness.Release();
|
||||||
|
|
||||||
|
Pump.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();
|
||||||
|
Pump.WaitFor(() => harness.Events.Contains("hold-start"), "the hold threshold was passed");
|
||||||
|
|
||||||
|
Pump.Run(harness.Service.Dispose);
|
||||||
|
Pump.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();
|
||||||
|
Pump.WaitFor(() => harness.Events.Count == 1, "the first press counted as short");
|
||||||
|
|
||||||
|
harness.Settings.CapsLockHoldMilliseconds = 20;
|
||||||
|
harness.Press();
|
||||||
|
|
||||||
|
Pump.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);
|
||||||
|
Pump.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);
|
||||||
|
|
||||||
|
Pump.Run(() =>
|
||||||
|
{
|
||||||
|
harness.Service.Start();
|
||||||
|
harness.Service.Dispose();
|
||||||
|
|
||||||
|
Assert.False(harness.Service.IsRunning);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A hold is never announced for a key that has already been let go.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The countdown started by the press can still be delivered just after the
|
||||||
|
/// release: Windows does not withdraw a WM_TIMER it has already posted. Taken at
|
||||||
|
/// face value it turned a tap into a hold — the popup came up showing the layout
|
||||||
|
/// the tap was about to change away from, and the switch followed it.
|
||||||
|
///
|
||||||
|
/// The tick is driven straight in here rather than waited for, because the point is
|
||||||
|
/// the one ordering a real clock will not reproduce on demand.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public void A_hold_is_not_announced_after_the_key_has_been_released()
|
||||||
|
{
|
||||||
|
using var harness = Harness.Create(holdMilliseconds: 10_000);
|
||||||
|
|
||||||
|
harness.Press();
|
||||||
|
harness.Release();
|
||||||
|
|
||||||
|
harness.ForceHoldTick();
|
||||||
|
Pump.Drain();
|
||||||
|
|
||||||
|
Assert.Equal(["tap"], harness.Events);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <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 };
|
||||||
|
|
||||||
|
// Events reach their subscribers the way they do in the agent: posted back
|
||||||
|
// to the message loop, after the hook procedure has returned
|
||||||
|
CapsLockHotkeyService service =
|
||||||
|
Pump.Run(() => new CapsLockHotkeyService(settings, Pump.Post));
|
||||||
|
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() => Pump.Run(() => Service.HandleKeyEvent(CapsLock, isKeyDown: true));
|
||||||
|
|
||||||
|
internal void Release() => Pump.Run(() => Service.HandleKeyEvent(CapsLock, isKeyDown: false));
|
||||||
|
|
||||||
|
/// <summary>Delivers the hold countdown by hand, the way a late WM_TIMER does.</summary>
|
||||||
|
internal void ForceHoldTick() => Pump.Run(Service.HandleHoldElapsed);
|
||||||
|
|
||||||
|
public void Dispose() => Pump.Run(Service.Dispose);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
using CursorLang.Agent.Services;
|
||||||
|
using CursorLang.Core.Models;
|
||||||
|
using CursorLang.Tests.Shared;
|
||||||
|
|
||||||
|
namespace CursorLang.Agent.Tests.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The lifetime of the tooltip. Its timer ticks on the message loop,
|
||||||
|
/// so everything happens on the pump thread 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 };
|
||||||
|
|
||||||
|
Pump.Run(() =>
|
||||||
|
{
|
||||||
|
using var service = new LayoutPopupService(window, settings);
|
||||||
|
service.Show(Russian);
|
||||||
|
});
|
||||||
|
|
||||||
|
Assert.Equal("RU", window.ShownText);
|
||||||
|
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 };
|
||||||
|
|
||||||
|
Pump.Run(() =>
|
||||||
|
{
|
||||||
|
using var service = new LayoutPopupService(window, settings);
|
||||||
|
service.Show(Russian);
|
||||||
|
|
||||||
|
Assert.Equal(0, window.HideCalls);
|
||||||
|
Pump.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 };
|
||||||
|
|
||||||
|
Pump.Run(() =>
|
||||||
|
{
|
||||||
|
using var service = new LayoutPopupService(window, settings);
|
||||||
|
service.Show(Russian);
|
||||||
|
|
||||||
|
settings.DurationMilliseconds = 30;
|
||||||
|
service.Show(English);
|
||||||
|
|
||||||
|
Pump.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 };
|
||||||
|
|
||||||
|
Pump.Run(() =>
|
||||||
|
{
|
||||||
|
using var service = new LayoutPopupService(window, settings);
|
||||||
|
|
||||||
|
for (int i = 0; i < 5; i++)
|
||||||
|
{
|
||||||
|
service.Show(i % 2 == 0 ? Russian : English);
|
||||||
|
Pump.Pause(TimeSpan.FromMilliseconds(20));
|
||||||
|
Assert.Equal(0, window.HideCalls);
|
||||||
|
}
|
||||||
|
|
||||||
|
Pump.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 };
|
||||||
|
|
||||||
|
Pump.Run(() =>
|
||||||
|
{
|
||||||
|
using var service = new LayoutPopupService(window, settings);
|
||||||
|
service.ShowUntilHidden(Russian);
|
||||||
|
|
||||||
|
Pump.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 };
|
||||||
|
|
||||||
|
Pump.Run(() =>
|
||||||
|
{
|
||||||
|
using var service = new LayoutPopupService(window, settings);
|
||||||
|
service.Show(Russian);
|
||||||
|
service.ShowUntilHidden(English);
|
||||||
|
|
||||||
|
Pump.Pause(TimeSpan.FromMilliseconds(100));
|
||||||
|
|
||||||
|
Assert.Equal(0, window.HideCalls);
|
||||||
|
Assert.Equal("EN", window.ShownText);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Hiding_cancels_a_running_countdown()
|
||||||
|
{
|
||||||
|
var window = new FakeLayoutPopupWindow();
|
||||||
|
var settings = new AppSettings { DurationMilliseconds = 30 };
|
||||||
|
|
||||||
|
Pump.Run(() =>
|
||||||
|
{
|
||||||
|
using var service = new LayoutPopupService(window, settings);
|
||||||
|
service.Show(Russian);
|
||||||
|
service.Hide();
|
||||||
|
|
||||||
|
Pump.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();
|
||||||
|
|
||||||
|
Pump.Run(() =>
|
||||||
|
{
|
||||||
|
var service = new LayoutPopupService(window, settings);
|
||||||
|
service.Show(Russian);
|
||||||
|
service.Dispose();
|
||||||
|
|
||||||
|
Pump.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 };
|
||||||
|
|
||||||
|
Pump.Run(() =>
|
||||||
|
{
|
||||||
|
var service = new LayoutPopupService(window, settings);
|
||||||
|
service.Show(Russian);
|
||||||
|
service.Dispose();
|
||||||
|
|
||||||
|
Pump.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 };
|
||||||
|
|
||||||
|
Pump.Run(() =>
|
||||||
|
{
|
||||||
|
using var service = new LayoutPopupService(window, settings);
|
||||||
|
service.Show(Russian);
|
||||||
|
|
||||||
|
Pump.WaitFor(() => window.HideCalls == 1, "the tooltip hid itself");
|
||||||
|
|
||||||
|
Assert.Equal(["show", "hide"], window.Calls);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
using CursorLang.Agent.Windows;
|
||||||
|
using CursorLang.Core.Services;
|
||||||
|
using CursorLang.Tests.Shared;
|
||||||
|
|
||||||
|
namespace CursorLang.Agent.Tests.Windows;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The one thing the settings window says to the agent.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Both halves of it live apart — the message and the window class name in Core, the
|
||||||
|
/// window that answers in the agent — and nothing but a matching pair makes it work.
|
||||||
|
/// A renamed class or a renamed message would leave the agent showing yesterday's
|
||||||
|
/// settings until it is restarted, and nothing else would complain.
|
||||||
|
///
|
||||||
|
/// The agent's own window is used rather than a stand-in: what is being checked is
|
||||||
|
/// that the window the agent really creates is the one the message reaches.
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class SettingsSignalTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void The_signal_reaches_the_agents_window()
|
||||||
|
{
|
||||||
|
var delivered = 0;
|
||||||
|
|
||||||
|
using AgentWindow window = Pump.Run(() =>
|
||||||
|
{
|
||||||
|
var created = new AgentWindow();
|
||||||
|
created.AddFilter((message, _, _) =>
|
||||||
|
{
|
||||||
|
if (message != SettingsSignal.Message)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
delivered++;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
return created;
|
||||||
|
});
|
||||||
|
|
||||||
|
Pump.Run(SettingsSignal.NotifyAgent);
|
||||||
|
Pump.Drain();
|
||||||
|
|
||||||
|
Assert.Equal(1, delivered);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nobody is listening, and that is a normal state of affairs: the settings window
|
||||||
|
// works perfectly well with no agent behind it
|
||||||
|
[Fact]
|
||||||
|
public void Signalling_with_no_agent_running_passes_without_consequence()
|
||||||
|
{
|
||||||
|
Pump.Run(SettingsSignal.NotifyAgent);
|
||||||
|
Pump.Drain();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
using System.ComponentModel;
|
||||||
|
using CursorLang.Agent.Services;
|
||||||
|
using CursorLang.Agent.Windows;
|
||||||
|
using CursorLang.Core.Models;
|
||||||
|
using CursorLang.Core.Services;
|
||||||
|
using CursorLang.Core.Threading;
|
||||||
|
|
||||||
|
namespace CursorLang.Agent;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The background half of CursorLang: the hook, the layout polling, the popup and the
|
||||||
|
/// tray icon, with a Win32 message loop underneath and no WPF anywhere.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The services are wired by hand rather than through a container, and that is a
|
||||||
|
/// decision rather than an omission: the whole point of this process is how little it
|
||||||
|
/// weighs, and a container is a megabyte of assembly and a graph of reflection on the
|
||||||
|
/// way to the same object. There are ten of them and they are all listed here.
|
||||||
|
/// </remarks>
|
||||||
|
internal sealed class Agent : IDisposable
|
||||||
|
{
|
||||||
|
private readonly SingleInstanceGate _gate;
|
||||||
|
private readonly AgentWindow _window;
|
||||||
|
private readonly SettingsService _settingsService;
|
||||||
|
private readonly AppSettings _settings;
|
||||||
|
private readonly LocalizationService _localization;
|
||||||
|
private readonly NativePopupWindow _popupWindow;
|
||||||
|
private readonly LayoutPopupService _popupService;
|
||||||
|
private readonly KeyboardLayoutService _layoutService;
|
||||||
|
private readonly CapsLockHotkeyService _hotkeyService;
|
||||||
|
private readonly LayoutNotificationCoordinator _notifications;
|
||||||
|
private readonly CapsLockSwitchCoordinator _capsLock;
|
||||||
|
private readonly NativeTrayIcon _tray;
|
||||||
|
|
||||||
|
internal Agent(SingleInstanceGate gate)
|
||||||
|
{
|
||||||
|
_gate = gate;
|
||||||
|
|
||||||
|
_window = new AgentWindow();
|
||||||
|
_window.AddFilter(OnWindowMessage);
|
||||||
|
|
||||||
|
_settingsService = new SettingsService();
|
||||||
|
_settings = _settingsService.Load();
|
||||||
|
|
||||||
|
_localization = new LocalizationService { CurrentLanguage = _settings.Language };
|
||||||
|
_settings.PropertyChanged += OnSettingsChanged;
|
||||||
|
|
||||||
|
_popupWindow = new NativePopupWindow(_settings);
|
||||||
|
_popupService = new LayoutPopupService(_popupWindow, _settings);
|
||||||
|
|
||||||
|
_layoutService = new KeyboardLayoutService(new KeyboardLayoutOptions());
|
||||||
|
_hotkeyService = new CapsLockHotkeyService(_settings, _window.Post);
|
||||||
|
|
||||||
|
_notifications = new LayoutNotificationCoordinator(_layoutService, _popupService);
|
||||||
|
_capsLock = new CapsLockSwitchCoordinator(_hotkeyService, _layoutService, _popupService, _settings);
|
||||||
|
|
||||||
|
_tray = new NativeTrayIcon(_window, _localization);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Starts everything and pumps messages until the user asks to quit.</summary>
|
||||||
|
internal int Run(bool automatic)
|
||||||
|
{
|
||||||
|
_gate.ActivationRequested += OnActivationRequested;
|
||||||
|
|
||||||
|
_tray.OpenRequested += OnOpenRequested;
|
||||||
|
_tray.ExitRequested += OnExitRequested;
|
||||||
|
|
||||||
|
bool hasTray = _tray.Install();
|
||||||
|
|
||||||
|
_notifications.Start();
|
||||||
|
_capsLock.Start();
|
||||||
|
|
||||||
|
if (!automatic || !hasTray)
|
||||||
|
{
|
||||||
|
SettingsLauncher.Open();
|
||||||
|
}
|
||||||
|
|
||||||
|
return MessageLoop.Run();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_gate.ActivationRequested -= OnActivationRequested;
|
||||||
|
|
||||||
|
_tray.OpenRequested -= OnOpenRequested;
|
||||||
|
_tray.ExitRequested -= OnExitRequested;
|
||||||
|
|
||||||
|
_settings.PropertyChanged -= OnSettingsChanged;
|
||||||
|
|
||||||
|
_capsLock.Dispose();
|
||||||
|
_notifications.Dispose();
|
||||||
|
_hotkeyService.Dispose();
|
||||||
|
_layoutService.Dispose();
|
||||||
|
_popupService.Dispose();
|
||||||
|
_settingsService.Dispose();
|
||||||
|
_tray.Dispose();
|
||||||
|
_window.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
// The settings window has written the file and says so. The write was a single
|
||||||
|
// atomic move, so there is nothing to wait for and nothing half-written to read
|
||||||
|
private bool OnWindowMessage(uint message, IntPtr wParam, IntPtr lParam)
|
||||||
|
{
|
||||||
|
if (message != SettingsSignal.Message)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
_settingsService.Reload();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnSettingsChanged(object? sender, PropertyChangedEventArgs e)
|
||||||
|
{
|
||||||
|
if (e.PropertyName == nameof(AppSettings.Language))
|
||||||
|
{
|
||||||
|
_localization.CurrentLanguage = _settings.Language;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A second launch of the agent, from the Start menu for instance. The one already
|
||||||
|
// running answers the way the user expects a second launch to be answered
|
||||||
|
private void OnActivationRequested(object? sender, EventArgs e) =>
|
||||||
|
_window.Post(() => SettingsLauncher.Open());
|
||||||
|
|
||||||
|
private void OnOpenRequested(object? sender, EventArgs e) => SettingsLauncher.Open();
|
||||||
|
|
||||||
|
// "Exit" means the application, not just the background half of it. A settings
|
||||||
|
// window left open would outlive the tray icon it was opened from, so it is asked
|
||||||
|
// to close first — it may be the very window the user is looking at
|
||||||
|
private void OnExitRequested(object? sender, EventArgs e)
|
||||||
|
{
|
||||||
|
SettingsCloseSignal.RequestClose();
|
||||||
|
_window.Quit();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<OutputType>WinExe</OutputType>
|
||||||
|
<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>
|
||||||
|
<RootNamespace>CursorLang.Agent</RootNamespace>
|
||||||
|
<AssemblyName>CursorLang</AssemblyName>
|
||||||
|
<ApplicationManifest>app.manifest</ApplicationManifest>
|
||||||
|
<ApplicationIcon>..\CursorLang.Core\Resources\CursorLang.ico</ApplicationIcon>
|
||||||
|
<PlatformTarget>AnyCPU</PlatformTarget>
|
||||||
|
<RuntimeIdentifiers>win-x64</RuntimeIdentifiers>
|
||||||
|
<SelfContained Condition="'$(RuntimeIdentifier)' != ''">true</SelfContained>
|
||||||
|
<PublishTrimmed>false</PublishTrimmed>
|
||||||
|
<PublishReadyToRun Condition="'$(RuntimeIdentifier)' != ''">true</PublishReadyToRun>
|
||||||
|
<Version>1.0.0</Version>
|
||||||
|
<AssemblyVersion>1.0.0.0</AssemblyVersion>
|
||||||
|
<FileVersion>1.0.0.0</FileVersion>
|
||||||
|
<Product>CursorLang</Product>
|
||||||
|
<Company>Aleksandr Neichev</Company>
|
||||||
|
<Description>Shows the keyboard layout at the cursor</Description>
|
||||||
|
<Copyright>Copyright (c) 2026</Copyright>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\CursorLang.Core\CursorLang.Core.csproj"/>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\CursorLang.Settings\CursorLang.Settings.csproj"
|
||||||
|
ReferenceOutputAssembly="false"
|
||||||
|
Private="false"/>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<Target Name="PlaceTheSettingsWindowBesideTheAgent" AfterTargets="Build">
|
||||||
|
<ItemGroup>
|
||||||
|
<SettingsOutput Include="..\CursorLang.Settings\bin\$(Configuration)\$(TargetFramework)\**\*"/>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<Copy SourceFiles="@(SettingsOutput)"
|
||||||
|
DestinationFolder="$(OutDir)%(RecursiveDir)"
|
||||||
|
SkipUnchangedFiles="true"/>
|
||||||
|
</Target>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<InternalsVisibleTo Include="CursorLang.Agent.Tests"/>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
using System.Drawing;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
using CursorLang.Core.Interop;
|
||||||
|
|
||||||
|
namespace CursorLang.Agent.Interop;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Plain GDI: a font, text, and an off-screen bitmap to draw them into.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// GDI rather than GDI+ on purpose. <c>System.Drawing.Common</c> would make the drawing
|
||||||
|
/// code shorter, but it is a separate assembly with a native GDI+ library behind it, and
|
||||||
|
/// how little this process weighs is the entire reason it exists apart from the window.
|
||||||
|
/// Everything the popup needs — one rounded rectangle and one line of text — GDI can do
|
||||||
|
/// on its own, and it draws the text with ClearType, exactly as Windows does everywhere else.
|
||||||
|
/// </remarks>
|
||||||
|
internal static class GdiNative
|
||||||
|
{
|
||||||
|
internal const int TRANSPARENT = 1;
|
||||||
|
|
||||||
|
internal const uint DT_SINGLELINE = 0x00000020;
|
||||||
|
internal const uint DT_CENTER = 0x00000001;
|
||||||
|
internal const uint DT_VCENTER = 0x00000004;
|
||||||
|
internal const uint DT_CALCRECT = 0x00000400;
|
||||||
|
internal const uint DT_NOPREFIX = 0x00000800;
|
||||||
|
internal const uint DT_NOCLIP = 0x00000100;
|
||||||
|
|
||||||
|
private const int DEFAULT_CHARSET = 1;
|
||||||
|
private const int OUT_TT_PRECIS = 4;
|
||||||
|
private const int CLIP_DEFAULT_PRECIS = 0;
|
||||||
|
private const int CLEARTYPE_QUALITY = 5;
|
||||||
|
private const int DEFAULT_PITCH = 0;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The face the popup is written in.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// WPF is asked for "Segoe UI" at FontWeight SemiBold and resolves that to the
|
||||||
|
/// seguisb.ttf face. To GDI that face is a family of its own — "Segoe UI Semibold" —
|
||||||
|
/// and asking for the "Segoe UI" family at weight 600 lands on Bold instead, which
|
||||||
|
/// is visibly heavier. So the family is named outright and the weight is left to the
|
||||||
|
/// mapper: the family has one member and no synthetic emboldening happens.
|
||||||
|
/// </remarks>
|
||||||
|
private const string SemiBoldFace = "Segoe UI Semibold";
|
||||||
|
|
||||||
|
private const int FW_DONTCARE = 0;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A font of the given size in physical pixels.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The size in the settings is in WPF units, that is 1/96 inch, while GDI counts
|
||||||
|
/// pixels — hence the multiplication by the monitor scale. The height is negative:
|
||||||
|
/// that asks for the em size rather than the cell height, which is what a font size
|
||||||
|
/// means everywhere else.
|
||||||
|
/// </remarks>
|
||||||
|
internal static IntPtr CreateFont(double wpfFontSize, double scale)
|
||||||
|
{
|
||||||
|
var height = (int)Math.Round(wpfFontSize * scale);
|
||||||
|
|
||||||
|
return CreateFontW(
|
||||||
|
-height, 0, 0, 0, FW_DONTCARE,
|
||||||
|
false, false, false,
|
||||||
|
DEFAULT_CHARSET, OUT_TT_PRECIS, CLIP_DEFAULT_PRECIS, CLEARTYPE_QUALITY, DEFAULT_PITCH,
|
||||||
|
SemiBoldFace);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The size of a single line of text with the font selected into the context.</summary>
|
||||||
|
internal static Size MeasureText(IntPtr deviceContext, string text)
|
||||||
|
{
|
||||||
|
var bounds = default(PopupWindowNative.Rect);
|
||||||
|
DrawText(deviceContext, text, text.Length, ref bounds,
|
||||||
|
DT_CALCRECT | DT_SINGLELINE | DT_NOPREFIX);
|
||||||
|
|
||||||
|
return new Size(bounds.Right - bounds.Left, bounds.Bottom - bounds.Top);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A colour as GDI wants it: 0x00BBGGRR, the alpha carried elsewhere.</summary>
|
||||||
|
internal static uint ToColorRef(Color color) =>
|
||||||
|
(uint)(color.R | (color.G << 8) | (color.B << 16));
|
||||||
|
|
||||||
|
[DllImport("gdi32.dll", CharSet = CharSet.Unicode, EntryPoint = "CreateFontW")]
|
||||||
|
private static extern IntPtr CreateFontW(int cHeight, int cWidth, int cEscapement, int cOrientation,
|
||||||
|
int cWeight, bool bItalic, bool bUnderline, bool bStrikeOut,
|
||||||
|
int iCharSet, int iOutPrecision, int iClipPrecision, int iQuality, int iPitchAndFamily,
|
||||||
|
string pszFaceName);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A 32-bit surface to draw the popup into before anyone can see it.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Top-down — a negative height — so that the first row of <paramref name="bits"/>
|
||||||
|
/// is the top row of the picture and the alpha fixing up afterwards can walk the
|
||||||
|
/// memory straight through.
|
||||||
|
/// </remarks>
|
||||||
|
internal static IntPtr CreateSurface(IntPtr deviceContext, int width, int height, out IntPtr bits)
|
||||||
|
{
|
||||||
|
var header = new BitmapInfoHeader
|
||||||
|
{
|
||||||
|
biSize = Marshal.SizeOf<BitmapInfoHeader>(),
|
||||||
|
biWidth = width,
|
||||||
|
biHeight = -height,
|
||||||
|
biPlanes = 1,
|
||||||
|
biBitCount = 32,
|
||||||
|
biCompression = BI_RGB,
|
||||||
|
};
|
||||||
|
|
||||||
|
return CreateDIBSection(deviceContext, ref header, DIB_RGB_COLORS, out bits, IntPtr.Zero, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private const uint BI_RGB = 0;
|
||||||
|
private const uint DIB_RGB_COLORS = 0;
|
||||||
|
|
||||||
|
[DllImport("gdi32.dll")]
|
||||||
|
internal static extern IntPtr CreateCompatibleDC(IntPtr hdc);
|
||||||
|
|
||||||
|
[DllImport("gdi32.dll")]
|
||||||
|
internal static extern bool DeleteDC(IntPtr hdc);
|
||||||
|
|
||||||
|
[DllImport("gdi32.dll")]
|
||||||
|
private static extern IntPtr CreateDIBSection(IntPtr hdc, ref BitmapInfoHeader header, uint usage,
|
||||||
|
out IntPtr bits, IntPtr section, uint offset);
|
||||||
|
|
||||||
|
[DllImport("gdi32.dll")]
|
||||||
|
internal static extern IntPtr SelectObject(IntPtr hdc, IntPtr h);
|
||||||
|
|
||||||
|
[DllImport("gdi32.dll")]
|
||||||
|
internal static extern bool DeleteObject(IntPtr ho);
|
||||||
|
|
||||||
|
[DllImport("gdi32.dll")]
|
||||||
|
internal static extern int SetBkMode(IntPtr hdc, int mode);
|
||||||
|
|
||||||
|
[DllImport("gdi32.dll")]
|
||||||
|
internal static extern uint SetTextColor(IntPtr hdc, uint color);
|
||||||
|
|
||||||
|
[DllImport("user32.dll", CharSet = CharSet.Unicode, EntryPoint = "DrawTextW")]
|
||||||
|
internal static extern int DrawText(IntPtr hdc, string lpchText, int cchText,
|
||||||
|
ref PopupWindowNative.Rect lprc, uint format);
|
||||||
|
|
||||||
|
[DllImport("user32.dll")]
|
||||||
|
internal static extern IntPtr GetDC(IntPtr hWnd);
|
||||||
|
|
||||||
|
[DllImport("user32.dll")]
|
||||||
|
internal static extern int ReleaseDC(IntPtr hWnd, IntPtr hDC);
|
||||||
|
|
||||||
|
[StructLayout(LayoutKind.Sequential)]
|
||||||
|
private struct BitmapInfoHeader
|
||||||
|
{
|
||||||
|
public int biSize;
|
||||||
|
public int biWidth;
|
||||||
|
public int biHeight;
|
||||||
|
public short biPlanes;
|
||||||
|
public short biBitCount;
|
||||||
|
public uint biCompression;
|
||||||
|
public uint biSizeImage;
|
||||||
|
public int biXPelsPerMeter;
|
||||||
|
public int biYPelsPerMeter;
|
||||||
|
public uint biClrUsed;
|
||||||
|
public uint biClrImportant;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
using System.Runtime.InteropServices;
|
||||||
|
using CursorLang.Core.Interop;
|
||||||
|
|
||||||
|
namespace CursorLang.Agent.Interop;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The system context menu — the one the tray icon raises.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// A menu built this way is drawn by Windows, so the theme and the language chosen in
|
||||||
|
/// the application no longer reach it. That is the accepted price of leaving WPF: a WPF
|
||||||
|
/// <c>ContextMenu</c> costs the whole rendering stack in the background process.
|
||||||
|
/// </remarks>
|
||||||
|
internal static class MenuNative
|
||||||
|
{
|
||||||
|
private const uint MF_STRING = 0x00000000;
|
||||||
|
private const uint MF_SEPARATOR = 0x00000800;
|
||||||
|
private const uint MF_GRAYED = 0x00000001;
|
||||||
|
|
||||||
|
private const uint TPM_LEFTALIGN = 0x0000;
|
||||||
|
private const uint TPM_RIGHTBUTTON = 0x0002;
|
||||||
|
private const uint TPM_RETURNCMD = 0x0100;
|
||||||
|
|
||||||
|
/// <summary>An item of the menu being built.</summary>
|
||||||
|
/// <param name="Id">What <see cref="Track"/> gives back when the item is chosen.</param>
|
||||||
|
/// <param name="Caption">The text, or <c>null</c> for a separator.</param>
|
||||||
|
/// <param name="IsEnabled">A greyed item is shown but cannot be chosen.</param>
|
||||||
|
internal readonly record struct Item(int Id, string? Caption, bool IsEnabled = true)
|
||||||
|
{
|
||||||
|
internal static Item Separator => new(0, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Raises the menu at a screen point and returns the identifier of the chosen item,
|
||||||
|
/// or zero when the user dismissed it.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <c>TPM_RETURNCMD</c> means the answer comes back from the call itself instead of
|
||||||
|
/// as a <c>WM_COMMAND</c> later, which keeps the whole menu in one place. The call
|
||||||
|
/// does not return until the user is done with the menu — that is how a modal menu
|
||||||
|
/// works, and the message loop keeps running inside it.
|
||||||
|
///
|
||||||
|
/// The window is brought to the foreground first and poked with an empty message
|
||||||
|
/// afterwards: without the first the menu never closes on a click elsewhere, and
|
||||||
|
/// without the second it stays on screen after the choice is made. Both are
|
||||||
|
/// long-standing quirks of a menu owned by a window the user cannot see.
|
||||||
|
/// </remarks>
|
||||||
|
internal static int Track(IntPtr owner, PopupWindowNative.Point at, IReadOnlyList<Item> items)
|
||||||
|
{
|
||||||
|
IntPtr menu = CreatePopupMenu();
|
||||||
|
if (menu == IntPtr.Zero)
|
||||||
|
{
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
foreach (Item item in items)
|
||||||
|
{
|
||||||
|
if (item.Caption is null)
|
||||||
|
{
|
||||||
|
AppendMenu(menu, MF_SEPARATOR, IntPtr.Zero, null);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
uint flags = MF_STRING | (item.IsEnabled ? 0 : MF_GRAYED);
|
||||||
|
AppendMenu(menu, flags, new IntPtr(item.Id), item.Caption);
|
||||||
|
}
|
||||||
|
|
||||||
|
TrayIconNative.BringToForeground(owner);
|
||||||
|
|
||||||
|
int chosen = TrackPopupMenuEx(
|
||||||
|
menu, TPM_LEFTALIGN | TPM_RIGHTBUTTON | TPM_RETURNCMD,
|
||||||
|
at.X, at.Y, owner, IntPtr.Zero);
|
||||||
|
|
||||||
|
WindowNative.PostMessage(owner, WindowNative.WM_NULL, IntPtr.Zero, IntPtr.Zero);
|
||||||
|
|
||||||
|
return chosen;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
DestroyMenu(menu);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[DllImport("user32.dll")]
|
||||||
|
private static extern IntPtr CreatePopupMenu();
|
||||||
|
|
||||||
|
[DllImport("user32.dll")]
|
||||||
|
private static extern bool DestroyMenu(IntPtr hMenu);
|
||||||
|
|
||||||
|
[DllImport("user32.dll", CharSet = CharSet.Unicode, EntryPoint = "AppendMenuW")]
|
||||||
|
private static extern bool AppendMenu(IntPtr hMenu, uint uFlags, IntPtr uIDNewItem, string? lpNewItem);
|
||||||
|
|
||||||
|
[DllImport("user32.dll")]
|
||||||
|
private static extern int TrackPopupMenuEx(IntPtr hMenu, uint uFlags, int x, int y,
|
||||||
|
IntPtr hwnd, IntPtr lptpm);
|
||||||
|
}
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
using System.Runtime.InteropServices;
|
||||||
|
using CursorLang.Core.Interop;
|
||||||
|
|
||||||
|
namespace CursorLang.Agent.Interop;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Win32 API for the notification area: the icon itself, the messages it sends
|
||||||
|
/// and the icon image taken from the executable.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The icon is asked for at version 4 of the protocol. It is the only version that
|
||||||
|
/// reports a request for the context menu as such — by the keyboard as well as by
|
||||||
|
/// the right button — and passes the point of the click along with it. The
|
||||||
|
/// notification then arrives in the low word of <c>lParam</c>, and the point in
|
||||||
|
/// <c>wParam</c>, which is the opposite of the earlier versions.
|
||||||
|
/// </remarks>
|
||||||
|
internal static class TrayIconNative
|
||||||
|
{
|
||||||
|
/// <summary>The message the icon sends to its window. WM_APP is free for the app.</summary>
|
||||||
|
internal const int CallbackMessage = 0x8000 + 1;
|
||||||
|
|
||||||
|
/// <summary>The user chose the icon: a click of the left button or Enter on it.</summary>
|
||||||
|
internal const int SelectNotification = 0x0400;
|
||||||
|
|
||||||
|
/// <summary>The same by the space bar — Windows tells the two apart.</summary>
|
||||||
|
internal const int KeySelectNotification = 0x0403;
|
||||||
|
|
||||||
|
/// <summary>The context menu is asked for: the right button or the menu key.</summary>
|
||||||
|
internal const int ContextMenuNotification = 0x007B;
|
||||||
|
|
||||||
|
private const int NIM_ADD = 0x00000000;
|
||||||
|
private const int NIM_DELETE = 0x00000002;
|
||||||
|
private const int NIM_SETVERSION = 0x00000004;
|
||||||
|
|
||||||
|
private const uint NIF_MESSAGE = 0x00000001;
|
||||||
|
private const uint NIF_ICON = 0x00000002;
|
||||||
|
private const uint NIF_TIP = 0x00000004;
|
||||||
|
private const uint NIF_SHOWTIP = 0x00000080;
|
||||||
|
|
||||||
|
private const uint NotifyIconVersion4 = 4;
|
||||||
|
|
||||||
|
private const uint IMAGE_ICON = 1;
|
||||||
|
private const uint LR_DEFAULTCOLOR = 0x00000000;
|
||||||
|
|
||||||
|
private const int SM_CXSMICON = 49;
|
||||||
|
private const int SM_CYSMICON = 50;
|
||||||
|
|
||||||
|
/// <summary>The resource the .NET build puts the application icon under.</summary>
|
||||||
|
private const int ApplicationIconResource = 32512;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Explorer says it has restarted this way. The icons of every application are
|
||||||
|
/// gone by then and have to be put back.
|
||||||
|
/// </summary>
|
||||||
|
internal static int TaskbarCreatedMessage { get; } = RegisterWindowMessage("TaskbarCreated");
|
||||||
|
|
||||||
|
/// <summary>Puts the icon into the notification area.</summary>
|
||||||
|
internal static bool Add(IntPtr window, int id, IntPtr icon, string tooltip)
|
||||||
|
{
|
||||||
|
NotifyIconData data = Describe(window, id, icon, tooltip);
|
||||||
|
|
||||||
|
if (!Shell_NotifyIcon(NIM_ADD, ref data))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The version is asked for after the icon is added and applies to it alone
|
||||||
|
data.uVersion = NotifyIconVersion4;
|
||||||
|
Shell_NotifyIcon(NIM_SETVERSION, ref data);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Takes the icon away. A forgotten icon stays in the tray until hovered.</summary>
|
||||||
|
internal static void Remove(IntPtr window, int id)
|
||||||
|
{
|
||||||
|
var data = new NotifyIconData
|
||||||
|
{
|
||||||
|
cbSize = Marshal.SizeOf<NotifyIconData>(),
|
||||||
|
hWnd = window,
|
||||||
|
uID = (uint)id,
|
||||||
|
};
|
||||||
|
|
||||||
|
Shell_NotifyIcon(NIM_DELETE, ref data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The icon of the application at the size the tray asks for.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The image comes from the executable itself, so the tray shows what the user
|
||||||
|
/// sees in Explorer. The build puts the icon under the standard resource; should
|
||||||
|
/// it end up elsewhere, the first icon of the file is taken, and failing that —
|
||||||
|
/// the icon Windows gives to an application without one. An icon is needed
|
||||||
|
/// either way: without it the tray shows an empty spot.
|
||||||
|
/// </remarks>
|
||||||
|
internal static IntPtr LoadApplicationIcon()
|
||||||
|
{
|
||||||
|
int width = GetSystemMetrics(SM_CXSMICON);
|
||||||
|
int height = GetSystemMetrics(SM_CYSMICON);
|
||||||
|
|
||||||
|
IntPtr icon = LoadImage(
|
||||||
|
GetModuleHandle(null), ApplicationIconResource, IMAGE_ICON, width, height, LR_DEFAULTCOLOR);
|
||||||
|
|
||||||
|
if (icon == IntPtr.Zero && Environment.ProcessPath is { Length: > 0 } path)
|
||||||
|
{
|
||||||
|
icon = ExtractIconEx(path, 0, out IntPtr large, out IntPtr small, 1) > 0 ? small : IntPtr.Zero;
|
||||||
|
|
||||||
|
if (large != IntPtr.Zero)
|
||||||
|
{
|
||||||
|
DestroyIcon(large);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return icon != IntPtr.Zero ? icon : LoadIcon(IntPtr.Zero, ApplicationIconResource);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Releases an icon loaded by <see cref="LoadApplicationIcon"/>.</summary>
|
||||||
|
internal static void ReleaseIcon(IntPtr icon)
|
||||||
|
{
|
||||||
|
if (icon != IntPtr.Zero)
|
||||||
|
{
|
||||||
|
DestroyIcon(icon);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The notification the icon has sent: it sits in the low word of lParam.</summary>
|
||||||
|
internal static int NotificationOf(IntPtr lParam) => (int)(lParam.ToInt64() & 0xFFFF);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The point of the click. Version 4 of the protocol reports it in screen pixels
|
||||||
|
/// in <c>wParam</c> — exactly what <c>TrackPopupMenuEx</c> expects.
|
||||||
|
/// </summary>
|
||||||
|
internal static PopupWindowNative.Point PointOf(IntPtr wParam) => new()
|
||||||
|
{
|
||||||
|
X = (short)(wParam.ToInt64() & 0xFFFF),
|
||||||
|
Y = (short)((wParam.ToInt64() >> 16) & 0xFFFF),
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Brings the window to the foreground.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Windows takes a menu down when its owner window loses the foreground. A tray
|
||||||
|
/// icon belongs to a window that is never shown, so the foreground has to be
|
||||||
|
/// asked for by hand — otherwise the menu stays on screen after the user has
|
||||||
|
/// clicked past it.
|
||||||
|
/// </remarks>
|
||||||
|
internal static void BringToForeground(IntPtr window) => SetForegroundWindow(window);
|
||||||
|
|
||||||
|
private static NotifyIconData Describe(IntPtr window, int id, IntPtr icon, string tooltip) => new()
|
||||||
|
{
|
||||||
|
cbSize = Marshal.SizeOf<NotifyIconData>(),
|
||||||
|
hWnd = window,
|
||||||
|
uID = (uint)id,
|
||||||
|
uFlags = NIF_MESSAGE | NIF_ICON | NIF_TIP | NIF_SHOWTIP,
|
||||||
|
uCallbackMessage = CallbackMessage,
|
||||||
|
hIcon = icon,
|
||||||
|
szTip = tooltip,
|
||||||
|
};
|
||||||
|
|
||||||
|
[DllImport("shell32.dll", CharSet = CharSet.Unicode)]
|
||||||
|
private static extern bool Shell_NotifyIcon(int dwMessage, ref NotifyIconData lpData);
|
||||||
|
|
||||||
|
[DllImport("user32.dll", CharSet = CharSet.Unicode)]
|
||||||
|
private static extern int RegisterWindowMessage(string lpString);
|
||||||
|
|
||||||
|
[DllImport("user32.dll")]
|
||||||
|
private static extern IntPtr LoadImage(IntPtr hInst, IntPtr name, uint type, int cx, int cy, uint fuLoad);
|
||||||
|
|
||||||
|
private static IntPtr LoadImage(IntPtr hInst, int resource, uint type, int cx, int cy, uint fuLoad) =>
|
||||||
|
LoadImage(hInst, new IntPtr(resource), type, cx, cy, fuLoad);
|
||||||
|
|
||||||
|
[DllImport("user32.dll")]
|
||||||
|
private static extern IntPtr LoadIcon(IntPtr hInstance, IntPtr lpIconName);
|
||||||
|
|
||||||
|
private static IntPtr LoadIcon(IntPtr hInstance, int resource) =>
|
||||||
|
LoadIcon(hInstance, new IntPtr(resource));
|
||||||
|
|
||||||
|
[DllImport("shell32.dll", CharSet = CharSet.Unicode)]
|
||||||
|
private static extern int ExtractIconEx(string lpszFile, int nIconIndex,
|
||||||
|
out IntPtr phiconLarge, out IntPtr phiconSmall, int nIcons);
|
||||||
|
|
||||||
|
[DllImport("user32.dll")]
|
||||||
|
private static extern bool DestroyIcon(IntPtr hIcon);
|
||||||
|
|
||||||
|
[DllImport("user32.dll")]
|
||||||
|
private static extern int GetSystemMetrics(int nIndex);
|
||||||
|
|
||||||
|
[DllImport("user32.dll")]
|
||||||
|
private static extern bool SetForegroundWindow(IntPtr hWnd);
|
||||||
|
|
||||||
|
[DllImport("kernel32.dll", CharSet = CharSet.Unicode)]
|
||||||
|
private static extern IntPtr GetModuleHandle(string? lpModuleName);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// NOTIFYICONDATAW. The whole structure is described even though only its first
|
||||||
|
/// half is used: Windows reads its size and refuses one it does not know.
|
||||||
|
/// </summary>
|
||||||
|
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
|
||||||
|
private struct NotifyIconData
|
||||||
|
{
|
||||||
|
public int cbSize;
|
||||||
|
public IntPtr hWnd;
|
||||||
|
public uint uID;
|
||||||
|
public uint uFlags;
|
||||||
|
public int uCallbackMessage;
|
||||||
|
public IntPtr hIcon;
|
||||||
|
|
||||||
|
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 128)]
|
||||||
|
public string szTip;
|
||||||
|
|
||||||
|
public uint dwState;
|
||||||
|
public uint dwStateMask;
|
||||||
|
|
||||||
|
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 256)]
|
||||||
|
public string szInfo;
|
||||||
|
|
||||||
|
/// <summary>A timeout in the older versions and the protocol version here.</summary>
|
||||||
|
public uint uVersion;
|
||||||
|
|
||||||
|
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 64)]
|
||||||
|
public string szInfoTitle;
|
||||||
|
|
||||||
|
public uint dwInfoFlags;
|
||||||
|
public Guid guidItem;
|
||||||
|
public IntPtr hBalloonIcon;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
using System.Runtime.InteropServices;
|
||||||
|
using CursorLang.Core.Interop;
|
||||||
|
|
||||||
|
namespace CursorLang.Agent.Interop;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The Win32 pieces a window needs when there is no framework to make one:
|
||||||
|
/// the class, the window itself, the message loop.
|
||||||
|
/// </summary>
|
||||||
|
internal static class WindowNative
|
||||||
|
{
|
||||||
|
/// <summary>The window procedure. Windows keeps the only reference to it.</summary>
|
||||||
|
internal delegate IntPtr WindowProc(IntPtr hWnd, uint message, IntPtr wParam, IntPtr lParam);
|
||||||
|
|
||||||
|
internal const int WS_POPUP = unchecked((int)0x80000000);
|
||||||
|
|
||||||
|
internal const int WS_EX_LAYERED = 0x00080000;
|
||||||
|
internal const int WS_EX_TOOLWINDOW = 0x00000080;
|
||||||
|
internal const int WS_EX_NOACTIVATE = 0x08000000;
|
||||||
|
internal const int WS_EX_TRANSPARENT = 0x00000020;
|
||||||
|
internal const int WS_EX_TOPMOST = 0x00000008;
|
||||||
|
|
||||||
|
internal const int SW_HIDE = 0;
|
||||||
|
internal const int SW_SHOWNOACTIVATE = 4;
|
||||||
|
|
||||||
|
internal const uint WM_DESTROY = 0x0002;
|
||||||
|
internal const uint WM_CLOSE = 0x0010;
|
||||||
|
internal const uint WM_QUIT = 0x0012;
|
||||||
|
internal const uint WM_NULL = 0x0000;
|
||||||
|
internal const uint WM_ENDSESSION = 0x0016;
|
||||||
|
|
||||||
|
/// <summary>WM_APP and up belong to the application; the tray takes WM_APP + 1.</summary>
|
||||||
|
internal const uint WM_APP = 0x8000;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Registers a window class. A class already there is not an error: the name is
|
||||||
|
/// unique per window kind, and a second agent in the same process would meet its
|
||||||
|
/// own registration.
|
||||||
|
/// </summary>
|
||||||
|
internal static void RegisterClass(string className, WindowProc windowProc)
|
||||||
|
{
|
||||||
|
var description = new WindowClass
|
||||||
|
{
|
||||||
|
cbSize = Marshal.SizeOf<WindowClass>(),
|
||||||
|
lpfnWndProc = windowProc,
|
||||||
|
hInstance = GetModuleHandle(null),
|
||||||
|
lpszClassName = className,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (RegisterClassEx(ref description) == 0 &&
|
||||||
|
Marshal.GetLastWin32Error() != ErrorClassAlreadyExists)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"RegisterClassExW failed for '{className}': {Marshal.GetLastWin32Error()}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private const int ErrorClassAlreadyExists = 1410;
|
||||||
|
|
||||||
|
/// <summary>Creates a window of a registered class. It is not shown.</summary>
|
||||||
|
internal static IntPtr CreateWindow(string className, string title, int style, int exStyle)
|
||||||
|
{
|
||||||
|
IntPtr window = CreateWindowEx(
|
||||||
|
exStyle, className, title, style,
|
||||||
|
0, 0, 0, 0,
|
||||||
|
IntPtr.Zero, IntPtr.Zero, GetModuleHandle(null), IntPtr.Zero);
|
||||||
|
|
||||||
|
if (window == IntPtr.Zero)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"CreateWindowExW failed for '{className}': {Marshal.GetLastWin32Error()}");
|
||||||
|
}
|
||||||
|
|
||||||
|
return window;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Puts a finished picture into a layered window, together with where it goes and
|
||||||
|
/// how see-through it is.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// One call replaces moving the window, resizing it, painting it and setting its
|
||||||
|
/// opacity, and it works while the window is still hidden. That is the point: the
|
||||||
|
/// content is ready before anyone can see the window, so it can never be shown
|
||||||
|
/// holding the picture of the previous time.
|
||||||
|
/// </remarks>
|
||||||
|
internal static bool SetContent(
|
||||||
|
IntPtr window, PopupWindowNative.Point at, Size size, IntPtr sourceDc, byte alpha)
|
||||||
|
{
|
||||||
|
var source = new PopupWindowNative.Point { X = 0, Y = 0 };
|
||||||
|
var blend = new BlendFunction
|
||||||
|
{
|
||||||
|
BlendOp = AC_SRC_OVER,
|
||||||
|
SourceConstantAlpha = alpha,
|
||||||
|
AlphaFormat = AC_SRC_ALPHA,
|
||||||
|
};
|
||||||
|
|
||||||
|
return UpdateLayeredWindow(
|
||||||
|
window, IntPtr.Zero, ref at, ref size, sourceDc, ref source, 0, ref blend, ULW_ALPHA);
|
||||||
|
}
|
||||||
|
|
||||||
|
private const byte AC_SRC_OVER = 0;
|
||||||
|
private const byte AC_SRC_ALPHA = 1;
|
||||||
|
private const uint ULW_ALPHA = 0x00000002;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// BLENDFUNCTION. <c>BlendFlags</c> is never assigned and must stay all the same:
|
||||||
|
/// Windows reads the structure by its layout, and dropping a byte from the middle
|
||||||
|
/// of it would shift everything after.
|
||||||
|
/// </summary>
|
||||||
|
[StructLayout(LayoutKind.Sequential)]
|
||||||
|
private struct BlendFunction
|
||||||
|
{
|
||||||
|
public byte BlendOp;
|
||||||
|
public byte BlendFlags;
|
||||||
|
public byte SourceConstantAlpha;
|
||||||
|
public byte AlphaFormat;
|
||||||
|
}
|
||||||
|
|
||||||
|
[StructLayout(LayoutKind.Sequential)]
|
||||||
|
internal struct Size
|
||||||
|
{
|
||||||
|
public int Width;
|
||||||
|
public int Height;
|
||||||
|
}
|
||||||
|
|
||||||
|
[DllImport("user32.dll", SetLastError = true)]
|
||||||
|
private static extern bool UpdateLayeredWindow(IntPtr hWnd, IntPtr hdcDst,
|
||||||
|
ref PopupWindowNative.Point pptDst, ref Size psize, IntPtr hdcSrc,
|
||||||
|
ref PopupWindowNative.Point pptSrc, uint crKey, ref BlendFunction pblend, uint dwFlags);
|
||||||
|
|
||||||
|
[DllImport("user32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
|
||||||
|
private static extern ushort RegisterClassEx(ref WindowClass lpwcx);
|
||||||
|
|
||||||
|
[DllImport("user32.dll", CharSet = CharSet.Unicode, SetLastError = true, EntryPoint = "CreateWindowExW")]
|
||||||
|
private static extern IntPtr CreateWindowEx(int dwExStyle, string lpClassName, string lpWindowName,
|
||||||
|
int dwStyle, int x, int y, int nWidth, int nHeight,
|
||||||
|
IntPtr hWndParent, IntPtr hMenu, IntPtr hInstance, IntPtr lpParam);
|
||||||
|
|
||||||
|
[DllImport("user32.dll", CharSet = CharSet.Unicode, EntryPoint = "DefWindowProcW")]
|
||||||
|
internal static extern IntPtr DefWindowProc(IntPtr hWnd, uint message, IntPtr wParam, IntPtr lParam);
|
||||||
|
|
||||||
|
[DllImport("user32.dll")]
|
||||||
|
internal static extern bool DestroyWindow(IntPtr hWnd);
|
||||||
|
|
||||||
|
[DllImport("user32.dll")]
|
||||||
|
internal static extern bool ShowWindow(IntPtr hWnd, int nCmdShow);
|
||||||
|
|
||||||
|
[DllImport("user32.dll")]
|
||||||
|
internal static extern bool IsWindowVisible(IntPtr hWnd);
|
||||||
|
|
||||||
|
[DllImport("user32.dll", CharSet = CharSet.Unicode, EntryPoint = "PostMessageW")]
|
||||||
|
internal static extern bool PostMessage(IntPtr hWnd, uint message, IntPtr wParam, IntPtr lParam);
|
||||||
|
|
||||||
|
[DllImport("user32.dll")]
|
||||||
|
internal static extern void PostQuitMessage(int nExitCode);
|
||||||
|
|
||||||
|
[DllImport("kernel32.dll", CharSet = CharSet.Unicode)]
|
||||||
|
private static extern IntPtr GetModuleHandle(string? lpModuleName);
|
||||||
|
|
||||||
|
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
|
||||||
|
private struct WindowClass
|
||||||
|
{
|
||||||
|
public int cbSize;
|
||||||
|
public uint style;
|
||||||
|
|
||||||
|
[MarshalAs(UnmanagedType.FunctionPtr)]
|
||||||
|
public WindowProc lpfnWndProc;
|
||||||
|
|
||||||
|
public int cbClsExtra;
|
||||||
|
public int cbWndExtra;
|
||||||
|
public IntPtr hInstance;
|
||||||
|
public IntPtr hIcon;
|
||||||
|
public IntPtr hCursor;
|
||||||
|
public IntPtr hbrBackground;
|
||||||
|
public string? lpszMenuName;
|
||||||
|
public string lpszClassName;
|
||||||
|
public IntPtr hIconSm;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
using CursorLang.Core.Services;
|
||||||
|
|
||||||
|
namespace CursorLang.Agent;
|
||||||
|
|
||||||
|
internal static class Program
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// A single-threaded apartment because of the caret: <c>AccessibleObjectFromWindow</c>
|
||||||
|
/// and UI Automation both go through COM, and both expect the thread that calls them
|
||||||
|
/// to be an STA one.
|
||||||
|
/// </summary>
|
||||||
|
[STAThread]
|
||||||
|
private static int Main(string[] arguments)
|
||||||
|
{
|
||||||
|
bool automatic = StartupLaunch.IsAutomatic(arguments);
|
||||||
|
|
||||||
|
var gate = new SingleInstanceGate(SingleInstanceGate.AgentName);
|
||||||
|
|
||||||
|
// A second launch is the user asking for the application, so the one already
|
||||||
|
// running opens the settings window and this one steps aside. A second launch
|
||||||
|
// by Windows at sign-in asks for nothing and gets nothing
|
||||||
|
if (!gate.TryAcquire(showRunningInstance: !automatic))
|
||||||
|
{
|
||||||
|
gate.Dispose();
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var agent = new Agent(gate);
|
||||||
|
return agent.Run(automatic);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
gate.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"profiles": {
|
||||||
|
"Agent": {
|
||||||
|
"commandName": "Project"
|
||||||
|
},
|
||||||
|
"Agent (started by Windows)": {
|
||||||
|
"commandName": "Project",
|
||||||
|
"commandLineArgs": "--startup"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
using CursorLang.Core.Interop;
|
||||||
|
using CursorLang.Core.Models;
|
||||||
|
using CursorLang.Core.Services;
|
||||||
|
using CursorLang.Core.Threading;
|
||||||
|
|
||||||
|
namespace CursorLang.Agent.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.
|
||||||
|
///
|
||||||
|
/// Two things changed on the way out of WPF: the hold is timed by
|
||||||
|
/// <see cref="MessageTimer"/>, and the event reaches its subscribers through a message
|
||||||
|
/// posted to the agent's window rather than through the dispatcher.
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class CapsLockHotkeyService : ICapsLockHotkeyService, IDisposable
|
||||||
|
{
|
||||||
|
private const int VirtualKeyCapsLock = 0x14;
|
||||||
|
|
||||||
|
private readonly AppSettings _settings;
|
||||||
|
private readonly Action<Action> _post;
|
||||||
|
private readonly LowLevelKeyboardHook _hook;
|
||||||
|
private readonly MessageTimer _holdTimer = new();
|
||||||
|
|
||||||
|
private bool _isPressed;
|
||||||
|
private bool _isHolding;
|
||||||
|
|
||||||
|
/// <param name="settings">Where the hold threshold is read from, on every press.</param>
|
||||||
|
/// <param name="post">
|
||||||
|
/// Hands work back to the message loop. Taken as a delegate rather than as the
|
||||||
|
/// agent's window so that the press logic can be checked without one.
|
||||||
|
/// </param>
|
||||||
|
public CapsLockHotkeyService(AppSettings settings, Action<Action> post)
|
||||||
|
{
|
||||||
|
_settings = settings;
|
||||||
|
_post = post;
|
||||||
|
_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 message loop is already gone by
|
||||||
|
// that moment and posted work would never run
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_holdTimer.Tick -= OnHoldTimerTick;
|
||||||
|
_holdTimer.Dispose();
|
||||||
|
_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) => HandleHoldElapsed();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The hold countdown has run out.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The key being down is checked rather than assumed. A countdown started on the
|
||||||
|
/// press can still be delivered just after the release — Windows does not withdraw a
|
||||||
|
/// WM_TIMER it has already posted — and announcing a hold then would put the popup on
|
||||||
|
/// screen showing the layout the tap is about to change away from.
|
||||||
|
///
|
||||||
|
/// The tests reach this directly: that ordering is the whole point and a real clock
|
||||||
|
/// will not reproduce it on demand.
|
||||||
|
/// </remarks>
|
||||||
|
internal void HandleHoldElapsed()
|
||||||
|
{
|
||||||
|
_holdTimer.Stop();
|
||||||
|
if (!_isPressed)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_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)
|
||||||
|
{
|
||||||
|
_post(() => 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,64 @@
|
|||||||
|
using CursorLang.Core.Models;
|
||||||
|
using CursorLang.Core.Services;
|
||||||
|
using CursorLang.Core.Threading;
|
||||||
|
|
||||||
|
namespace CursorLang.Agent.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>
|
||||||
|
/// <remarks>
|
||||||
|
/// The same service it always was, with <c>DispatcherTimer</c> swapped for
|
||||||
|
/// <see cref="MessageTimer"/>: both tick on the thread that owns the window, so
|
||||||
|
/// nothing else about the logic had to move.
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class LayoutPopupService : ILayoutPopupService, IDisposable
|
||||||
|
{
|
||||||
|
private readonly ILayoutPopupWindow _window;
|
||||||
|
private readonly AppSettings _settings;
|
||||||
|
private readonly MessageTimer _hideTimer = new();
|
||||||
|
|
||||||
|
public LayoutPopupService(ILayoutPopupWindow window, AppSettings settings)
|
||||||
|
{
|
||||||
|
_window = window;
|
||||||
|
_settings = settings;
|
||||||
|
|
||||||
|
_hideTimer.Tick += OnHideTimerTick;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Show(KeyboardLayout layout)
|
||||||
|
{
|
||||||
|
ShowUntilHidden(layout);
|
||||||
|
|
||||||
|
// 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();
|
||||||
|
_window.ShowPopup(layout.ShortName);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Hide()
|
||||||
|
{
|
||||||
|
_hideTimer.Stop();
|
||||||
|
_window.Hide();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_hideTimer.Tick -= OnHideTimerTick;
|
||||||
|
_hideTimer.Dispose();
|
||||||
|
_window.Close();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnHideTimerTick(object? sender, EventArgs e)
|
||||||
|
{
|
||||||
|
_hideTimer.Stop();
|
||||||
|
_window.Hide();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
using System.ComponentModel;
|
||||||
|
using System.Diagnostics;
|
||||||
|
using CursorLang.Core.Services;
|
||||||
|
|
||||||
|
namespace CursorLang.Agent.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Starts the settings window.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The agent does not keep track of whether the window is already open, and does not
|
||||||
|
/// need to: the settings process guards a single-instance slot of its own, so a second
|
||||||
|
/// launch raises the window already there and exits. That costs a process start to find
|
||||||
|
/// out, which is a fraction of the time it takes a person to look at the tray, and it
|
||||||
|
/// saves the agent from holding a handle to something it does not own.
|
||||||
|
/// </remarks>
|
||||||
|
internal static class SettingsLauncher
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Opens the settings window. Returns <c>false</c> when the executable is not
|
||||||
|
/// where it should be — a half-copied installation, or the agent run from a build
|
||||||
|
/// folder of its own.
|
||||||
|
/// </summary>
|
||||||
|
internal static bool Open()
|
||||||
|
{
|
||||||
|
if (AgentExecutable.SettingsPath is not { } path)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using Process? started = Process.Start(new ProcessStartInfo(path) { UseShellExecute = false });
|
||||||
|
return started is not null;
|
||||||
|
}
|
||||||
|
catch (Exception e) when (e is Win32Exception or InvalidOperationException)
|
||||||
|
{
|
||||||
|
// Nothing to tell the user with: the agent has no window of its own, and
|
||||||
|
// the one that would have shown the message is the one that failed to start
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
using System.Collections.Concurrent;
|
||||||
|
using CursorLang.Agent.Interop;
|
||||||
|
|
||||||
|
namespace CursorLang.Agent.Windows;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The window the agent lives around: never shown, but it owns the tray icon and it
|
||||||
|
/// is the way back onto the message loop from a callback.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// A window with no <c>WS_VISIBLE</c> shows nowhere, yet is a window in every other
|
||||||
|
/// way. A message-only window would do as well were it not for the news of Explorer
|
||||||
|
/// restarting: that one is broadcast, and broadcasts pass such windows by.
|
||||||
|
/// </remarks>
|
||||||
|
internal sealed class AgentWindow : NativeWindow
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// A message hook. Returning <c>true</c> means the message has been dealt with.
|
||||||
|
/// </summary>
|
||||||
|
internal delegate bool MessageFilter(uint message, IntPtr wParam, IntPtr lParam);
|
||||||
|
|
||||||
|
private const string ClassName = "CursorLang.Agent.Window";
|
||||||
|
|
||||||
|
/// <summary>Drain the queue of posted work. WM_APP is free for the application.</summary>
|
||||||
|
private const uint WM_INVOKE = WindowNative.WM_APP + 100;
|
||||||
|
|
||||||
|
private readonly List<MessageFilter> _filters = [];
|
||||||
|
private readonly ConcurrentQueue<Action> _posted = new();
|
||||||
|
|
||||||
|
internal AgentWindow()
|
||||||
|
: base(ClassName, "CursorLang agent", WindowNative.WS_POPUP, WindowNative.WS_EX_TOOLWINDOW)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
internal void AddFilter(MessageFilter filter) => _filters.Add(filter);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Runs the action on the message loop, after the current message is done with.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// This is what the agent has instead of <c>Dispatcher.BeginInvoke</c>. The caller
|
||||||
|
/// that matters is the keyboard hook: Windows removes a hook whose procedure takes
|
||||||
|
/// too long, so the procedure only records what happened and the answer — showing
|
||||||
|
/// the popup, switching the layout — waits for the message after this one.
|
||||||
|
/// </remarks>
|
||||||
|
internal void Post(Action action)
|
||||||
|
{
|
||||||
|
_posted.Enqueue(action);
|
||||||
|
WindowNative.PostMessage(Handle, WM_INVOKE, IntPtr.Zero, IntPtr.Zero);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Asks the message loop to finish.</summary>
|
||||||
|
internal void Quit() => WindowNative.PostQuitMessage(0);
|
||||||
|
|
||||||
|
protected override bool OnMessage(uint message, IntPtr wParam, IntPtr lParam, out IntPtr result)
|
||||||
|
{
|
||||||
|
result = IntPtr.Zero;
|
||||||
|
|
||||||
|
if (message == WM_INVOKE)
|
||||||
|
{
|
||||||
|
while (_posted.TryDequeue(out Action? action))
|
||||||
|
{
|
||||||
|
action();
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message is WindowNative.WM_CLOSE or WindowNative.WM_ENDSESSION)
|
||||||
|
{
|
||||||
|
Quit();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (MessageFilter filter in _filters)
|
||||||
|
{
|
||||||
|
if (filter(message, wParam, lParam))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,424 @@
|
|||||||
|
using System.Drawing;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
using CursorLang.Agent.Interop;
|
||||||
|
using CursorLang.Agent.Services;
|
||||||
|
using CursorLang.Core.Interop;
|
||||||
|
using CursorLang.Core.Models;
|
||||||
|
using CursorLang.Core.Services;
|
||||||
|
|
||||||
|
namespace CursorLang.Agent.Windows;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The popup with the short name of the layout, drawn by Win32 alone.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// A like-for-like replacement of the WPF popup this once was: a rounded rectangle of
|
||||||
|
/// radius 4 with 10×4 padding, the fill and the text colour from the settings, the whole
|
||||||
|
/// thing at the opacity from the settings, the name in Segoe UI SemiBold at the size
|
||||||
|
/// from the settings.
|
||||||
|
///
|
||||||
|
/// The picture is drawn into an off-screen bitmap and handed to the window whole, by
|
||||||
|
/// <c>UpdateLayeredWindow</c>. Painting on demand instead — a <c>WM_PAINT</c> after the
|
||||||
|
/// window is shown — is what the first version did, and it had the popup appear holding
|
||||||
|
/// the picture of the previous show: hiding a window does not throw its content away,
|
||||||
|
/// and the content is always the other layout. Here there is nothing to be stale,
|
||||||
|
/// because the window is never shown before its picture is in place.
|
||||||
|
///
|
||||||
|
/// It also does away with two devices the painted version needed: the corners came from
|
||||||
|
/// a window region, which cuts without antialiasing, and the opacity from
|
||||||
|
/// <c>SetLayeredWindowAttributes</c>. Both are now just pixels in the bitmap.
|
||||||
|
///
|
||||||
|
/// Responsible only for showing the popup, its size and its place on screen: when to
|
||||||
|
/// take it down is decided by <see cref="LayoutPopupService"/>.
|
||||||
|
/// </remarks>
|
||||||
|
internal sealed class NativePopupWindow : NativeWindow, ILayoutPopupWindow
|
||||||
|
{
|
||||||
|
private const string ClassName = "CursorLang.Agent.Popup";
|
||||||
|
|
||||||
|
// The numbers of the XAML: Border CornerRadius="4" Padding="10,4", all in WPF units
|
||||||
|
private const double CornerRadius = 4;
|
||||||
|
private const double PaddingX = 10;
|
||||||
|
private const double PaddingY = 4;
|
||||||
|
|
||||||
|
private readonly AppSettings _settings;
|
||||||
|
|
||||||
|
private IntPtr _font;
|
||||||
|
private double _fontSize;
|
||||||
|
private double _fontScale;
|
||||||
|
|
||||||
|
internal NativePopupWindow(AppSettings settings)
|
||||||
|
: base(
|
||||||
|
ClassName,
|
||||||
|
"CursorLang popup",
|
||||||
|
WindowNative.WS_POPUP,
|
||||||
|
WindowNative.WS_EX_LAYERED | WindowNative.WS_EX_TOOLWINDOW |
|
||||||
|
WindowNative.WS_EX_NOACTIVATE | WindowNative.WS_EX_TRANSPARENT |
|
||||||
|
WindowNative.WS_EX_TOPMOST)
|
||||||
|
{
|
||||||
|
_settings = settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Shows the popup with the given text at the place set by the settings.
|
||||||
|
/// </summary>
|
||||||
|
public void ShowPopup(string text)
|
||||||
|
{
|
||||||
|
if (Handle == IntPtr.Zero)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
PopupWindowNative.Rect? anchor = TryGetAnchor();
|
||||||
|
PopupModeSettings mode = ModeFor(anchor);
|
||||||
|
|
||||||
|
PopupWindowNative.Rect work = default;
|
||||||
|
double scale;
|
||||||
|
|
||||||
|
if (anchor is { } at)
|
||||||
|
{
|
||||||
|
scale = PopupWindowNative.GetScaleAt(new PopupWindowNative.Point { X = at.Left, Y = at.Top });
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
(work, scale) = PopupWindowNative.GetActiveMonitorWorkArea();
|
||||||
|
}
|
||||||
|
|
||||||
|
EnsureFont(mode, scale);
|
||||||
|
|
||||||
|
Size measured = MeasureText(text);
|
||||||
|
int width = measured.Width + (2 * PopupLayout.ToPixels(PaddingX, scale));
|
||||||
|
int height = measured.Height + (2 * PopupLayout.ToPixels(PaddingY, scale));
|
||||||
|
|
||||||
|
PopupWindowNative.Point position = anchor is { } near
|
||||||
|
? PopupLayout.NearAnchor(
|
||||||
|
near,
|
||||||
|
SideForMode(),
|
||||||
|
PopupLayout.ToPixels(mode.Offset, scale),
|
||||||
|
width,
|
||||||
|
height)
|
||||||
|
: PopupLayout.OnScreen(
|
||||||
|
work,
|
||||||
|
_settings.FixedPoint.Position,
|
||||||
|
PopupLayout.ToPixels(mode.Offset, scale),
|
||||||
|
width,
|
||||||
|
height);
|
||||||
|
|
||||||
|
if (!Draw(mode, text, position, width, height, PopupLayout.ToPixels(CornerRadius, scale)))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!WindowNative.IsWindowVisible(Handle))
|
||||||
|
{
|
||||||
|
WindowNative.ShowWindow(Handle, WindowNative.SW_SHOWNOACTIVATE);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Hide()
|
||||||
|
{
|
||||||
|
if (Handle != IntPtr.Zero)
|
||||||
|
{
|
||||||
|
WindowNative.ShowWindow(Handle, WindowNative.SW_HIDE);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Destroys the window. The agent only does this on the way out.</summary>
|
||||||
|
public void Close() => Dispose();
|
||||||
|
|
||||||
|
public override void Dispose()
|
||||||
|
{
|
||||||
|
ReleaseFont();
|
||||||
|
base.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Draws the popup off screen and hands the finished picture to the window.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The bitmap is thrown away afterwards rather than kept: it is a few tens of
|
||||||
|
/// kilobytes for the length of one call, the popup is shown rarely, and a cached one
|
||||||
|
/// would have to be rebuilt on every change of size, colour or scale anyway.
|
||||||
|
/// </remarks>
|
||||||
|
private bool Draw(
|
||||||
|
PopupModeSettings mode, string text, PopupWindowNative.Point at, int width, int height, int radius)
|
||||||
|
{
|
||||||
|
IntPtr screen = GdiNative.GetDC(IntPtr.Zero);
|
||||||
|
if (screen == IntPtr.Zero)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
IntPtr memory = IntPtr.Zero;
|
||||||
|
IntPtr surface = IntPtr.Zero;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
memory = GdiNative.CreateCompatibleDC(screen);
|
||||||
|
if (memory == IntPtr.Zero)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
surface = GdiNative.CreateSurface(memory, width, height, out IntPtr bits);
|
||||||
|
if (surface == IntPtr.Zero)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
GdiNative.SelectObject(memory, surface);
|
||||||
|
|
||||||
|
Fill(mode, bits, width, height);
|
||||||
|
DrawText(mode, memory, text, width, height);
|
||||||
|
|
||||||
|
// GDI writes nothing into the alpha channel, so the letters it just drew are
|
||||||
|
// sitting at zero alpha and would come out invisible. The inside of the
|
||||||
|
// popup is opaque anyway, so the whole surface is simply declared so — and
|
||||||
|
// the corners are rounded off afterwards, which is the only place alpha
|
||||||
|
// varies
|
||||||
|
MakeOpaque(bits, width, height);
|
||||||
|
RoundTheCorners(bits, width, height, radius);
|
||||||
|
|
||||||
|
var size = new WindowNative.Size { Width = width, Height = height };
|
||||||
|
var alpha = (byte)Math.Clamp(Math.Round(mode.Opacity * 255), 0, 255);
|
||||||
|
|
||||||
|
return WindowNative.SetContent(Handle, at, size, memory, alpha);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
// The context goes first: a bitmap still selected into one cannot be
|
||||||
|
// deleted, and this way that holds however the method was left
|
||||||
|
if (memory != IntPtr.Zero)
|
||||||
|
{
|
||||||
|
GdiNative.DeleteDC(memory);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (surface != IntPtr.Zero)
|
||||||
|
{
|
||||||
|
GdiNative.DeleteObject(surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
GdiNative.ReleaseDC(IntPtr.Zero, screen);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Fill(PopupModeSettings mode, IntPtr bits, int width, int height)
|
||||||
|
{
|
||||||
|
Color background = mode.BackgroundColor;
|
||||||
|
|
||||||
|
// Straight into the bitmap rather than through a brush: the pixels have to be
|
||||||
|
// written anyway to carry an alpha channel GDI would not touch
|
||||||
|
int packed = (255 << 24) | (background.R << 16) | (background.G << 8) | background.B;
|
||||||
|
var row = new int[width];
|
||||||
|
Array.Fill(row, packed);
|
||||||
|
|
||||||
|
for (var y = 0; y < height; y++)
|
||||||
|
{
|
||||||
|
Marshal.Copy(row, 0, bits + (y * width * 4), width);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void DrawText(
|
||||||
|
PopupModeSettings mode, IntPtr deviceContext, string text, int width, int height)
|
||||||
|
{
|
||||||
|
if (_font == IntPtr.Zero || text.Length == 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var bounds = new PopupWindowNative.Rect { Left = 0, Top = 0, Right = width, Bottom = height };
|
||||||
|
|
||||||
|
IntPtr previousFont = GdiNative.SelectObject(deviceContext, _font);
|
||||||
|
GdiNative.SetBkMode(deviceContext, GdiNative.TRANSPARENT);
|
||||||
|
GdiNative.SetTextColor(deviceContext, GdiNative.ToColorRef(mode.ForegroundColor));
|
||||||
|
|
||||||
|
GdiNative.DrawText(deviceContext, text, text.Length, ref bounds,
|
||||||
|
GdiNative.DT_SINGLELINE | GdiNative.DT_CENTER | GdiNative.DT_VCENTER |
|
||||||
|
GdiNative.DT_NOPREFIX | GdiNative.DT_NOCLIP);
|
||||||
|
|
||||||
|
GdiNative.SelectObject(deviceContext, previousFont);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void MakeOpaque(IntPtr bits, int width, int height)
|
||||||
|
{
|
||||||
|
var row = new int[width];
|
||||||
|
|
||||||
|
for (var y = 0; y < height; y++)
|
||||||
|
{
|
||||||
|
IntPtr line = bits + (y * width * 4);
|
||||||
|
Marshal.Copy(line, row, 0, width);
|
||||||
|
|
||||||
|
for (var x = 0; x < width; x++)
|
||||||
|
{
|
||||||
|
row[x] = (int)((uint)row[x] | 0xFF000000);
|
||||||
|
}
|
||||||
|
|
||||||
|
Marshal.Copy(row, 0, line, width);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Cuts the four corners to a radius, fading the edge rather than stepping it.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The painted version cut them with a window region, which is a yes-or-no mask and
|
||||||
|
/// left a visible staircase at 200% scale. Here the corner pixels carry a partial
|
||||||
|
/// alpha worked out from how far the pixel centre is past the arc, which is what
|
||||||
|
/// antialiasing amounts to. The colours are premultiplied to match, as
|
||||||
|
/// <c>UpdateLayeredWindow</c> expects.
|
||||||
|
/// </remarks>
|
||||||
|
private static void RoundTheCorners(IntPtr bits, int width, int height, int radius)
|
||||||
|
{
|
||||||
|
if (radius <= 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
radius = Math.Min(radius, Math.Min(width, height) / 2);
|
||||||
|
|
||||||
|
var row = new int[width];
|
||||||
|
|
||||||
|
for (var y = 0; y < height; y++)
|
||||||
|
{
|
||||||
|
bool nearTop = y < radius;
|
||||||
|
bool nearBottom = y >= height - radius;
|
||||||
|
if (!nearTop && !nearBottom)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
IntPtr line = bits + (y * width * 4);
|
||||||
|
Marshal.Copy(line, row, 0, width);
|
||||||
|
|
||||||
|
double centreY = nearTop ? radius - 0.5 : height - radius - 0.5;
|
||||||
|
|
||||||
|
for (var x = 0; x < width; x++)
|
||||||
|
{
|
||||||
|
bool nearLeft = x < radius;
|
||||||
|
bool nearRight = x >= width - radius;
|
||||||
|
if (!nearLeft && !nearRight)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
double centreX = nearLeft ? radius - 0.5 : width - radius - 0.5;
|
||||||
|
double distance = Math.Sqrt(
|
||||||
|
((x - centreX) * (x - centreX)) + ((y - centreY) * (y - centreY)));
|
||||||
|
|
||||||
|
// One pixel of softness across the arc: fully inside, fully outside,
|
||||||
|
// and a ramp in between
|
||||||
|
double coverage = Math.Clamp(radius - distance + 0.5, 0, 1);
|
||||||
|
if (coverage >= 1)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
row[x] = Premultiply(row[x], coverage);
|
||||||
|
}
|
||||||
|
|
||||||
|
Marshal.Copy(row, 0, line, width);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int Premultiply(int pixel, double coverage)
|
||||||
|
{
|
||||||
|
var value = (uint)pixel;
|
||||||
|
var alpha = (uint)Math.Round(((value >> 24) & 0xFF) * coverage);
|
||||||
|
|
||||||
|
uint red = (uint)Math.Round(((value >> 16) & 0xFF) * coverage);
|
||||||
|
uint green = (uint)Math.Round(((value >> 8) & 0xFF) * coverage);
|
||||||
|
uint blue = (uint)Math.Round((value & 0xFF) * coverage);
|
||||||
|
|
||||||
|
return (int)((alpha << 24) | (red << 16) | (green << 8) | blue);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// What the popup is placed next to, or <c>null</c> when there is nothing: the fixed
|
||||||
|
/// point mode, and the caret mode where the application reports no caret.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The cursor is a rectangle of zero size, so the corner arithmetic is shared by it
|
||||||
|
/// and the caret.
|
||||||
|
/// </remarks>
|
||||||
|
private PopupWindowNative.Rect? TryGetAnchor() => _settings.PlacementMode switch
|
||||||
|
{
|
||||||
|
PopupPlacementMode.FixedPoint => null,
|
||||||
|
PopupPlacementMode.AtCaret => CaretNative.TryGetCaretRect(),
|
||||||
|
_ => PopupLayout.AsAnchor(PopupWindowNative.GetCursorPosition()),
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The settings the popup is shown with: those of the mode chosen, or those of the
|
||||||
|
/// fixed point when there is no anchor to stand next to.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The caret mode falls back to the fixed point rather than to the mouse cursor:
|
||||||
|
/// the cursor is wherever it was last left — off to a side, on another monitor, or
|
||||||
|
/// over the very text being typed — and a popup that lands there while the eyes are
|
||||||
|
/// on the caret is one that is looked for and not found. The fixed point is always
|
||||||
|
/// in the same place, so it is known where to look.
|
||||||
|
///
|
||||||
|
/// The look comes from the fixed point mode too, not just the place. The two are set
|
||||||
|
/// up together for a reason: the popup by the caret is small and quiet because it
|
||||||
|
/// sits inside a text being read, while the one in the corner of the monitor is
|
||||||
|
/// looked for on purpose and is set larger. Keeping the caret look at the corner
|
||||||
|
/// would put a popup meant to go unnoticed where nothing else draws the eye.
|
||||||
|
/// </remarks>
|
||||||
|
private PopupModeSettings ModeFor(PopupWindowNative.Rect? anchor) =>
|
||||||
|
anchor is null ? _settings.FixedPoint : _settings.Current;
|
||||||
|
|
||||||
|
// The caret has two sides to choose from and the cursor has six, so each mode names
|
||||||
|
// its own side in its own terms
|
||||||
|
private AnchorSide SideForMode() => _settings.PlacementMode == PopupPlacementMode.AtCaret
|
||||||
|
? _settings.AtCaret.Anchor
|
||||||
|
: _settings.AtCursor.Side;
|
||||||
|
|
||||||
|
private Size MeasureText(string text)
|
||||||
|
{
|
||||||
|
IntPtr deviceContext = GdiNative.GetDC(Handle);
|
||||||
|
if (deviceContext == IntPtr.Zero)
|
||||||
|
{
|
||||||
|
return Size.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
IntPtr previousFont = GdiNative.SelectObject(deviceContext, _font);
|
||||||
|
Size measured = GdiNative.MeasureText(deviceContext, text);
|
||||||
|
GdiNative.SelectObject(deviceContext, previousFont);
|
||||||
|
|
||||||
|
return measured;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
GdiNative.ReleaseDC(Handle, deviceContext);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The font is rebuilt only when the size in the settings or the monitor scale
|
||||||
|
// changes: it is the one expensive thing a show does. A switch of the placement
|
||||||
|
// mode counts as a change of the size, since the size belongs to the mode
|
||||||
|
private void EnsureFont(PopupModeSettings mode, double scale)
|
||||||
|
{
|
||||||
|
if (_font != IntPtr.Zero &&
|
||||||
|
Math.Abs(_fontSize - mode.FontSize) < 0.01 &&
|
||||||
|
Math.Abs(_fontScale - scale) < 0.01)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ReleaseFont();
|
||||||
|
|
||||||
|
_fontSize = mode.FontSize;
|
||||||
|
_fontScale = scale;
|
||||||
|
_font = GdiNative.CreateFont(_fontSize, scale);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ReleaseFont()
|
||||||
|
{
|
||||||
|
if (_font != IntPtr.Zero)
|
||||||
|
{
|
||||||
|
GdiNative.DeleteObject(_font);
|
||||||
|
_font = IntPtr.Zero;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
using CursorLang.Agent.Interop;
|
||||||
|
using CursorLang.Core.Interop;
|
||||||
|
using CursorLang.Core.Services;
|
||||||
|
|
||||||
|
namespace CursorLang.Agent.Windows;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The icon in the notification area: the way to the settings window and the only way
|
||||||
|
/// to quit the application.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The interop half is the same as it always was — the icon has never known anything
|
||||||
|
/// about the framework. What changed is the menu: a WPF <c>ContextMenu</c> obeyed the
|
||||||
|
/// theme and the language chosen in the settings, and a <c>TrackPopupMenuEx</c> menu is
|
||||||
|
/// drawn by Windows in the system look. The language still reaches it, because the
|
||||||
|
/// captions are ours; the theme does not, and that is the price of taking the rendering
|
||||||
|
/// stack out of the background process.
|
||||||
|
///
|
||||||
|
/// The captions are read each time the menu is raised rather than once: the language is
|
||||||
|
/// changed in the settings without a restart, and the menu is built on every click
|
||||||
|
/// anyway — a menu costs nothing to build and is asked for rarely.
|
||||||
|
/// </remarks>
|
||||||
|
internal sealed class NativeTrayIcon : IDisposable
|
||||||
|
{
|
||||||
|
/// <summary>Windows shows it under the pointer. The name of the app says enough.</summary>
|
||||||
|
private const string Tooltip = "CursorLang";
|
||||||
|
|
||||||
|
/// <summary>Distinguishes the icon among those of the same window; we have one.</summary>
|
||||||
|
private const int IconId = 1;
|
||||||
|
|
||||||
|
private const int CommandSettings = 1;
|
||||||
|
private const int CommandExit = 2;
|
||||||
|
|
||||||
|
private readonly AgentWindow _window;
|
||||||
|
private readonly ILocalizationService _localization;
|
||||||
|
|
||||||
|
private IntPtr _icon;
|
||||||
|
private bool _isInstalled;
|
||||||
|
|
||||||
|
internal NativeTrayIcon(AgentWindow window, ILocalizationService localization)
|
||||||
|
{
|
||||||
|
_window = window;
|
||||||
|
_localization = localization;
|
||||||
|
|
||||||
|
_window.AddFilter(OnMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal event EventHandler? OpenRequested;
|
||||||
|
|
||||||
|
internal event EventHandler? ExitRequested;
|
||||||
|
|
||||||
|
internal bool Install()
|
||||||
|
{
|
||||||
|
if (_isInstalled)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
_icon = TrayIconNative.LoadApplicationIcon();
|
||||||
|
_isInstalled = TrayIconNative.Add(_window.Handle, IconId, _icon, Tooltip);
|
||||||
|
|
||||||
|
return _isInstalled;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (_isInstalled)
|
||||||
|
{
|
||||||
|
TrayIconNative.Remove(_window.Handle, IconId);
|
||||||
|
_isInstalled = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
TrayIconNative.ReleaseIcon(_icon);
|
||||||
|
_icon = IntPtr.Zero;
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool OnMessage(uint message, IntPtr wParam, IntPtr lParam)
|
||||||
|
{
|
||||||
|
// Explorer has restarted and taken every icon down with it
|
||||||
|
if (message == (uint)TrayIconNative.TaskbarCreatedMessage && _isInstalled)
|
||||||
|
{
|
||||||
|
TrayIconNative.Add(_window.Handle, IconId, _icon, Tooltip);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message != TrayIconNative.CallbackMessage)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (TrayIconNative.NotificationOf(lParam))
|
||||||
|
{
|
||||||
|
case TrayIconNative.SelectNotification:
|
||||||
|
case TrayIconNative.KeySelectNotification:
|
||||||
|
OpenRequested?.Invoke(this, EventArgs.Empty);
|
||||||
|
return true;
|
||||||
|
|
||||||
|
case TrayIconNative.ContextMenuNotification:
|
||||||
|
ShowMenu(TrayIconNative.PointOf(wParam));
|
||||||
|
return true;
|
||||||
|
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Raises the menu of the icon where the pointer is.</summary>
|
||||||
|
internal void ShowMenu(PopupWindowNative.Point at)
|
||||||
|
{
|
||||||
|
MenuNative.Item[] items =
|
||||||
|
[
|
||||||
|
new(CommandSettings, _localization["TrayMenuSettings"]),
|
||||||
|
MenuNative.Item.Separator,
|
||||||
|
new(CommandExit, _localization["TrayMenuExit"]),
|
||||||
|
];
|
||||||
|
|
||||||
|
switch (MenuNative.Track(_window.Handle, at, items))
|
||||||
|
{
|
||||||
|
case CommandSettings:
|
||||||
|
OpenRequested?.Invoke(this, EventArgs.Empty);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case CommandExit:
|
||||||
|
ExitRequested?.Invoke(this, EventArgs.Empty);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
using CursorLang.Agent.Interop;
|
||||||
|
|
||||||
|
namespace CursorLang.Agent.Windows;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A window with no framework behind it: a registered class, a handle and a window
|
||||||
|
/// procedure that lands in <see cref="OnMessage"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Windows knows one procedure per class, so the procedure here is shared and static,
|
||||||
|
/// and finds the instance by handle. The very first message of a window arrives while
|
||||||
|
/// <c>CreateWindowExW</c> is still running and there is nothing to find yet — that is
|
||||||
|
/// what the field holding the instance under construction is for.
|
||||||
|
///
|
||||||
|
/// Everything is deliberately without locks: the agent has one message loop, every
|
||||||
|
/// window belongs to it, and a window procedure can only ever be called on the thread
|
||||||
|
/// that created the window.
|
||||||
|
/// </remarks>
|
||||||
|
internal abstract class NativeWindow : IDisposable
|
||||||
|
{
|
||||||
|
private static readonly Dictionary<IntPtr, NativeWindow> Live = [];
|
||||||
|
private static readonly HashSet<string> RegisteredClasses = new(StringComparer.Ordinal);
|
||||||
|
|
||||||
|
// The shared procedure is a static field for the same reason a hook procedure is:
|
||||||
|
// Windows holds the only reference to it and the collector does not see that
|
||||||
|
private static readonly WindowNative.WindowProc SharedProc = StaticWindowProc;
|
||||||
|
|
||||||
|
[ThreadStatic]
|
||||||
|
private static NativeWindow? _creating;
|
||||||
|
|
||||||
|
protected NativeWindow(string className, string title, int style, int exStyle)
|
||||||
|
{
|
||||||
|
if (RegisteredClasses.Add(className))
|
||||||
|
{
|
||||||
|
WindowNative.RegisterClass(className, SharedProc);
|
||||||
|
}
|
||||||
|
|
||||||
|
_creating = this;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Handle = WindowNative.CreateWindow(className, title, style, exStyle);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_creating = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Live[Handle] = this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The window handle. Zero once the window is gone.</summary>
|
||||||
|
internal IntPtr Handle { get; private set; }
|
||||||
|
|
||||||
|
public virtual void Dispose()
|
||||||
|
{
|
||||||
|
if (Handle == IntPtr.Zero)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
IntPtr handle = Handle;
|
||||||
|
Handle = IntPtr.Zero;
|
||||||
|
Live.Remove(handle);
|
||||||
|
|
||||||
|
WindowNative.DestroyWindow(handle);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A message for this window. Returning <c>false</c> passes it to
|
||||||
|
/// <c>DefWindowProcW</c>, which is what the vast majority of messages want — and
|
||||||
|
/// what all of them want for a window whose content is set from the outside.
|
||||||
|
/// </summary>
|
||||||
|
protected virtual bool OnMessage(uint message, IntPtr wParam, IntPtr lParam, out IntPtr result)
|
||||||
|
{
|
||||||
|
result = IntPtr.Zero;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IntPtr StaticWindowProc(IntPtr hWnd, uint message, IntPtr wParam, IntPtr lParam)
|
||||||
|
{
|
||||||
|
if (!Live.TryGetValue(hWnd, out NativeWindow? window))
|
||||||
|
{
|
||||||
|
if (_creating is null)
|
||||||
|
{
|
||||||
|
return WindowNative.DefWindowProc(hWnd, message, wParam, lParam);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The window is being created right now: bind the handle to the instance
|
||||||
|
// so that the rest of its creation messages find their way home
|
||||||
|
window = _creating;
|
||||||
|
window.Handle = hWnd;
|
||||||
|
Live[hWnd] = window;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message == WindowNative.WM_DESTROY)
|
||||||
|
{
|
||||||
|
Live.Remove(hWnd);
|
||||||
|
}
|
||||||
|
|
||||||
|
return window.OnMessage(message, wParam, lParam, out IntPtr result)
|
||||||
|
? result
|
||||||
|
: WindowNative.DefWindowProc(hWnd, message, wParam, lParam);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
|
||||||
|
<assemblyIdentity version="1.0.0.0" name="CursorLang.Agent.app" />
|
||||||
|
|
||||||
|
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v2">
|
||||||
|
<security>
|
||||||
|
<requestedPrivileges xmlns="urn:schemas-microsoft-com:asm.v3">
|
||||||
|
<requestedExecutionLevel level="asInvoker" uiAccess="false" />
|
||||||
|
</requestedPrivileges>
|
||||||
|
</security>
|
||||||
|
</trustInfo>
|
||||||
|
|
||||||
|
<application xmlns="urn:schemas-microsoft-com:asm.v3">
|
||||||
|
<windowsSettings>
|
||||||
|
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2</dpiAwareness>
|
||||||
|
<dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true/pm</dpiAware>
|
||||||
|
</windowsSettings>
|
||||||
|
</application>
|
||||||
|
</assembly>
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<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>
|
||||||
|
<OutputType>Exe</OutputType>
|
||||||
|
<PlatformTarget>AnyCPU</PlatformTarget>
|
||||||
|
<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
|
||||||
|
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||||
|
<NoWarn>$(NoWarn);CS1591</NoWarn>
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
<IsTestProject>true</IsTestProject>
|
||||||
|
<RootNamespace>CursorLang.Core.Tests</RootNamespace>
|
||||||
|
<AssemblyName>CursorLang.Core.Tests</AssemblyName>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Using Include="Xunit" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<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.Core\CursorLang.Core.csproj" />
|
||||||
|
<ProjectReference Include="..\CursorLang.Tests.Shared\CursorLang.Tests.Shared.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
using CursorLang.Core.Interop;
|
||||||
|
using CursorLang.Tests.Shared;
|
||||||
|
|
||||||
|
namespace CursorLang.Core.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 = Pump.Run(CaretNative.TryGetCaretRect);
|
||||||
|
|
||||||
|
if (caret is not null)
|
||||||
|
{
|
||||||
|
Assert.True(caret.Value.Bottom > caret.Value.Top);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
using System.Reflection;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
using CursorLang.Core.Interop;
|
||||||
|
using CursorLang.Tests.Shared;
|
||||||
|
|
||||||
|
namespace CursorLang.Core.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()
|
||||||
|
{
|
||||||
|
Pump.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()
|
||||||
|
{
|
||||||
|
Pump.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()
|
||||||
|
{
|
||||||
|
Pump.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,319 @@
|
|||||||
|
using System.ComponentModel;
|
||||||
|
using System.Drawing;
|
||||||
|
using System.Reflection;
|
||||||
|
using System.Text.Json;
|
||||||
|
using CursorLang.Core.Models;
|
||||||
|
|
||||||
|
namespace CursorLang.Core.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(500, settings.DurationMilliseconds);
|
||||||
|
Assert.Equal(300, settings.CapsLockHoldMilliseconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Every_mode_starts_out_looking_the_same()
|
||||||
|
{
|
||||||
|
var settings = new AppSettings();
|
||||||
|
|
||||||
|
Assert.Equal(AnchorSide.BottomRight, settings.AtCursor.Side);
|
||||||
|
Assert.Equal(16, settings.AtCursor.Offset);
|
||||||
|
Assert.Equal(CaretSide.Right, settings.AtCaret.Side);
|
||||||
|
Assert.Equal(16, settings.AtCaret.Offset);
|
||||||
|
|
||||||
|
// The middle of the monitor, where there is no edge to stand off from
|
||||||
|
Assert.Equal(ScreenPosition.Center, settings.FixedPoint.Position);
|
||||||
|
Assert.Equal(0, settings.FixedPoint.Offset);
|
||||||
|
|
||||||
|
PopupModeSettings[] modes = [settings.AtCursor, settings.AtCaret, settings.FixedPoint];
|
||||||
|
|
||||||
|
Assert.All(modes, mode =>
|
||||||
|
{
|
||||||
|
Assert.Equal(20, mode.FontSize);
|
||||||
|
Assert.Equal(0.9, mode.Opacity);
|
||||||
|
Assert.Equal(Color.FromArgb(0x20, 0x20, 0x20), mode.BackgroundColor);
|
||||||
|
Assert.Equal(Color.FromArgb(0xFF, 0xFF, 0xFF), mode.ForegroundColor);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Three modes and three sets of settings: none of them is shared
|
||||||
|
Assert.Equal(3, modes.Distinct().Count());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(PopupPlacementMode.AtCursor)]
|
||||||
|
[InlineData(PopupPlacementMode.AtCaret)]
|
||||||
|
[InlineData(PopupPlacementMode.FixedPoint)]
|
||||||
|
public void The_chosen_mode_is_the_one_handed_out(PopupPlacementMode mode)
|
||||||
|
{
|
||||||
|
var settings = new AppSettings { PlacementMode = mode };
|
||||||
|
|
||||||
|
PopupModeSettings expected = mode switch
|
||||||
|
{
|
||||||
|
PopupPlacementMode.AtCaret => settings.AtCaret,
|
||||||
|
PopupPlacementMode.FixedPoint => settings.FixedPoint,
|
||||||
|
_ => settings.AtCursor,
|
||||||
|
};
|
||||||
|
|
||||||
|
Assert.Same(expected, settings.Current);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The settings of a mode are its own: setting one up leaves the others alone.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// This is the whole point of keeping them per mode. A look shared by the modes
|
||||||
|
/// meant setting it up again after every switch, and switching modes to see what
|
||||||
|
/// they do undid what had just been set up.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public void Setting_one_mode_up_leaves_the_others_where_they_were()
|
||||||
|
{
|
||||||
|
var settings = new AppSettings();
|
||||||
|
|
||||||
|
settings.AtCaret.FontSize = 12;
|
||||||
|
settings.AtCaret.Side = CaretSide.Left;
|
||||||
|
settings.AtCaret.BackgroundColor = Color.FromArgb(0x11, 0x22, 0x33);
|
||||||
|
|
||||||
|
Assert.Equal(20, settings.AtCursor.FontSize);
|
||||||
|
Assert.Equal(AnchorSide.BottomRight, settings.AtCursor.Side);
|
||||||
|
Assert.Equal(20, settings.FixedPoint.FontSize);
|
||||||
|
Assert.Equal(Color.FromArgb(0x20, 0x20, 0x20), settings.FixedPoint.BackgroundColor);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The mode in force decides what the look means, so a switch of it is a change of
|
||||||
|
// everything the window shows through the current mode
|
||||||
|
[Fact]
|
||||||
|
public void A_switch_of_the_mode_is_announced_as_a_change_of_the_current_one()
|
||||||
|
{
|
||||||
|
var settings = new AppSettings();
|
||||||
|
List<string?> changed = [];
|
||||||
|
settings.PropertyChanged += (_, e) => changed.Add(e.PropertyName);
|
||||||
|
|
||||||
|
settings.PlacementMode = PopupPlacementMode.FixedPoint;
|
||||||
|
|
||||||
|
Assert.Contains(nameof(AppSettings.PlacementMode), changed);
|
||||||
|
Assert.Contains(nameof(AppSettings.Current), changed);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A change inside a mode is passed on as a change of the settings.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The service that writes the file listens to the settings alone. Without this, a
|
||||||
|
/// font size dragged in the window would never reach the disk.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public void A_change_inside_a_mode_is_announced_by_the_settings()
|
||||||
|
{
|
||||||
|
var settings = new AppSettings();
|
||||||
|
List<string?> changed = [];
|
||||||
|
settings.PropertyChanged += (_, e) => changed.Add(e.PropertyName);
|
||||||
|
|
||||||
|
settings.AtCaret.FontSize = 42;
|
||||||
|
settings.FixedPoint.Position = ScreenPosition.Bottom;
|
||||||
|
settings.AtCursor.Offset = 8;
|
||||||
|
|
||||||
|
Assert.Contains("AtCaret.FontSize", changed);
|
||||||
|
Assert.Contains("FixedPoint.Position", changed);
|
||||||
|
Assert.Contains("AtCursor.Offset", changed);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Pouring another instance in fills the modes in place rather than replacing them.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// This is the agent's side of the connection: the fresh values arrive as a freshly
|
||||||
|
/// parsed instance. Replacing the mode objects would leave the popup bound to the
|
||||||
|
/// old ones.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public void Taking_other_settings_over_fills_the_modes_that_are_already_there()
|
||||||
|
{
|
||||||
|
var settings = new AppSettings();
|
||||||
|
CursorModeSettings cursor = settings.AtCursor;
|
||||||
|
|
||||||
|
var other = new AppSettings
|
||||||
|
{
|
||||||
|
Language = "ru",
|
||||||
|
Theme = AppTheme.Dark,
|
||||||
|
PlacementMode = PopupPlacementMode.FixedPoint,
|
||||||
|
DurationMilliseconds = 900,
|
||||||
|
UseCapsLockHotkey = true,
|
||||||
|
CapsLockHoldMilliseconds = 450,
|
||||||
|
};
|
||||||
|
|
||||||
|
other.AtCursor.Side = AnchorSide.TopLeft;
|
||||||
|
other.AtCursor.Offset = 5;
|
||||||
|
other.AtCursor.FontSize = 11;
|
||||||
|
other.AtCaret.Side = CaretSide.Left;
|
||||||
|
other.AtCaret.ForegroundColor = Color.FromArgb(0x0A, 0x0B, 0x0C);
|
||||||
|
other.FixedPoint.Position = ScreenPosition.Top;
|
||||||
|
other.FixedPoint.Offset = 64;
|
||||||
|
|
||||||
|
settings.CopyFrom(other);
|
||||||
|
|
||||||
|
Assert.Same(cursor, settings.AtCursor);
|
||||||
|
|
||||||
|
Assert.Equal("ru", settings.Language);
|
||||||
|
Assert.Equal(AppTheme.Dark, settings.Theme);
|
||||||
|
Assert.Equal(PopupPlacementMode.FixedPoint, settings.PlacementMode);
|
||||||
|
Assert.Equal(900, settings.DurationMilliseconds);
|
||||||
|
Assert.True(settings.UseCapsLockHotkey);
|
||||||
|
Assert.Equal(450, settings.CapsLockHoldMilliseconds);
|
||||||
|
|
||||||
|
Assert.Equal(AnchorSide.TopLeft, settings.AtCursor.Side);
|
||||||
|
Assert.Equal(5, settings.AtCursor.Offset);
|
||||||
|
Assert.Equal(11, settings.AtCursor.FontSize);
|
||||||
|
Assert.Equal(CaretSide.Left, settings.AtCaret.Side);
|
||||||
|
Assert.Equal(Color.FromArgb(0x0A, 0x0B, 0x0C), settings.AtCaret.ForegroundColor);
|
||||||
|
Assert.Equal(ScreenPosition.Top, settings.FixedPoint.Position);
|
||||||
|
Assert.Equal(64, settings.FixedPoint.Offset);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Derived values and the modes handed out for convenience are computed from what
|
||||||
|
// is stored and have no business being in the file themselves
|
||||||
|
[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);
|
||||||
|
Assert.DoesNotContain(nameof(AppSettings.Current), 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);
|
||||||
|
Assert.Contains(nameof(AppSettings.AtCursor), names);
|
||||||
|
Assert.Contains(nameof(AppSettings.AtCaret), names);
|
||||||
|
Assert.Contains(nameof(AppSettings.FixedPoint), 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>
|
||||||
|
/// <remarks>
|
||||||
|
/// The modes are handed out rather than assigned — they are filled in place — so
|
||||||
|
/// they are not among these. What is inside them is checked by
|
||||||
|
/// <see cref="PopupModeSettingsTests"/>.
|
||||||
|
/// </remarks>
|
||||||
|
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.FromArgb((byte)(color.R + 1), color.G, color.B),
|
||||||
|
Enum value => NextEnumValue(value),
|
||||||
|
|
||||||
|
_ => 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.Core.Models;
|
||||||
|
|
||||||
|
namespace CursorLang.Core.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.Core.Models;
|
||||||
|
|
||||||
|
namespace CursorLang.Core.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,289 @@
|
|||||||
|
using System.ComponentModel;
|
||||||
|
using System.Drawing;
|
||||||
|
using System.Reflection;
|
||||||
|
using System.Text.Json;
|
||||||
|
using CursorLang.Core.Models;
|
||||||
|
|
||||||
|
namespace CursorLang.Core.Tests.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The settings of a single placement mode: what the popup looks like there and how
|
||||||
|
/// far from its anchor it sits.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class PopupModeSettingsTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void The_cursor_mode_starts_out_below_and_right_of_the_pointer()
|
||||||
|
{
|
||||||
|
var mode = new CursorModeSettings();
|
||||||
|
|
||||||
|
Assert.Equal(AnchorSide.BottomRight, mode.Side);
|
||||||
|
Assert.Equal(16, mode.Offset);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Next to the caret the popup goes beside it, and to the right by default.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Above or below the caret is where the next line of the text is, so those sides
|
||||||
|
/// are not on offer at all — the type has the two of them and no more.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public void The_caret_mode_starts_out_to_the_right_of_the_caret()
|
||||||
|
{
|
||||||
|
var mode = new CaretModeSettings();
|
||||||
|
|
||||||
|
Assert.Equal(CaretSide.Right, mode.Side);
|
||||||
|
Assert.Equal(16, mode.Offset);
|
||||||
|
Assert.Equal([CaretSide.Left, CaretSide.Right], Enum.GetValues<CaretSide>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(CaretSide.Left, AnchorSide.Left)]
|
||||||
|
[InlineData(CaretSide.Right, AnchorSide.Right)]
|
||||||
|
public void The_side_of_the_caret_lines_the_popup_up_with_it(CaretSide side, AnchorSide expected)
|
||||||
|
{
|
||||||
|
var mode = new CaretModeSettings { Side = side };
|
||||||
|
|
||||||
|
Assert.Equal(expected, mode.Anchor);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_changed_side_of_the_caret_is_a_change_of_what_it_lines_up_with()
|
||||||
|
{
|
||||||
|
var mode = new CaretModeSettings();
|
||||||
|
List<string?> changed = [];
|
||||||
|
mode.PropertyChanged += (_, e) => changed.Add(e.PropertyName);
|
||||||
|
|
||||||
|
mode.Side = CaretSide.Left;
|
||||||
|
|
||||||
|
Assert.Contains(nameof(CaretModeSettings.Anchor), changed);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The fixed point starts out in the middle of the monitor, where there is no edge
|
||||||
|
/// to stand off from.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void The_fixed_point_starts_out_in_the_middle_with_no_offset()
|
||||||
|
{
|
||||||
|
var mode = new FixedPointModeSettings();
|
||||||
|
|
||||||
|
Assert.Equal(ScreenPosition.Center, mode.Position);
|
||||||
|
Assert.Equal(0, mode.Offset);
|
||||||
|
Assert.False(mode.IsAtAnEdge);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A setting that is not shown must not be one that still applies
|
||||||
|
[Fact]
|
||||||
|
public void Moving_the_fixed_point_to_the_middle_drops_the_offset()
|
||||||
|
{
|
||||||
|
var mode = new FixedPointModeSettings { Position = ScreenPosition.TopLeft, Offset = 40 };
|
||||||
|
|
||||||
|
Assert.True(mode.IsAtAnEdge);
|
||||||
|
|
||||||
|
mode.Position = ScreenPosition.Center;
|
||||||
|
|
||||||
|
Assert.Equal(0, mode.Offset);
|
||||||
|
Assert.False(mode.IsAtAnEdge);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(ScreenPosition.TopLeft)]
|
||||||
|
[InlineData(ScreenPosition.Top)]
|
||||||
|
[InlineData(ScreenPosition.TopRight)]
|
||||||
|
[InlineData(ScreenPosition.BottomLeft)]
|
||||||
|
[InlineData(ScreenPosition.Bottom)]
|
||||||
|
[InlineData(ScreenPosition.BottomRight)]
|
||||||
|
public void Away_from_the_middle_the_offset_is_kept(ScreenPosition position)
|
||||||
|
{
|
||||||
|
var mode = new FixedPointModeSettings { Position = position, Offset = 40 };
|
||||||
|
|
||||||
|
Assert.Equal(40, mode.Offset);
|
||||||
|
Assert.True(mode.IsAtAnEdge);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_changed_place_of_the_fixed_point_is_a_change_of_having_an_edge()
|
||||||
|
{
|
||||||
|
var mode = new FixedPointModeSettings();
|
||||||
|
List<string?> changed = [];
|
||||||
|
mode.PropertyChanged += (_, e) => changed.Add(e.PropertyName);
|
||||||
|
|
||||||
|
mode.Position = ScreenPosition.Bottom;
|
||||||
|
|
||||||
|
Assert.Contains(nameof(FixedPointModeSettings.IsAtAnEdge), changed);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[MemberData(nameof(WritableProperties), typeof(CursorModeSettings))]
|
||||||
|
[MemberData(nameof(WritableProperties), typeof(CaretModeSettings))]
|
||||||
|
[MemberData(nameof(WritableProperties), typeof(FixedPointModeSettings))]
|
||||||
|
public void A_changed_setting_is_announced_to_subscribers(Type type, string propertyName)
|
||||||
|
{
|
||||||
|
PopupModeSettings mode = Create(type);
|
||||||
|
List<string?> changed = [];
|
||||||
|
mode.PropertyChanged += (_, e) => changed.Add(e.PropertyName);
|
||||||
|
|
||||||
|
SetDifferentValue(mode, propertyName);
|
||||||
|
|
||||||
|
Assert.Contains(propertyName, changed);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[MemberData(nameof(WritableProperties), typeof(CursorModeSettings))]
|
||||||
|
[MemberData(nameof(WritableProperties), typeof(CaretModeSettings))]
|
||||||
|
[MemberData(nameof(WritableProperties), typeof(FixedPointModeSettings))]
|
||||||
|
public void Writing_the_same_value_leaves_subscribers_alone(Type type, string propertyName)
|
||||||
|
{
|
||||||
|
PopupModeSettings mode = Create(type);
|
||||||
|
PropertyInfo property = type.GetProperty(propertyName)!;
|
||||||
|
|
||||||
|
List<string?> changed = [];
|
||||||
|
mode.PropertyChanged += (_, e) => changed.Add(e.PropertyName);
|
||||||
|
|
||||||
|
property.SetValue(mode, property.GetValue(mode));
|
||||||
|
|
||||||
|
Assert.Empty(changed);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void The_cursor_mode_takes_another_one_over_whole()
|
||||||
|
{
|
||||||
|
var mode = new CursorModeSettings();
|
||||||
|
var other = new CursorModeSettings
|
||||||
|
{
|
||||||
|
Side = AnchorSide.Left,
|
||||||
|
Offset = 3,
|
||||||
|
FontSize = 41,
|
||||||
|
Opacity = 0.25,
|
||||||
|
BackgroundColor = Color.FromArgb(0x01, 0x02, 0x03),
|
||||||
|
ForegroundColor = Color.FromArgb(0x04, 0x05, 0x06),
|
||||||
|
};
|
||||||
|
|
||||||
|
mode.CopyFrom(other);
|
||||||
|
|
||||||
|
Assert.Equal(AnchorSide.Left, mode.Side);
|
||||||
|
Assert.Equal(3, mode.Offset);
|
||||||
|
Assert.Equal(41, mode.FontSize);
|
||||||
|
Assert.Equal(0.25, mode.Opacity);
|
||||||
|
Assert.Equal(Color.FromArgb(0x01, 0x02, 0x03), mode.BackgroundColor);
|
||||||
|
Assert.Equal(Color.FromArgb(0x04, 0x05, 0x06), mode.ForegroundColor);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void The_caret_mode_takes_another_one_over_whole()
|
||||||
|
{
|
||||||
|
var mode = new CaretModeSettings();
|
||||||
|
var other = new CaretModeSettings { Side = CaretSide.Left, Offset = 2, FontSize = 15 };
|
||||||
|
|
||||||
|
mode.CopyFrom(other);
|
||||||
|
|
||||||
|
Assert.Equal(CaretSide.Left, mode.Side);
|
||||||
|
Assert.Equal(2, mode.Offset);
|
||||||
|
Assert.Equal(15, mode.FontSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void The_fixed_point_takes_another_one_over_whole()
|
||||||
|
{
|
||||||
|
var mode = new FixedPointModeSettings();
|
||||||
|
var other = new FixedPointModeSettings
|
||||||
|
{
|
||||||
|
Position = ScreenPosition.Bottom,
|
||||||
|
Offset = 120,
|
||||||
|
FontSize = 60,
|
||||||
|
Opacity = 0.5,
|
||||||
|
BackgroundColor = Color.FromArgb(0x07, 0x08, 0x09),
|
||||||
|
ForegroundColor = Color.FromArgb(0x0A, 0x0B, 0x0C),
|
||||||
|
};
|
||||||
|
|
||||||
|
mode.CopyFrom(other);
|
||||||
|
|
||||||
|
Assert.Equal(ScreenPosition.Bottom, mode.Position);
|
||||||
|
Assert.Equal(120, mode.Offset);
|
||||||
|
Assert.Equal(60, mode.FontSize);
|
||||||
|
Assert.Equal(0.5, mode.Opacity);
|
||||||
|
Assert.Equal(Color.FromArgb(0x07, 0x08, 0x09), mode.BackgroundColor);
|
||||||
|
Assert.Equal(Color.FromArgb(0x0A, 0x0B, 0x0C), mode.ForegroundColor);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Taking over a mode that sits in the middle takes its lack of an offset over too
|
||||||
|
[Fact]
|
||||||
|
public void Taking_over_a_fixed_point_in_the_middle_leaves_no_offset()
|
||||||
|
{
|
||||||
|
var mode = new FixedPointModeSettings { Position = ScreenPosition.Top, Offset = 32 };
|
||||||
|
|
||||||
|
mode.CopyFrom(new FixedPointModeSettings());
|
||||||
|
|
||||||
|
Assert.Equal(ScreenPosition.Center, mode.Position);
|
||||||
|
Assert.Equal(0, mode.Offset);
|
||||||
|
}
|
||||||
|
|
||||||
|
// What a mode works out from what it stores is not stored itself
|
||||||
|
[Fact]
|
||||||
|
public void Derived_values_of_a_mode_stay_out_of_the_file()
|
||||||
|
{
|
||||||
|
List<string> caret = Stored(new CaretModeSettings());
|
||||||
|
List<string> fixedPoint = Stored(new FixedPointModeSettings());
|
||||||
|
|
||||||
|
Assert.DoesNotContain(nameof(CaretModeSettings.Anchor), caret);
|
||||||
|
Assert.DoesNotContain(nameof(FixedPointModeSettings.IsAtAnEdge), fixedPoint);
|
||||||
|
|
||||||
|
Assert.Contains(nameof(CaretModeSettings.Side), caret);
|
||||||
|
Assert.Contains(nameof(FixedPointModeSettings.Position), fixedPoint);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_mode_reports_changes_as_INotifyPropertyChanged()
|
||||||
|
{
|
||||||
|
Assert.IsAssignableFrom<INotifyPropertyChanged>(new CursorModeSettings());
|
||||||
|
Assert.IsAssignableFrom<INotifyPropertyChanged>(new CaretModeSettings());
|
||||||
|
Assert.IsAssignableFrom<INotifyPropertyChanged>(new FixedPointModeSettings());
|
||||||
|
}
|
||||||
|
|
||||||
|
public static TheoryData<Type, string> WritableProperties(Type type)
|
||||||
|
{
|
||||||
|
var data = new TheoryData<Type, string>();
|
||||||
|
|
||||||
|
foreach (string name in WritablePropertyNames(type))
|
||||||
|
{
|
||||||
|
data.Add(type, name);
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Names of the settings of a mode the user is able to change.</summary>
|
||||||
|
internal static IEnumerable<string> WritablePropertyNames(Type type) =>
|
||||||
|
type.GetProperties().Where(property => property.CanWrite).Select(property => property.Name);
|
||||||
|
|
||||||
|
private static PopupModeSettings Create(Type type) => (PopupModeSettings)Activator.CreateInstance(type)!;
|
||||||
|
|
||||||
|
private static List<string> Stored<TMode>(TMode mode) where TMode : PopupModeSettings
|
||||||
|
{
|
||||||
|
using JsonDocument document = JsonSerializer.SerializeToDocument(mode);
|
||||||
|
|
||||||
|
return [.. document.RootElement.EnumerateObject().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(PopupModeSettings mode, string propertyName)
|
||||||
|
{
|
||||||
|
PropertyInfo property = mode.GetType().GetProperty(propertyName)!;
|
||||||
|
|
||||||
|
object next = property.GetValue(mode) switch
|
||||||
|
{
|
||||||
|
double number => number + 1,
|
||||||
|
Color color => Color.FromArgb((byte)(color.R + 1), color.G, color.B),
|
||||||
|
Enum value => Enum.GetValues(value.GetType())
|
||||||
|
.Cast<object>()
|
||||||
|
.First(other => !Equals(other, value)),
|
||||||
|
|
||||||
|
var other => throw new NotSupportedException($"Unknown kind of setting: {other?.GetType()}"),
|
||||||
|
};
|
||||||
|
|
||||||
|
property.SetValue(mode, next);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
using System.Collections;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.Resources;
|
||||||
|
using CursorLang.Core.Models;
|
||||||
|
using CursorLang.Core.Services;
|
||||||
|
|
||||||
|
namespace CursorLang.Core.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 class StringsTests
|
||||||
|
{
|
||||||
|
private static readonly ResourceManager Resources =
|
||||||
|
new("CursorLang.Core.Resources.Strings", typeof(LocalizationService).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>
|
||||||
|
/// The version is put into the title by the app, so the place for it has to
|
||||||
|
/// be there in both languages: the title is the only place it is shown, and a
|
||||||
|
/// translation without the placeholder would quietly drop it.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void The_title_of_the_window_has_room_for_the_version()
|
||||||
|
{
|
||||||
|
Assert.Contains("{0}", Resources.GetString("SettingsTitle", English), StringComparison.Ordinal);
|
||||||
|
Assert.Contains("{0}", Resources.GetString("SettingsTitle", Russian), StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
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<CaretSide>())
|
||||||
|
{
|
||||||
|
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)!;
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
using CursorLang.Core.Models;
|
||||||
|
using CursorLang.Core.Services;
|
||||||
|
using CursorLang.Tests.Shared;
|
||||||
|
|
||||||
|
namespace CursorLang.Core.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.Current.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,286 @@
|
|||||||
|
using System.Collections.Concurrent;
|
||||||
|
using CursorLang.Core.Models;
|
||||||
|
using CursorLang.Core.Services;
|
||||||
|
using CursorLang.Tests.Shared;
|
||||||
|
|
||||||
|
namespace CursorLang.Core.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();
|
||||||
|
|
||||||
|
Pump.Run(service.Start);
|
||||||
|
world.LocaleId = Russian;
|
||||||
|
Pump.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();
|
||||||
|
|
||||||
|
Pump.Run(service.Start);
|
||||||
|
|
||||||
|
world.ForegroundWindow = SecondWindow;
|
||||||
|
world.LocaleId = Russian;
|
||||||
|
Pump.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();
|
||||||
|
|
||||||
|
Pump.Run(service.Start);
|
||||||
|
|
||||||
|
world.ForegroundWindow = SecondWindow;
|
||||||
|
Pump.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();
|
||||||
|
|
||||||
|
Pump.Run(service.Start);
|
||||||
|
|
||||||
|
for (int i = 0; i < 5; i++)
|
||||||
|
{
|
||||||
|
Pump.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();
|
||||||
|
|
||||||
|
Pump.Run(service.Start);
|
||||||
|
|
||||||
|
world.ForegroundWindow = IntPtr.Zero;
|
||||||
|
world.LocaleId = Russian;
|
||||||
|
Pump.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;
|
||||||
|
Pump.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();
|
||||||
|
|
||||||
|
Pump.Run(service.Start);
|
||||||
|
|
||||||
|
world.LocaleId = Russian;
|
||||||
|
Pump.Run(service.Poll);
|
||||||
|
Pump.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;
|
||||||
|
Pump.Run(service.Start);
|
||||||
|
Pump.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));
|
||||||
|
|
||||||
|
Pump.Run(service.Start);
|
||||||
|
world.LocaleId = Russian;
|
||||||
|
|
||||||
|
Pump.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));
|
||||||
|
|
||||||
|
Pump.Run(service.Start);
|
||||||
|
Pump.Run(service.Stop);
|
||||||
|
|
||||||
|
world.LocaleId = Russian;
|
||||||
|
Pump.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));
|
||||||
|
|
||||||
|
Pump.Run(service.Start);
|
||||||
|
Pump.Run(service.Dispose);
|
||||||
|
|
||||||
|
world.LocaleId = Russian;
|
||||||
|
Pump.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));
|
||||||
|
|
||||||
|
Pump.Run(service.Start);
|
||||||
|
Pump.Run(service.Stop);
|
||||||
|
Pump.Run(service.Start);
|
||||||
|
|
||||||
|
world.LocaleId = Russian;
|
||||||
|
|
||||||
|
Pump.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 = Pump.Run(() =>
|
||||||
|
new KeyboardLayoutService(new KeyboardLayoutOptions { PollInterval = TimeSpan.FromHours(1) }));
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Pump.Run(service.Start);
|
||||||
|
Pump.Run(service.Poll);
|
||||||
|
|
||||||
|
Assert.InRange(service.Current.LocaleId, 1, 0xFFFF);
|
||||||
|
|
||||||
|
Pump.Run(service.Stop);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
Pump.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)
|
||||||
|
{
|
||||||
|
var options = new KeyboardLayoutOptions
|
||||||
|
{
|
||||||
|
PollInterval = pollInterval ?? TimeSpan.FromHours(1),
|
||||||
|
};
|
||||||
|
|
||||||
|
_service = Pump.Run(() => new KeyboardLayoutService(
|
||||||
|
options,
|
||||||
|
() => ForegroundWindow,
|
||||||
|
() => LocaleId,
|
||||||
|
() => SwitchRequests++));
|
||||||
|
|
||||||
|
_service.LayoutChanged += (_, e) => Changes.Enqueue(e);
|
||||||
|
|
||||||
|
return _service;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (_service is not null)
|
||||||
|
{
|
||||||
|
Pump.Run(_service.Dispose);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
using CursorLang.Core.Models;
|
||||||
|
using CursorLang.Core.Services;
|
||||||
|
using CursorLang.Tests.Shared;
|
||||||
|
|
||||||
|
namespace CursorLang.Core.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,149 @@
|
|||||||
|
using System.ComponentModel;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.Windows.Data;
|
||||||
|
using CursorLang.Core.Services;
|
||||||
|
|
||||||
|
namespace CursorLang.Core.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,229 @@
|
|||||||
|
using CursorLang.Core.Interop;
|
||||||
|
using CursorLang.Core.Models;
|
||||||
|
using CursorLang.Core.Services;
|
||||||
|
|
||||||
|
namespace CursorLang.Core.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.Core.Models;
|
||||||
|
using CursorLang.Core.Services;
|
||||||
|
using CursorLang.Tests.Shared;
|
||||||
|
using Microsoft.Win32;
|
||||||
|
|
||||||
|
namespace CursorLang.Core.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,115 @@
|
|||||||
|
using System.Collections.Concurrent;
|
||||||
|
using CursorLang.Core.Services;
|
||||||
|
using CursorLang.Tests.Shared;
|
||||||
|
|
||||||
|
namespace CursorLang.Core.Tests.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The one thing the agent says to the settings window: quit with me.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The event names here are the tests' own. The application's name is fixed, and a test
|
||||||
|
/// listening on it would answer for a settings window someone is using — or, signalling,
|
||||||
|
/// close it.
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class SettingsCloseSignalTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void The_request_reaches_the_settings_window()
|
||||||
|
{
|
||||||
|
string suffix = UniqueSuffix();
|
||||||
|
var signal = new SettingsCloseSignal(suffix);
|
||||||
|
|
||||||
|
ConcurrentQueue<EventArgs> requests = new();
|
||||||
|
signal.CloseRequested += (_, e) => requests.Enqueue(e);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
signal.Listen();
|
||||||
|
|
||||||
|
Assert.True(RequestApart(suffix), "the request found nobody listening");
|
||||||
|
Pump.WaitFor(() => !requests.IsEmpty, "the settings window got the request to close");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
signal.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The usual case: the user quits from the tray with no settings window on the screen
|
||||||
|
[Fact]
|
||||||
|
public void A_request_with_no_settings_window_open_passes_without_consequence()
|
||||||
|
{
|
||||||
|
Assert.False(RequestApart(UniqueSuffix()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// The window has closed on its own, and the process is on its way out anyway
|
||||||
|
[Fact]
|
||||||
|
public void No_request_arrives_after_the_window_is_gone()
|
||||||
|
{
|
||||||
|
string suffix = UniqueSuffix();
|
||||||
|
var signal = new SettingsCloseSignal(suffix);
|
||||||
|
|
||||||
|
ConcurrentQueue<EventArgs> requests = new();
|
||||||
|
signal.CloseRequested += (_, e) => requests.Enqueue(e);
|
||||||
|
|
||||||
|
signal.Listen();
|
||||||
|
signal.Dispose();
|
||||||
|
|
||||||
|
RequestApart(suffix);
|
||||||
|
Pump.Pause(TimeSpan.FromMilliseconds(80));
|
||||||
|
|
||||||
|
Assert.Empty(requests);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Listening_twice_leaves_one_listener()
|
||||||
|
{
|
||||||
|
string suffix = UniqueSuffix();
|
||||||
|
var signal = new SettingsCloseSignal(suffix);
|
||||||
|
|
||||||
|
var requests = 0;
|
||||||
|
signal.CloseRequested += (_, _) => Interlocked.Increment(ref requests);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
signal.Listen();
|
||||||
|
signal.Listen();
|
||||||
|
|
||||||
|
Assert.True(RequestApart(suffix));
|
||||||
|
Pump.WaitFor(() => Volatile.Read(ref requests) > 0, "the request arrived");
|
||||||
|
Pump.Pause(TimeSpan.FromMilliseconds(80));
|
||||||
|
|
||||||
|
Assert.Equal(1, Volatile.Read(ref requests));
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
signal.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Closing_without_listening_passes_without_consequence()
|
||||||
|
{
|
||||||
|
var signal = new SettingsCloseSignal(UniqueSuffix());
|
||||||
|
|
||||||
|
signal.Dispose();
|
||||||
|
signal.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every test gets a namespace of kernel objects of its own
|
||||||
|
private static string UniqueSuffix() => "." + Guid.NewGuid().ToString("N");
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Asks for the close the way the agent does it — from another process, and here
|
||||||
|
/// from another thread, which is as foreign as a test can get.
|
||||||
|
/// </summary>
|
||||||
|
private static bool RequestApart(string suffix)
|
||||||
|
{
|
||||||
|
var heard = false;
|
||||||
|
|
||||||
|
Pump.RunApart(() => heard = SettingsCloseSignal.RequestClose(suffix));
|
||||||
|
|
||||||
|
return heard;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,653 @@
|
|||||||
|
using System.Drawing;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.Text.Json;
|
||||||
|
using CursorLang.Core.Models;
|
||||||
|
using CursorLang.Core.Services;
|
||||||
|
using CursorLang.Core.Tests.Models;
|
||||||
|
using CursorLang.Tests.Shared;
|
||||||
|
|
||||||
|
namespace CursorLang.Core.Tests.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Keeping the settings in a file. Only the settings window writes, and it asks for
|
||||||
|
/// that with TrackChanges; the agent loads the same file and never saves. 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 = Pump.Run(() =>
|
||||||
|
{
|
||||||
|
using SettingsService service = Create(folder);
|
||||||
|
return service.Load();
|
||||||
|
});
|
||||||
|
|
||||||
|
Assert.Equal(AppTheme.System, settings.Theme);
|
||||||
|
Assert.Equal(20, settings.Current.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 = Pump.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();
|
||||||
|
|
||||||
|
Pump.Run(() =>
|
||||||
|
{
|
||||||
|
using SettingsService service = Create(folder);
|
||||||
|
AppSettings settings = service.Load();
|
||||||
|
|
||||||
|
settings.Theme = AppTheme.Dark;
|
||||||
|
settings.PlacementMode = PopupPlacementMode.AtCaret;
|
||||||
|
settings.Current.FontSize = 42;
|
||||||
|
settings.Current.BackgroundColor = Color.FromArgb(0x11, 0x22, 0x33);
|
||||||
|
settings.UseCapsLockHotkey = true;
|
||||||
|
|
||||||
|
service.Save();
|
||||||
|
});
|
||||||
|
|
||||||
|
AppSettings restored = Pump.Run(() =>
|
||||||
|
{
|
||||||
|
using SettingsService service = Create(folder);
|
||||||
|
return service.Load();
|
||||||
|
});
|
||||||
|
|
||||||
|
Assert.Equal(42, restored.Current.FontSize);
|
||||||
|
Assert.Equal(AppTheme.Dark, restored.Theme);
|
||||||
|
Assert.Equal(PopupPlacementMode.AtCaret, restored.PlacementMode);
|
||||||
|
Assert.Equal(Color.FromArgb(0x11, 0x22, 0x33), restored.Current.BackgroundColor);
|
||||||
|
Assert.True(restored.UseCapsLockHotkey);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void The_settings_land_in_the_file_in_a_readable_form()
|
||||||
|
{
|
||||||
|
using var folder = new TempFolder();
|
||||||
|
|
||||||
|
Pump.Run(() =>
|
||||||
|
{
|
||||||
|
using SettingsService service = Create(folder);
|
||||||
|
AppSettings settings = service.Load();
|
||||||
|
settings.Theme = AppTheme.Dark;
|
||||||
|
settings.Current.BackgroundColor = Color.FromArgb(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");
|
||||||
|
|
||||||
|
Pump.Run(() =>
|
||||||
|
{
|
||||||
|
using SettingsService service = Create(folder);
|
||||||
|
AppSettings settings = service.Load();
|
||||||
|
service.TrackChanges();
|
||||||
|
|
||||||
|
settings.Current.FontSize = 33;
|
||||||
|
|
||||||
|
// Right after the edit there is nothing on disk yet: the write is deferred
|
||||||
|
Assert.False(File.Exists(path));
|
||||||
|
|
||||||
|
Pump.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");
|
||||||
|
|
||||||
|
Pump.Run(() =>
|
||||||
|
{
|
||||||
|
using SettingsService service = new(path, folder.File("inherited.json"), TimeSpan.FromMilliseconds(150));
|
||||||
|
AppSettings settings = service.Load();
|
||||||
|
service.TrackChanges();
|
||||||
|
|
||||||
|
for (int i = 0; i < 10; i++)
|
||||||
|
{
|
||||||
|
settings.Current.Opacity = 0.5 + (i * 0.01);
|
||||||
|
Assert.False(File.Exists(path));
|
||||||
|
Pump.Pause(TimeSpan.FromMilliseconds(10));
|
||||||
|
}
|
||||||
|
|
||||||
|
Pump.WaitFor(() => File.Exists(path), "the write happened after the pause in edits");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Asking to track changes before reading the file still tracks them.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The settings window asks in exactly that order: its container hands out the
|
||||||
|
/// service first and the settings only when something needs them. A version of this
|
||||||
|
/// that quietly did nothing when the file had not been read yet left the window
|
||||||
|
/// saving nothing at all — neither while it was open nor when it was closed.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public void Tracking_asked_for_before_the_file_is_read_still_saves()
|
||||||
|
{
|
||||||
|
using var folder = new TempFolder();
|
||||||
|
string path = folder.File("settings.json");
|
||||||
|
|
||||||
|
Pump.Run(() =>
|
||||||
|
{
|
||||||
|
using SettingsService service = Create(folder);
|
||||||
|
|
||||||
|
// Before Load, the way the settings window does it
|
||||||
|
service.TrackChanges();
|
||||||
|
|
||||||
|
AppSettings settings = service.Load();
|
||||||
|
settings.Current.FontSize = 29;
|
||||||
|
|
||||||
|
Pump.WaitFor(() => File.Exists(path), "the settings were written by the timer");
|
||||||
|
});
|
||||||
|
|
||||||
|
Assert.Contains("\"FontSize\": 29", File.ReadAllText(path), StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Two reads would mean two instances, and the window would edit one while the
|
||||||
|
// service saved the other
|
||||||
|
[Fact]
|
||||||
|
public void Reading_twice_hands_out_the_same_settings()
|
||||||
|
{
|
||||||
|
using var folder = new TempFolder();
|
||||||
|
|
||||||
|
Pump.Run(() =>
|
||||||
|
{
|
||||||
|
using SettingsService service = Create(folder);
|
||||||
|
|
||||||
|
Assert.Same(service.Load(), service.Load());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Re-reading pours the file into the instance everything is already bound to.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// This is the agent's whole side of the connection: the settings window writes and
|
||||||
|
/// says so, and the agent calls this. Replacing the instance instead of filling it
|
||||||
|
/// would leave the popup, the hook and the timers bound to the old one.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public void Re_reading_lands_in_the_settings_already_in_hand()
|
||||||
|
{
|
||||||
|
using var folder = new TempFolder();
|
||||||
|
string path = folder.File("settings.json");
|
||||||
|
|
||||||
|
Pump.Run(() =>
|
||||||
|
{
|
||||||
|
using SettingsService service = Create(folder);
|
||||||
|
AppSettings settings = service.Load();
|
||||||
|
|
||||||
|
File.WriteAllText(path, """{"AtCursor": {"FontSize": 31, "BackgroundColor": "#FF102030"}}""");
|
||||||
|
service.Reload();
|
||||||
|
|
||||||
|
Assert.Equal(31, settings.Current.FontSize);
|
||||||
|
Assert.Equal(Color.FromArgb(0x10, 0x20, 0x30), settings.Current.BackgroundColor);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// A file that has gone missing or turned to nonsense leaves the settings alone:
|
||||||
|
// showing the popup with yesterday's colours beats showing it with none
|
||||||
|
[Fact]
|
||||||
|
public void Re_reading_an_unreadable_file_keeps_what_was_already_there()
|
||||||
|
{
|
||||||
|
using var folder = new TempFolder();
|
||||||
|
string path = folder.File("settings.json");
|
||||||
|
|
||||||
|
Pump.Run(() =>
|
||||||
|
{
|
||||||
|
using SettingsService service = Create(folder);
|
||||||
|
AppSettings settings = service.Load();
|
||||||
|
settings.Current.FontSize = 44;
|
||||||
|
|
||||||
|
File.WriteAllText(path, "not json at all");
|
||||||
|
service.Reload();
|
||||||
|
|
||||||
|
Assert.Equal(44, settings.Current.FontSize);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Closing_the_service_saves_the_latest_edits()
|
||||||
|
{
|
||||||
|
using var folder = new TempFolder();
|
||||||
|
|
||||||
|
Pump.Run(() =>
|
||||||
|
{
|
||||||
|
SettingsService service = Create(folder);
|
||||||
|
AppSettings settings = service.Load();
|
||||||
|
service.TrackChanges();
|
||||||
|
settings.Current.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");
|
||||||
|
|
||||||
|
Pump.Run(() =>
|
||||||
|
{
|
||||||
|
SettingsService service = Create(folder);
|
||||||
|
AppSettings settings = service.Load();
|
||||||
|
service.TrackChanges();
|
||||||
|
service.Dispose();
|
||||||
|
|
||||||
|
string afterDispose = File.ReadAllText(path);
|
||||||
|
|
||||||
|
settings.Current.FontSize = 99;
|
||||||
|
Pump.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, """{"AtCursor": {"FontSize": 31}, "Language": "ru"}""");
|
||||||
|
|
||||||
|
AppSettings settings = Pump.Run(() =>
|
||||||
|
{
|
||||||
|
using SettingsService service = new(own, inherited, SaveDelay);
|
||||||
|
return service.Load();
|
||||||
|
});
|
||||||
|
|
||||||
|
Assert.Equal(31, settings.Current.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 = """{"AtCursor": {"FontSize": 31}}""";
|
||||||
|
|
||||||
|
File.WriteAllText(inherited, original);
|
||||||
|
|
||||||
|
Pump.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, """{"AtCursor": {"FontSize": 12}}""");
|
||||||
|
File.WriteAllText(inherited, """{"AtCursor": {"FontSize": 31}}""");
|
||||||
|
|
||||||
|
AppSettings settings = Pump.Run(() =>
|
||||||
|
{
|
||||||
|
using SettingsService service = new(own, inherited, SaveDelay);
|
||||||
|
return service.Load();
|
||||||
|
});
|
||||||
|
|
||||||
|
Assert.Equal(12, settings.Current.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");
|
||||||
|
|
||||||
|
Pump.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 = Pump.Run(() =>
|
||||||
|
{
|
||||||
|
using SettingsService service = Create(folder);
|
||||||
|
return service.Load();
|
||||||
|
});
|
||||||
|
|
||||||
|
Assert.Equal(20, settings.Current.FontSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Settings_with_unknown_fields_are_still_read()
|
||||||
|
{
|
||||||
|
using var folder = new TempFolder();
|
||||||
|
File.WriteAllText(folder.File("settings.json"), """{"AtCursor": {"FontSize": 15}, "SomethingNew": true}""");
|
||||||
|
|
||||||
|
AppSettings settings = Pump.Run(() =>
|
||||||
|
{
|
||||||
|
using SettingsService service = Create(folder);
|
||||||
|
return service.Load();
|
||||||
|
});
|
||||||
|
|
||||||
|
Assert.Equal(15, settings.Current.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"), $$"""{"AtCursor": {"BackgroundColor": {{stored}} } }""");
|
||||||
|
|
||||||
|
AppSettings settings = Pump.Run(() =>
|
||||||
|
{
|
||||||
|
using SettingsService service = Create(folder);
|
||||||
|
return service.Load();
|
||||||
|
});
|
||||||
|
|
||||||
|
Assert.Equal(Color.FromArgb(r, g, b), settings.Current.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"), $$"""{"AtCursor": {"BackgroundColor": {{stored}} } }""");
|
||||||
|
|
||||||
|
AppSettings settings = Pump.Run(() =>
|
||||||
|
{
|
||||||
|
using SettingsService service = Create(folder);
|
||||||
|
return service.Load();
|
||||||
|
});
|
||||||
|
|
||||||
|
Assert.Equal(Color.Black, settings.Current.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");
|
||||||
|
|
||||||
|
Pump.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);
|
||||||
|
|
||||||
|
Pump.Run(() =>
|
||||||
|
{
|
||||||
|
using SettingsService service = new(path, folder.File("inherited.json"), SaveDelay);
|
||||||
|
AppSettings settings = service.Load();
|
||||||
|
|
||||||
|
settings.Current.FontSize = 18;
|
||||||
|
service.Save();
|
||||||
|
});
|
||||||
|
|
||||||
|
Assert.True(Directory.Exists(path));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Saving_without_loading_writes_nothing()
|
||||||
|
{
|
||||||
|
using var folder = new TempFolder();
|
||||||
|
|
||||||
|
Pump.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();
|
||||||
|
|
||||||
|
Pump.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();
|
||||||
|
|
||||||
|
Pump.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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The modes are sections of their own, and every setting of a mode has to reach the
|
||||||
|
// file inside its own section
|
||||||
|
[Theory]
|
||||||
|
[InlineData(nameof(AppSettings.AtCursor), typeof(CursorModeSettings))]
|
||||||
|
[InlineData(nameof(AppSettings.AtCaret), typeof(CaretModeSettings))]
|
||||||
|
[InlineData(nameof(AppSettings.FixedPoint), typeof(FixedPointModeSettings))]
|
||||||
|
public void Every_setting_of_a_mode_reaches_its_section_of_the_file(string section, Type mode)
|
||||||
|
{
|
||||||
|
using var folder = new TempFolder();
|
||||||
|
|
||||||
|
Pump.Run(() =>
|
||||||
|
{
|
||||||
|
using SettingsService service = Create(folder);
|
||||||
|
_ = service.Load();
|
||||||
|
service.Save();
|
||||||
|
});
|
||||||
|
|
||||||
|
using JsonDocument document = JsonDocument.Parse(File.ReadAllText(folder.File("settings.json")));
|
||||||
|
|
||||||
|
Assert.True(document.RootElement.TryGetProperty(section, out JsonElement stored));
|
||||||
|
|
||||||
|
foreach (string name in PopupModeSettingsTests.WritablePropertyNames(mode))
|
||||||
|
{
|
||||||
|
Assert.True(stored.TryGetProperty(name, out _), $"{section}.{name}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The modes are stored apart: what is set up in one is still there after a trip
|
||||||
|
/// through the file and the other two.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void Each_mode_keeps_its_own_settings()
|
||||||
|
{
|
||||||
|
using var folder = new TempFolder();
|
||||||
|
|
||||||
|
Pump.Run(() =>
|
||||||
|
{
|
||||||
|
using SettingsService service = Create(folder);
|
||||||
|
AppSettings settings = service.Load();
|
||||||
|
|
||||||
|
settings.AtCursor.Side = AnchorSide.TopLeft;
|
||||||
|
settings.AtCursor.FontSize = 14;
|
||||||
|
settings.AtCaret.Side = CaretSide.Left;
|
||||||
|
settings.AtCaret.FontSize = 28;
|
||||||
|
settings.AtCaret.ForegroundColor = Color.FromArgb(0x0A, 0x0B, 0x0C);
|
||||||
|
settings.FixedPoint.Position = ScreenPosition.Top;
|
||||||
|
settings.FixedPoint.Offset = 96;
|
||||||
|
settings.FixedPoint.Opacity = 0.4;
|
||||||
|
|
||||||
|
service.Save();
|
||||||
|
});
|
||||||
|
|
||||||
|
AppSettings restored = Pump.Run(() =>
|
||||||
|
{
|
||||||
|
using SettingsService service = Create(folder);
|
||||||
|
return service.Load();
|
||||||
|
});
|
||||||
|
|
||||||
|
Assert.Equal(AnchorSide.TopLeft, restored.AtCursor.Side);
|
||||||
|
Assert.Equal(14, restored.AtCursor.FontSize);
|
||||||
|
Assert.Equal(CaretSide.Left, restored.AtCaret.Side);
|
||||||
|
Assert.Equal(28, restored.AtCaret.FontSize);
|
||||||
|
Assert.Equal(Color.FromArgb(0x0A, 0x0B, 0x0C), restored.AtCaret.ForegroundColor);
|
||||||
|
Assert.Equal(ScreenPosition.Top, restored.FixedPoint.Position);
|
||||||
|
Assert.Equal(96, restored.FixedPoint.Offset);
|
||||||
|
Assert.Equal(0.4, restored.FixedPoint.Opacity);
|
||||||
|
|
||||||
|
// What was left alone stays at its default rather than following a neighbour
|
||||||
|
Assert.Equal(0.9, restored.AtCursor.Opacity);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A change inside a mode is a change of the settings: the file has to follow the
|
||||||
|
// sliders of the look as well as the ones above them
|
||||||
|
[Fact]
|
||||||
|
public void A_change_inside_a_mode_saves_itself()
|
||||||
|
{
|
||||||
|
using var folder = new TempFolder();
|
||||||
|
string path = folder.File("settings.json");
|
||||||
|
|
||||||
|
Pump.Run(() =>
|
||||||
|
{
|
||||||
|
using SettingsService service = Create(folder);
|
||||||
|
AppSettings settings = service.Load();
|
||||||
|
service.TrackChanges();
|
||||||
|
|
||||||
|
settings.AtCaret.FontSize = 37;
|
||||||
|
|
||||||
|
Pump.WaitFor(() => File.Exists(path), "the settings were written by the timer");
|
||||||
|
});
|
||||||
|
|
||||||
|
using JsonDocument document = JsonDocument.Parse(File.ReadAllText(path));
|
||||||
|
|
||||||
|
Assert.Equal(
|
||||||
|
37,
|
||||||
|
document.RootElement
|
||||||
|
.GetProperty(nameof(AppSettings.AtCaret))
|
||||||
|
.GetProperty(nameof(PopupModeSettings.FontSize))
|
||||||
|
.GetDouble());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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()
|
||||||
|
{
|
||||||
|
Pump.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,207 @@
|
|||||||
|
using System.Collections.Concurrent;
|
||||||
|
using CursorLang.Core.Services;
|
||||||
|
using CursorLang.Tests.Shared;
|
||||||
|
|
||||||
|
namespace CursorLang.Core.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 = Pump.Run(() => new SingleInstanceGate(suffix));
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Assert.True(Pump.Run(gate.TryAcquire));
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
Pump.Run(gate.Dispose);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void The_second_run_does_not_get_the_place()
|
||||||
|
{
|
||||||
|
string suffix = UniqueSuffix();
|
||||||
|
SingleInstanceGate first = Pump.Run(() => new SingleInstanceGate(suffix));
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Assert.True(Pump.Run(first.TryAcquire));
|
||||||
|
Assert.False(TryAcquireApart(suffix));
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
Pump.Run(first.Dispose);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void The_second_run_asks_the_running_one_to_show_its_window()
|
||||||
|
{
|
||||||
|
string suffix = UniqueSuffix();
|
||||||
|
SingleInstanceGate first = Pump.Run(() => new SingleInstanceGate(suffix));
|
||||||
|
|
||||||
|
ConcurrentQueue<EventArgs> requests = new();
|
||||||
|
first.ActivationRequested += (_, e) => requests.Enqueue(e);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Pump.Run(first.TryAcquire);
|
||||||
|
Assert.False(TryAcquireApart(suffix));
|
||||||
|
|
||||||
|
Pump.WaitFor(() => !requests.IsEmpty, "the running instance got the request to show itself");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
Pump.Run(first.Dispose);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Without_a_second_run_no_request_arrives()
|
||||||
|
{
|
||||||
|
string suffix = UniqueSuffix();
|
||||||
|
SingleInstanceGate gate = Pump.Run(() => new SingleInstanceGate(suffix));
|
||||||
|
|
||||||
|
ConcurrentQueue<EventArgs> requests = new();
|
||||||
|
gate.ActivationRequested += (_, e) => requests.Enqueue(e);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Pump.Run(gate.TryAcquire);
|
||||||
|
Pump.Pause(TimeSpan.FromMilliseconds(80));
|
||||||
|
|
||||||
|
Assert.Empty(requests);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
Pump.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 = Pump.Run(() => new SingleInstanceGate(suffix));
|
||||||
|
Assert.True(Pump.Run(first.TryAcquire));
|
||||||
|
Pump.Run(first.Dispose);
|
||||||
|
|
||||||
|
Assert.True(TryAcquireApart(suffix));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void No_requests_arrive_after_the_exit()
|
||||||
|
{
|
||||||
|
string suffix = UniqueSuffix();
|
||||||
|
SingleInstanceGate first = Pump.Run(() => new SingleInstanceGate(suffix));
|
||||||
|
|
||||||
|
ConcurrentQueue<EventArgs> requests = new();
|
||||||
|
first.ActivationRequested += (_, e) => requests.Enqueue(e);
|
||||||
|
|
||||||
|
Pump.Run(first.TryAcquire);
|
||||||
|
Pump.Run(first.Dispose);
|
||||||
|
|
||||||
|
// The place is free, so the new run simply takes it for itself
|
||||||
|
Assert.True(TryAcquireApart(suffix));
|
||||||
|
Pump.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
|
||||||
|
Pump.RunApart(() =>
|
||||||
|
{
|
||||||
|
var abandoned = new Mutex(initiallyOwned: false, "CursorLang.SingleInstance" + suffix);
|
||||||
|
abandoned.WaitOne(TimeSpan.Zero, exitContext: false);
|
||||||
|
});
|
||||||
|
|
||||||
|
SingleInstanceGate gate = Pump.Run(() => new SingleInstanceGate(suffix));
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Assert.True(Pump.Run(gate.TryAcquire));
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
Pump.Run(gate.Dispose);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Closing_without_taking_the_place_passes_without_consequence()
|
||||||
|
{
|
||||||
|
SingleInstanceGate gate = Pump.Run(() => new SingleInstanceGate(UniqueSuffix()));
|
||||||
|
|
||||||
|
Pump.Run(gate.Dispose);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Each half of the application takes a place of its own: one background process
|
||||||
|
// and one settings window, and neither gets in the other's way
|
||||||
|
[Theory]
|
||||||
|
[InlineData(SingleInstanceGate.AgentName)]
|
||||||
|
[InlineData(SingleInstanceGate.SettingsName)]
|
||||||
|
public void Each_half_of_the_application_takes_a_place_of_its_own(string name)
|
||||||
|
{
|
||||||
|
SingleInstanceGate gate = Pump.Run(() => new SingleInstanceGate(name));
|
||||||
|
|
||||||
|
// The place may be held by a running application — then it is simply not taken
|
||||||
|
Pump.Run(gate.Dispose);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Closing_twice_passes_without_consequence()
|
||||||
|
{
|
||||||
|
SingleInstanceGate gate = Pump.Run(() => new SingleInstanceGate(UniqueSuffix()));
|
||||||
|
|
||||||
|
Pump.Run(gate.TryAcquire);
|
||||||
|
Pump.Run(gate.Dispose);
|
||||||
|
Pump.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;
|
||||||
|
|
||||||
|
Pump.RunApart(() =>
|
||||||
|
{
|
||||||
|
var gate = new SingleInstanceGate(suffix);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
acquired = gate.TryAcquire();
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
gate.Dispose();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return acquired;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
using CursorLang.Core.Services;
|
||||||
|
using CursorLang.Tests.Shared;
|
||||||
|
|
||||||
|
namespace CursorLang.Core.Tests.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Telling a launch by Windows apart from a launch by the user: the first one goes
|
||||||
|
/// to the tray without a window, the second one is what the window is for.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class StartupLaunchTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void A_launch_by_the_user_carries_no_argument()
|
||||||
|
{
|
||||||
|
Assert.False(StartupLaunch.HasArgument([]));
|
||||||
|
Assert.False(StartupLaunch.IsAutomatic([]));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void The_startup_entry_says_so_in_the_command_line()
|
||||||
|
{
|
||||||
|
Assert.True(StartupLaunch.HasArgument([StartupLaunch.Argument]));
|
||||||
|
Assert.True(StartupLaunch.IsAutomatic([StartupLaunch.Argument]));
|
||||||
|
}
|
||||||
|
|
||||||
|
// The argument does not have to come first: Windows may put its own
|
||||||
|
// alongside it one day
|
||||||
|
[Fact]
|
||||||
|
public void The_argument_is_looked_for_among_the_others()
|
||||||
|
{
|
||||||
|
Assert.True(StartupLaunch.HasArgument(["--whatever", StartupLaunch.Argument]));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void The_case_of_the_argument_does_not_matter()
|
||||||
|
{
|
||||||
|
Assert.True(StartupLaunch.HasArgument([StartupLaunch.Argument.ToUpperInvariant()]));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Anything_else_is_a_launch_by_the_user()
|
||||||
|
{
|
||||||
|
Assert.False(StartupLaunch.HasArgument(["--startupp", "startup", "-startup"]));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The command written into the registry carries the argument: that is the whole
|
||||||
|
/// point of the argument.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void The_startup_entry_is_written_with_the_argument()
|
||||||
|
{
|
||||||
|
using var agent = new StagedAgentExecutable();
|
||||||
|
|
||||||
|
string? command = RegistryStartup.GetCommand();
|
||||||
|
|
||||||
|
Assert.NotNull(command);
|
||||||
|
Assert.EndsWith(StartupLaunch.Argument, command, StringComparison.Ordinal);
|
||||||
|
|
||||||
|
// And the path itself stays quoted: it has spaces in it more often than not
|
||||||
|
Assert.StartsWith("\"", command, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Windows must start the agent, whoever asked for it.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The checkbox lives in the settings window, which is a process of its own. Were
|
||||||
|
/// the entry written from the path of whoever is running, the startup list would
|
||||||
|
/// hold the settings window — a process that shows a window and exits, instead of
|
||||||
|
/// the one that is supposed to sit in the tray.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public void The_startup_entry_names_the_agent_rather_than_whoever_wrote_it()
|
||||||
|
{
|
||||||
|
using var agent = new StagedAgentExecutable();
|
||||||
|
|
||||||
|
string? command = RegistryStartup.GetCommand();
|
||||||
|
|
||||||
|
Assert.NotNull(command);
|
||||||
|
Assert.Contains("CursorLang.exe", command, StringComparison.OrdinalIgnoreCase);
|
||||||
|
Assert.DoesNotContain("CursorLang.Settings.exe", command, StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Without an agent on disk there is nothing to put in the startup list, and
|
||||||
|
// pointing Windows at a file that is not there would be worse than saying nothing
|
||||||
|
[Fact]
|
||||||
|
public void Without_an_agent_on_disk_there_is_no_entry_to_write()
|
||||||
|
{
|
||||||
|
string agent = Path.Combine(AppContext.BaseDirectory, "CursorLang.exe");
|
||||||
|
if (File.Exists(agent))
|
||||||
|
{
|
||||||
|
Assert.Skip("The agent is built into the test output folder — nothing to check here");
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert.Null(RegistryStartup.GetCommand());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
using CursorLang.Core.Interop;
|
||||||
|
using CursorLang.Core.Models;
|
||||||
|
using CursorLang.Core.Services;
|
||||||
|
using CursorLang.Tests.Shared;
|
||||||
|
using Windows.ApplicationModel;
|
||||||
|
|
||||||
|
namespace CursorLang.Core.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
using var agent = new StagedAgentExecutable();
|
||||||
|
|
||||||
|
// 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,129 @@
|
|||||||
|
using CursorLang.Core.Threading;
|
||||||
|
using CursorLang.Tests.Shared;
|
||||||
|
|
||||||
|
namespace CursorLang.Core.Tests.Threading;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The timer the agent has instead of a dispatcher timer.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// It ticks on the message loop, so everything here runs on the pump thread — a timer
|
||||||
|
/// started on one thread and awaited on another would never be seen to fire.
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class MessageTimerTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void A_started_timer_ticks()
|
||||||
|
{
|
||||||
|
int ticks = 0;
|
||||||
|
|
||||||
|
Pump.Run(() =>
|
||||||
|
{
|
||||||
|
using var timer = new MessageTimer();
|
||||||
|
timer.Interval = TimeSpan.FromMilliseconds(15);
|
||||||
|
timer.Tick += (_, _) => ticks++;
|
||||||
|
timer.Start();
|
||||||
|
|
||||||
|
Pump.WaitFor(() => ticks > 0, "the timer ticked");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_timer_keeps_ticking_until_it_is_stopped()
|
||||||
|
{
|
||||||
|
var ticks = 0;
|
||||||
|
|
||||||
|
Pump.Run(() =>
|
||||||
|
{
|
||||||
|
using var timer = new MessageTimer();
|
||||||
|
timer.Interval = TimeSpan.FromMilliseconds(15);
|
||||||
|
timer.Tick += (_, _) => ticks++;
|
||||||
|
timer.Start();
|
||||||
|
|
||||||
|
Pump.WaitFor(() => ticks >= 3, "the timer ticked more than once");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// A stopped countdown does not go off, however long the loop runs afterwards
|
||||||
|
[Fact]
|
||||||
|
public void A_stopped_timer_does_not_tick()
|
||||||
|
{
|
||||||
|
var ticks = 0;
|
||||||
|
|
||||||
|
Pump.Run(() =>
|
||||||
|
{
|
||||||
|
using var timer = new MessageTimer();
|
||||||
|
timer.Interval = TimeSpan.FromMilliseconds(10);
|
||||||
|
timer.Tick += (_, _) => ticks++;
|
||||||
|
timer.Start();
|
||||||
|
timer.Stop();
|
||||||
|
|
||||||
|
Pump.Pause(TimeSpan.FromMilliseconds(80));
|
||||||
|
});
|
||||||
|
|
||||||
|
Assert.Equal(0, ticks);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restarting means from zero, so a countdown kept short by repeated restarts
|
||||||
|
// never reaches its end
|
||||||
|
[Fact]
|
||||||
|
public void Restarting_begins_the_countdown_again()
|
||||||
|
{
|
||||||
|
var ticks = 0;
|
||||||
|
|
||||||
|
Pump.Run(() =>
|
||||||
|
{
|
||||||
|
using var timer = new MessageTimer();
|
||||||
|
timer.Interval = TimeSpan.FromMilliseconds(60);
|
||||||
|
timer.Tick += (_, _) => ticks++;
|
||||||
|
|
||||||
|
for (var i = 0; i < 6; i++)
|
||||||
|
{
|
||||||
|
timer.Start();
|
||||||
|
Pump.Pause(TimeSpan.FromMilliseconds(20));
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert.Equal(0, ticks);
|
||||||
|
|
||||||
|
Pump.WaitFor(() => ticks > 0, "left alone, the timer reached its end");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Closing_the_timer_ends_the_ticking()
|
||||||
|
{
|
||||||
|
var ticks = 0;
|
||||||
|
|
||||||
|
Pump.Run(() =>
|
||||||
|
{
|
||||||
|
var timer = new MessageTimer { Interval = TimeSpan.FromMilliseconds(15) };
|
||||||
|
timer.Tick += (_, _) => ticks++;
|
||||||
|
timer.Start();
|
||||||
|
|
||||||
|
Pump.WaitFor(() => ticks > 0, "the timer ticked");
|
||||||
|
timer.Dispose();
|
||||||
|
|
||||||
|
int seen = ticks;
|
||||||
|
Pump.Pause(TimeSpan.FromMilliseconds(80));
|
||||||
|
|
||||||
|
Assert.Equal(seen, ticks);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_timer_that_was_never_started_says_so()
|
||||||
|
{
|
||||||
|
Pump.Run(() =>
|
||||||
|
{
|
||||||
|
using var timer = new MessageTimer();
|
||||||
|
|
||||||
|
Assert.False(timer.IsRunning);
|
||||||
|
|
||||||
|
timer.Start();
|
||||||
|
Assert.True(timer.IsRunning);
|
||||||
|
|
||||||
|
timer.Stop();
|
||||||
|
Assert.False(timer.IsRunning);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
|
||||||
|
// Core keeps its interop and its arithmetic internal, as it always did. The two
|
||||||
|
// processes built on it are not outside consumers but the other halves of the same
|
||||||
|
// application, so they are let in rather than the surface being widened for them.
|
||||||
|
[assembly: InternalsVisibleTo("CursorLang")]
|
||||||
|
[assembly: InternalsVisibleTo("CursorLang.Settings")]
|
||||||
|
[assembly: InternalsVisibleTo("CursorLang.Core.Tests")]
|
||||||
|
[assembly: InternalsVisibleTo("CursorLang.Agent.Tests")]
|
||||||
|
[assembly: InternalsVisibleTo("CursorLang.Settings.Tests")]
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
<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>
|
||||||
|
<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
|
||||||
|
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||||
|
<NoWarn>$(NoWarn);CS1591</NoWarn>
|
||||||
|
<RootNamespace>CursorLang.Core</RootNamespace>
|
||||||
|
<AssemblyName>CursorLang.Core</AssemblyName>
|
||||||
|
<PlatformTarget>AnyCPU</PlatformTarget>
|
||||||
|
<RuntimeIdentifiers>win-x64</RuntimeIdentifiers>
|
||||||
|
<Version>1.0.0</Version>
|
||||||
|
<AssemblyVersion>1.0.0.0</AssemblyVersion>
|
||||||
|
<FileVersion>1.0.0.0</FileVersion>
|
||||||
|
<Product>CursorLang</Product>
|
||||||
|
<Company>Aleksandr Neichev</Company>
|
||||||
|
<Description>Shared part of CursorLang: models, settings, layout tracking</Description>
|
||||||
|
<Copyright>Copyright (c) 2026</Copyright>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<CaretUiAutomation Condition="'$(CaretUiAutomation)' == ''">true</CaretUiAutomation>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<PropertyGroup Condition="'$(CaretUiAutomation)' == 'true'">
|
||||||
|
<DefineConstants>$(DefineConstants);CARET_UI_AUTOMATION</DefineConstants>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<FrameworkReference Include="Microsoft.WindowsDesktop.App.WPF" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.2" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
using System.Runtime.InteropServices;
|
||||||
|
#if CARET_UI_AUTOMATION
|
||||||
|
using System.Windows.Automation;
|
||||||
|
using System.Windows.Automation.Text;
|
||||||
|
#endif
|
||||||
|
using Accessibility;
|
||||||
|
|
||||||
|
namespace CursorLang.Core.Interop;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Locates the caret in the active input field — including one in another application.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// 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.
|
||||||
|
///
|
||||||
|
/// The UI Automation step is behind <c>CARET_UI_AUTOMATION</c>: it is the one part of
|
||||||
|
/// the background process that reaches into the WPF half of the desktop runtime —
|
||||||
|
/// TextPatternRange hands its rectangles back as System.Windows.Rect, which lives in
|
||||||
|
/// WindowsBase — and it was measured at +3.9 MB private. It is also the last of the
|
||||||
|
/// three steps and rarely reached. See the switch in CursorLang.Core.csproj.
|
||||||
|
/// </remarks>
|
||||||
|
internal static class CaretNative
|
||||||
|
{
|
||||||
|
[DllImport("user32.dll")]
|
||||||
|
private static extern bool ClientToScreen(IntPtr hWnd, ref PopupWindowNative.Point lpPoint);
|
||||||
|
|
||||||
|
[DllImport("oleacc.dll")]
|
||||||
|
private static extern int AccessibleObjectFromWindow(IntPtr hwnd, uint dwObjectId,
|
||||||
|
ref Guid riid, out IAccessible ppvObject);
|
||||||
|
|
||||||
|
[DllImport("user32.dll")]
|
||||||
|
private static extern bool GetWindowRect(IntPtr hWnd, out PopupWindowNative.Rect lpRect);
|
||||||
|
|
||||||
|
[DllImport("user32.dll")]
|
||||||
|
private static extern uint GetDpiForWindow(IntPtr hWnd);
|
||||||
|
|
||||||
|
private const uint OBJID_CARET = 0xFFFFFFF8;
|
||||||
|
private const int CHILDID_SELF = 0;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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()
|
||||||
|
{
|
||||||
|
if (!ForegroundInputNative.TryGetInfo(out ForegroundInputNative.GuiThreadInfo info))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
PopupWindowNative.Rect? caret = TryGetSystemCaret(info)
|
||||||
|
?? TryGetAccessibleCaret(info.hwndFocus)
|
||||||
|
?? TryGetAutomationCaret();
|
||||||
|
|
||||||
|
return caret is null ? null : Validate(caret.Value, info.hwndFocus);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <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)
|
||||||
|
{
|
||||||
|
if (hwndFocus == IntPtr.Zero || !GetWindowRect(hwndFocus, out PopupWindowNative.Rect window))
|
||||||
|
{
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
var scaled = new PopupWindowNative.Rect
|
||||||
|
{
|
||||||
|
Left = (int)(caret.Left * scale),
|
||||||
|
Top = (int)(caret.Top * scale),
|
||||||
|
Right = (int)(caret.Right * scale),
|
||||||
|
Bottom = (int)(caret.Bottom * scale),
|
||||||
|
};
|
||||||
|
|
||||||
|
return IsInside(scaled, window) ? scaled : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
#if CARET_UI_AUTOMATION
|
||||||
|
/// <summary>How long we wait for another application to answer over UI Automation.</summary>
|
||||||
|
private static readonly TimeSpan AutomationTimeout = TimeSpan.FromMilliseconds(150);
|
||||||
|
|
||||||
|
// 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 the popup goes to the fixed point
|
||||||
|
Task<PopupWindowNative.Rect?> query = Task.Run(QueryAutomationCaret);
|
||||||
|
return query.Wait(AutomationTimeout) ? query.Result : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static PopupWindowNative.Rect? QueryAutomationCaret()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
AutomationElement focused = AutomationElement.FocusedElement;
|
||||||
|
if (focused is null ||
|
||||||
|
!focused.TryGetCurrentPattern(TextPattern.Pattern, out object pattern))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
TextPatternRange[] selection = ((TextPattern)pattern).GetSelection();
|
||||||
|
if (selection.Length == 0)
|
||||||
|
{
|
||||||
|
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);
|
||||||
|
|
||||||
|
System.Windows.Rect[] rectangles = range.GetBoundingRectangles();
|
||||||
|
if (rectangles.Length == 0 || rectangles[0].Height <= 0)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
System.Windows.Rect caret = rectangles[0];
|
||||||
|
return new PopupWindowNative.Rect
|
||||||
|
{
|
||||||
|
Left = (int)caret.Left,
|
||||||
|
Top = (int)caret.Top,
|
||||||
|
Right = (int)caret.Right,
|
||||||
|
Bottom = (int)caret.Bottom,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
catch (Exception e) when (e is ElementNotAvailableException
|
||||||
|
or InvalidOperationException
|
||||||
|
or COMException)
|
||||||
|
{
|
||||||
|
// The application closed or stopped responding — that must not take the popup down
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#else
|
||||||
|
// Built without UI Automation: Chromium and Electron keep the system caret and MSAA
|
||||||
|
// steps above, and where those stay silent the popup falls back to the fixed point
|
||||||
|
private static PopupWindowNative.Rect? TryGetAutomationCaret() => null;
|
||||||
|
#endif
|
||||||
|
|
||||||
|
// 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))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var topLeft = new PopupWindowNative.Point { X = info.rcCaret.Left, Y = info.rcCaret.Top };
|
||||||
|
var bottomRight = new PopupWindowNative.Point { X = info.rcCaret.Right, Y = info.rcCaret.Bottom };
|
||||||
|
if (!ClientToScreen(info.hwndCaret, ref topLeft) || !ClientToScreen(info.hwndCaret, ref bottomRight))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new PopupWindowNative.Rect
|
||||||
|
{
|
||||||
|
Left = topLeft.X,
|
||||||
|
Top = topLeft.Y,
|
||||||
|
Right = bottomRight.X,
|
||||||
|
Bottom = bottomRight.Y,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// The caret through accessibility interfaces: this is where browsers and Electron land
|
||||||
|
private static PopupWindowNative.Rect? TryGetAccessibleCaret(IntPtr hwndFocus)
|
||||||
|
{
|
||||||
|
if (hwndFocus == IntPtr.Zero)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Guid iid = typeof(IAccessible).GUID;
|
||||||
|
if (AccessibleObjectFromWindow(hwndFocus, OBJID_CARET, ref iid, out IAccessible caret) != 0)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
caret.accLocation(out int left, out int top, out int width, out int height, CHILDID_SELF);
|
||||||
|
var rect = new PopupWindowNative.Rect
|
||||||
|
{
|
||||||
|
Left = left,
|
||||||
|
Top = top,
|
||||||
|
Right = left + width,
|
||||||
|
Bottom = top + height,
|
||||||
|
};
|
||||||
|
|
||||||
|
return IsEmpty(rect) ? null : rect;
|
||||||
|
}
|
||||||
|
catch (COMException)
|
||||||
|
{
|
||||||
|
// The application declared support but did not report the position
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
Marshal.ReleaseComObject(caret);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
using System.Runtime.InteropServices;
|
||||||
|
|
||||||
|
namespace CursorLang.Core.Interop;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Input details of the active application: which window holds keyboard focus
|
||||||
|
/// and where the caret is.
|
||||||
|
/// </summary>
|
||||||
|
internal static class ForegroundInputNative
|
||||||
|
{
|
||||||
|
[StructLayout(LayoutKind.Sequential)]
|
||||||
|
internal struct GuiThreadInfo
|
||||||
|
{
|
||||||
|
public int cbSize;
|
||||||
|
public uint flags;
|
||||||
|
public IntPtr hwndActive;
|
||||||
|
public IntPtr hwndFocus;
|
||||||
|
public IntPtr hwndCapture;
|
||||||
|
public IntPtr hwndMenuOwner;
|
||||||
|
public IntPtr hwndMoveSize;
|
||||||
|
public IntPtr hwndCaret;
|
||||||
|
public PopupWindowNative.Rect rcCaret;
|
||||||
|
}
|
||||||
|
|
||||||
|
[DllImport("user32.dll")]
|
||||||
|
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)
|
||||||
|
{
|
||||||
|
info = new GuiThreadInfo { cbSize = Marshal.SizeOf<GuiThreadInfo>() };
|
||||||
|
return GetGUIThreadInfo(0, ref info);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
using System.Runtime.InteropServices;
|
||||||
|
|
||||||
|
namespace CursorLang.Core.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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
using System.Runtime.InteropServices;
|
||||||
|
|
||||||
|
namespace CursorLang.Core.Interop;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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();
|
||||||
|
|
||||||
|
[DllImport("user32.dll")]
|
||||||
|
private static extern uint GetWindowThreadProcessId(IntPtr hWnd, IntPtr lpdwProcessId);
|
||||||
|
|
||||||
|
[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>
|
||||||
|
/// 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 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 info.hwndFocus;
|
||||||
|
}
|
||||||
|
|
||||||
|
return GetForegroundWindow();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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)
|
||||||
|
{
|
||||||
|
uint threadId = GetWindowThreadProcessId(hWnd, IntPtr.Zero);
|
||||||
|
return (int)GetKeyboardLayout(threadId).ToInt64() & 0xFFFF;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
using System.Runtime.InteropServices;
|
||||||
|
|
||||||
|
namespace CursorLang.Core.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.Core.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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
using System.Runtime.InteropServices;
|
||||||
|
|
||||||
|
namespace CursorLang.Core.Interop;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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
|
||||||
|
{
|
||||||
|
[StructLayout(LayoutKind.Sequential)]
|
||||||
|
internal struct Point
|
||||||
|
{
|
||||||
|
public int X;
|
||||||
|
public int Y;
|
||||||
|
}
|
||||||
|
|
||||||
|
[DllImport("user32.dll")]
|
||||||
|
private static extern bool GetCursorPos(out Point lpPoint);
|
||||||
|
|
||||||
|
[DllImport("user32.dll")]
|
||||||
|
private static extern IntPtr GetForegroundWindow();
|
||||||
|
|
||||||
|
[DllImport("user32.dll")]
|
||||||
|
private static extern bool SetWindowPos(IntPtr hWnd, IntPtr hWndInsertAfter,
|
||||||
|
int X, int Y, int cx, int cy, uint uFlags);
|
||||||
|
|
||||||
|
[DllImport("user32.dll")]
|
||||||
|
private static extern IntPtr MonitorFromPoint(Point pt, uint dwFlags);
|
||||||
|
|
||||||
|
[DllImport("shcore.dll")]
|
||||||
|
private static extern int GetDpiForMonitor(IntPtr hMonitor, int dpiType, out uint dpiX, out uint dpiY);
|
||||||
|
|
||||||
|
[DllImport("user32.dll")]
|
||||||
|
private static extern IntPtr MonitorFromWindow(IntPtr hWnd, uint dwFlags);
|
||||||
|
|
||||||
|
[DllImport("user32.dll")]
|
||||||
|
private static extern bool GetMonitorInfo(IntPtr hMonitor, ref MonitorInfo lpmi);
|
||||||
|
|
||||||
|
[StructLayout(LayoutKind.Sequential)]
|
||||||
|
internal struct Rect
|
||||||
|
{
|
||||||
|
public int Left;
|
||||||
|
public int Top;
|
||||||
|
public int Right;
|
||||||
|
public int Bottom;
|
||||||
|
}
|
||||||
|
|
||||||
|
[StructLayout(LayoutKind.Sequential)]
|
||||||
|
private struct MonitorInfo
|
||||||
|
{
|
||||||
|
public int cbSize;
|
||||||
|
public Rect rcMonitor;
|
||||||
|
public Rect rcWork;
|
||||||
|
public uint dwFlags;
|
||||||
|
}
|
||||||
|
|
||||||
|
private const uint SWP_NOSIZE = 0x0001;
|
||||||
|
private const uint SWP_NOZORDER = 0x0004;
|
||||||
|
private const uint SWP_NOACTIVATE = 0x0010;
|
||||||
|
|
||||||
|
private const uint MONITOR_DEFAULTTONEAREST = 2;
|
||||||
|
private const int MDT_EFFECTIVE_DPI = 0;
|
||||||
|
|
||||||
|
internal static Point GetCursorPosition()
|
||||||
|
{
|
||||||
|
GetCursorPos(out Point cursor);
|
||||||
|
return cursor;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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)
|
||||||
|
{
|
||||||
|
SetWindowPos(hWnd, IntPtr.Zero, x, y, 0, 0, SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <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()
|
||||||
|
{
|
||||||
|
IntPtr monitor = MonitorFromWindow(GetForegroundWindow(), MONITOR_DEFAULTTONEAREST);
|
||||||
|
|
||||||
|
var info = new MonitorInfo { cbSize = Marshal.SizeOf<MonitorInfo>() };
|
||||||
|
if (!GetMonitorInfo(monitor, ref info))
|
||||||
|
{
|
||||||
|
return (new Rect(), 1.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (info.rcWork, GetScaleOf(monitor));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static double GetScaleOf(IntPtr monitor)
|
||||||
|
{
|
||||||
|
if (GetDpiForMonitor(monitor, MDT_EFFECTIVE_DPI, out uint dpiX, out _) < 0)
|
||||||
|
{
|
||||||
|
return 1.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return dpiX / 96.0;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
using System.ComponentModel;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
|
||||||
|
namespace CursorLang.Core.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The application settings, as the settings window writes them to settings.json.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Everything about the popup — the side, the offset, the font size, the opacity and
|
||||||
|
/// both colours — belongs to a placement mode rather than to the application, and is
|
||||||
|
/// kept in a <see cref="PopupModeSettings"/> of its own for each of them. So the
|
||||||
|
/// settings of a mode are still there after a trip through the other two.
|
||||||
|
///
|
||||||
|
/// Both processes hold an instance of this, but only the settings window writes: the
|
||||||
|
/// agent re-reads the file and pours the fresh values into the instance it already has,
|
||||||
|
/// so everything subscribed to it stays subscribed. See <see cref="CopyFrom"/>.
|
||||||
|
/// </remarks>
|
||||||
|
public sealed partial class AppSettings : ObservableObject
|
||||||
|
{
|
||||||
|
/// <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;
|
||||||
|
|
||||||
|
/// <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;
|
||||||
|
|
||||||
|
public AppSettings()
|
||||||
|
{
|
||||||
|
AtCursor.PropertyChanged += OnModeChanged;
|
||||||
|
AtCaret.PropertyChanged += OnModeChanged;
|
||||||
|
FixedPoint.PropertyChanged += OnModeChanged;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The <see cref="PopupPlacementMode.AtCursor"/> mode.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The modes are handed out rather than replaced — the instance is the same for the
|
||||||
|
/// life of the settings, so a binding and a subscription to it hold. That is what
|
||||||
|
/// the creation handling is for: without it the deserializer would want a setter,
|
||||||
|
/// and reading the file would swap the object everything is bound to.
|
||||||
|
/// </remarks>
|
||||||
|
[JsonObjectCreationHandling(JsonObjectCreationHandling.Populate)]
|
||||||
|
public CursorModeSettings AtCursor { get; } = new();
|
||||||
|
|
||||||
|
/// <summary>The <see cref="PopupPlacementMode.AtCaret"/> mode.</summary>
|
||||||
|
[JsonObjectCreationHandling(JsonObjectCreationHandling.Populate)]
|
||||||
|
public CaretModeSettings AtCaret { get; } = new();
|
||||||
|
|
||||||
|
/// <summary>The <see cref="PopupPlacementMode.FixedPoint"/> mode.</summary>
|
||||||
|
[JsonObjectCreationHandling(JsonObjectCreationHandling.Populate)]
|
||||||
|
public FixedPointModeSettings FixedPoint { get; } = new();
|
||||||
|
|
||||||
|
/// <summary>The settings of the mode currently chosen.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The settings window binds the look through here, and the popup asks for it here
|
||||||
|
/// too: what is on screen is always the settings of the mode in force. A change of
|
||||||
|
/// <see cref="PlacementMode"/> announces this as changed, so the bindings follow.
|
||||||
|
/// </remarks>
|
||||||
|
[JsonIgnore]
|
||||||
|
public PopupModeSettings Current => PlacementMode switch
|
||||||
|
{
|
||||||
|
PopupPlacementMode.AtCaret => AtCaret,
|
||||||
|
PopupPlacementMode.FixedPoint => FixedPoint,
|
||||||
|
_ => AtCursor,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <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);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Takes the values of another instance over, raising a change notification for
|
||||||
|
/// every property that has actually moved.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// This is how the agent learns about an edit: the settings window is a separate
|
||||||
|
/// process, so the fresh values arrive as a freshly parsed instance and are poured
|
||||||
|
/// into the one everything is already bound to, rather than replacing it. The modes
|
||||||
|
/// are filled the same way, and for the same reason.
|
||||||
|
/// </remarks>
|
||||||
|
public void CopyFrom(AppSettings other)
|
||||||
|
{
|
||||||
|
Language = other.Language;
|
||||||
|
Theme = other.Theme;
|
||||||
|
PlacementMode = other.PlacementMode;
|
||||||
|
DurationMilliseconds = other.DurationMilliseconds;
|
||||||
|
UseCapsLockHotkey = other.UseCapsLockHotkey;
|
||||||
|
CapsLockHoldMilliseconds = other.CapsLockHoldMilliseconds;
|
||||||
|
|
||||||
|
AtCursor.CopyFrom(other.AtCursor);
|
||||||
|
AtCaret.CopyFrom(other.AtCaret);
|
||||||
|
FixedPoint.CopyFrom(other.FixedPoint);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The mode in force decides what the look means, so a switch of the mode is a
|
||||||
|
// change of everything bound through Current
|
||||||
|
partial void OnPlacementModeChanged(PopupPlacementMode value) => OnPropertyChanged(nameof(Current));
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Passes a change made inside a mode on as a change of the settings.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// A mode is an object of its own, so an edit in it is not an edit of this one as
|
||||||
|
/// far as <see cref="INotifyPropertyChanged"/> goes. The settings window binds
|
||||||
|
/// through the path and hears the mode itself, but the service that writes the file
|
||||||
|
/// listens to the settings alone — and without this a font size dragged in the
|
||||||
|
/// window would never reach the disk. The name says where the change happened:
|
||||||
|
/// "AtCaret.FontSize".
|
||||||
|
/// </remarks>
|
||||||
|
private void OnModeChanged(object? sender, PropertyChangedEventArgs e) =>
|
||||||
|
OnPropertyChanged($"{NameOfMode(sender)}.{e.PropertyName}");
|
||||||
|
|
||||||
|
private string NameOfMode(object? mode) =>
|
||||||
|
ReferenceEquals(mode, AtCaret) ? nameof(AtCaret)
|
||||||
|
: ReferenceEquals(mode, FixedPoint) ? nameof(FixedPoint)
|
||||||
|
: nameof(AtCursor);
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
namespace CursorLang.Core.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
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
|
||||||
|
namespace CursorLang.Core.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A keyboard layout in a form convenient for display.
|
||||||
|
/// </summary>
|
||||||
|
/// <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)
|
||||||
|
{
|
||||||
|
CultureInfo? culture = TryGetCulture(localeId);
|
||||||
|
if (culture is null)
|
||||||
|
{
|
||||||
|
string fallback = $"0x{localeId:X4}";
|
||||||
|
return new KeyboardLayout(localeId, fallback, fallback);
|
||||||
|
}
|
||||||
|
|
||||||
|
string shortName = culture.TwoLetterISOLanguageName.ToUpperInvariant();
|
||||||
|
return new KeyboardLayout(localeId, shortName, $"{shortName} — {culture.NativeName}");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static CultureInfo? TryGetCulture(int localeId)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return new CultureInfo(localeId);
|
||||||
|
}
|
||||||
|
catch (CultureNotFoundException)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
namespace CursorLang.Core.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Why the current layout has changed.
|
||||||
|
/// </summary>
|
||||||
|
public enum LayoutChangeReason
|
||||||
|
{
|
||||||
|
/// <summary>The user switched the layout in the active application.</summary>
|
||||||
|
UserSwitched,
|
||||||
|
|
||||||
|
/// <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
|
||||||
|
{
|
||||||
|
public KeyboardLayout Layout { get; } = layout;
|
||||||
|
|
||||||
|
public LayoutChangeReason Reason { get; } = reason;
|
||||||
|
}
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
using System.Drawing;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
|
||||||
|
namespace CursorLang.Core.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The settings of one placement mode: where the popup goes and how it looks there.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Every mode keeps a set of its own. The popup next to the caret sits inside a text
|
||||||
|
/// being read and is wanted small and quiet; the one in the corner of the monitor is
|
||||||
|
/// looked for on purpose and is wanted large. A look shared by the modes meant setting
|
||||||
|
/// it up again after every switch — and switching modes to see what they do undid what
|
||||||
|
/// had just been set up.
|
||||||
|
///
|
||||||
|
/// The colours are <see cref="System.Drawing.Color"/> for the same reason as in
|
||||||
|
/// <see cref="AppSettings"/>: the agent reads them and must stay clear of WPF.
|
||||||
|
/// </remarks>
|
||||||
|
public abstract partial class PopupModeSettings : ObservableObject
|
||||||
|
{
|
||||||
|
/// <summary>The distance from whatever the popup is placed by, in WPF units.</summary>
|
||||||
|
[ObservableProperty]
|
||||||
|
private double _offset = 16;
|
||||||
|
|
||||||
|
/// <summary>The size of the layout name in the popup, in WPF units.</summary>
|
||||||
|
[ObservableProperty]
|
||||||
|
private double _fontSize = 20;
|
||||||
|
|
||||||
|
/// <summary>The popup opacity: 1.0 is fully opaque.</summary>
|
||||||
|
[ObservableProperty]
|
||||||
|
private double _opacity = 0.9;
|
||||||
|
|
||||||
|
/// <summary>The fill of the popup. The opacity is set by <see cref="Opacity"/>.</summary>
|
||||||
|
[ObservableProperty]
|
||||||
|
private Color _backgroundColor = Color.FromArgb(0xFF, 0x20, 0x20, 0x20);
|
||||||
|
|
||||||
|
/// <summary>The colour of the layout name in the popup.</summary>
|
||||||
|
[ObservableProperty]
|
||||||
|
private Color _foregroundColor = Color.FromArgb(0xFF, 0xFF, 0xFF, 0xFF);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Takes the offset and the look of another mode over. What tells the modes apart —
|
||||||
|
/// the side, the place on the monitor — is copied by the mode itself.
|
||||||
|
/// </summary>
|
||||||
|
protected void CopyLookFrom(PopupModeSettings other)
|
||||||
|
{
|
||||||
|
Offset = other.Offset;
|
||||||
|
FontSize = other.FontSize;
|
||||||
|
Opacity = other.Opacity;
|
||||||
|
BackgroundColor = other.BackgroundColor;
|
||||||
|
ForegroundColor = other.ForegroundColor;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The mode that puts the popup next to the mouse cursor.</summary>
|
||||||
|
public sealed partial class CursorModeSettings : PopupModeSettings
|
||||||
|
{
|
||||||
|
/// <summary>The corner or the side of the cursor the popup is put on.</summary>
|
||||||
|
[ObservableProperty]
|
||||||
|
private AnchorSide _side = AnchorSide.BottomRight;
|
||||||
|
|
||||||
|
/// <summary>Takes the values of another mode of the same kind over.</summary>
|
||||||
|
public void CopyFrom(CursorModeSettings other)
|
||||||
|
{
|
||||||
|
CopyLookFrom(other);
|
||||||
|
Side = other.Side;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The mode that puts the popup next to the caret of the active input field.
|
||||||
|
/// </summary>
|
||||||
|
public sealed partial class CaretModeSettings : PopupModeSettings
|
||||||
|
{
|
||||||
|
/// <summary>The side of the caret the popup is put on: only left or right.</summary>
|
||||||
|
[ObservableProperty]
|
||||||
|
private CaretSide _side = CaretSide.Right;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The side as the layout arithmetic wants it. Both of them line the popup up with
|
||||||
|
/// the caret rather than putting it above or below.
|
||||||
|
/// </summary>
|
||||||
|
[JsonIgnore]
|
||||||
|
public AnchorSide Anchor => Side == CaretSide.Left ? AnchorSide.Left : AnchorSide.Right;
|
||||||
|
|
||||||
|
/// <summary>Takes the values of another mode of the same kind over.</summary>
|
||||||
|
public void CopyFrom(CaretModeSettings other)
|
||||||
|
{
|
||||||
|
CopyLookFrom(other);
|
||||||
|
Side = other.Side;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The side decides what the popup is lined up against, so it is a change of that too
|
||||||
|
partial void OnSideChanged(CaretSide value) => OnPropertyChanged(nameof(Anchor));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The mode that puts the popup at a fixed place of the monitor holding the active
|
||||||
|
/// window. Here <see cref="PopupModeSettings.Offset"/> is the distance from the edge
|
||||||
|
/// of the monitor.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// In the middle of the monitor there is no edge to keep a distance from, so the offset
|
||||||
|
/// is zero there and the settings window does not offer it. Choosing the middle puts it
|
||||||
|
/// back to zero rather than remembering it for later: a setting that is not shown must
|
||||||
|
/// not be one that still applies.
|
||||||
|
/// </remarks>
|
||||||
|
public sealed partial class FixedPointModeSettings : PopupModeSettings
|
||||||
|
{
|
||||||
|
/// <summary>The place on the monitor the popup is put at.</summary>
|
||||||
|
[ObservableProperty]
|
||||||
|
private ScreenPosition _position = ScreenPosition.Center;
|
||||||
|
|
||||||
|
public FixedPointModeSettings()
|
||||||
|
{
|
||||||
|
// The middle is the default, and it has no edge to stand off from
|
||||||
|
Offset = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Whether the popup is put at an edge of the monitor rather than in its middle.</summary>
|
||||||
|
[JsonIgnore]
|
||||||
|
public bool IsAtAnEdge => Position != ScreenPosition.Center;
|
||||||
|
|
||||||
|
/// <summary>Takes the values of another mode of the same kind over.</summary>
|
||||||
|
public void CopyFrom(FixedPointModeSettings other)
|
||||||
|
{
|
||||||
|
CopyLookFrom(other);
|
||||||
|
Position = other.Position;
|
||||||
|
}
|
||||||
|
|
||||||
|
partial void OnPositionChanged(ScreenPosition value)
|
||||||
|
{
|
||||||
|
OnPropertyChanged(nameof(IsAtAnEdge));
|
||||||
|
|
||||||
|
if (value == ScreenPosition.Center)
|
||||||
|
{
|
||||||
|
Offset = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
namespace CursorLang.Core.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// How the place for the popup is chosen.
|
||||||
|
/// </summary>
|
||||||
|
public enum PopupPlacementMode
|
||||||
|
{
|
||||||
|
/// <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 as in <see cref="FixedPoint"/> — at the
|
||||||
|
/// place and with the look that mode is set up with.
|
||||||
|
/// </summary>
|
||||||
|
AtCaret,
|
||||||
|
|
||||||
|
/// <summary>At a fixed point of the monitor holding the active window.</summary>
|
||||||
|
FixedPoint,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Which side of the cursor to show the popup on.
|
||||||
|
/// </summary>
|
||||||
|
public enum AnchorSide
|
||||||
|
{
|
||||||
|
TopLeft,
|
||||||
|
TopRight,
|
||||||
|
Left,
|
||||||
|
Right,
|
||||||
|
BottomLeft,
|
||||||
|
BottomRight,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Which side of the caret to show the popup on.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Only the two sides, unlike <see cref="AnchorSide"/>. The caret stands in a line of
|
||||||
|
/// text being written, and above or below it is exactly where the next line is: the
|
||||||
|
/// popup would cover what is being read. To the side it covers nothing, and the line
|
||||||
|
/// it lines up with is the one the caret is in.
|
||||||
|
/// </remarks>
|
||||||
|
public enum CaretSide
|
||||||
|
{
|
||||||
|
Left,
|
||||||
|
Right,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The place on the monitor for the <see cref="PopupPlacementMode.FixedPoint"/> mode.
|
||||||
|
/// </summary>
|
||||||
|
public enum ScreenPosition
|
||||||
|
{
|
||||||
|
TopLeft,
|
||||||
|
Top,
|
||||||
|
TopRight,
|
||||||
|
Center,
|
||||||
|
BottomLeft,
|
||||||
|
Bottom,
|
||||||
|
BottomRight,
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
namespace CursorLang.Core.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.Core.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.
@@ -0,0 +1,214 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<root>
|
||||||
|
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
|
||||||
|
<xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
|
||||||
|
<xsd:element name="root" msdata:IsDataSet="true">
|
||||||
|
<xsd:complexType>
|
||||||
|
<xsd:choice maxOccurs="unbounded">
|
||||||
|
<xsd:element name="metadata">
|
||||||
|
<xsd:complexType>
|
||||||
|
<xsd:sequence>
|
||||||
|
<xsd:element name="value" type="xsd:string" minOccurs="0" />
|
||||||
|
</xsd:sequence>
|
||||||
|
<xsd:attribute name="name" use="required" type="xsd:string" />
|
||||||
|
<xsd:attribute name="type" type="xsd:string" />
|
||||||
|
<xsd:attribute name="mimetype" type="xsd:string" />
|
||||||
|
<xsd:attribute ref="xml:space" />
|
||||||
|
</xsd:complexType>
|
||||||
|
</xsd:element>
|
||||||
|
<xsd:element name="assembly">
|
||||||
|
<xsd:complexType>
|
||||||
|
<xsd:attribute name="alias" type="xsd:string" />
|
||||||
|
<xsd:attribute name="name" type="xsd:string" />
|
||||||
|
</xsd:complexType>
|
||||||
|
</xsd:element>
|
||||||
|
<xsd:element name="data">
|
||||||
|
<xsd:complexType>
|
||||||
|
<xsd:sequence>
|
||||||
|
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
|
||||||
|
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
|
||||||
|
</xsd:sequence>
|
||||||
|
<xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" />
|
||||||
|
<xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
|
||||||
|
<xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
|
||||||
|
<xsd:attribute ref="xml:space" />
|
||||||
|
</xsd:complexType>
|
||||||
|
</xsd:element>
|
||||||
|
<xsd:element name="resheader">
|
||||||
|
<xsd:complexType>
|
||||||
|
<xsd:sequence>
|
||||||
|
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
|
||||||
|
</xsd:sequence>
|
||||||
|
<xsd:attribute name="name" type="xsd:string" use="required" />
|
||||||
|
</xsd:complexType>
|
||||||
|
</xsd:element>
|
||||||
|
</xsd:choice>
|
||||||
|
</xsd:complexType>
|
||||||
|
</xsd:element>
|
||||||
|
</xsd:schema>
|
||||||
|
<resheader name="resmimetype">
|
||||||
|
<value>text/microsoft-resx</value>
|
||||||
|
</resheader>
|
||||||
|
<resheader name="version">
|
||||||
|
<value>2.0</value>
|
||||||
|
</resheader>
|
||||||
|
<resheader name="reader">
|
||||||
|
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||||
|
</resheader>
|
||||||
|
<resheader name="writer">
|
||||||
|
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||||
|
</resheader>
|
||||||
|
<data name="SettingsTitle" xml:space="preserve">
|
||||||
|
<value>CursorLang {0} — Settings</value>
|
||||||
|
</data>
|
||||||
|
<data name="TrayMenuSettings" xml:space="preserve">
|
||||||
|
<value>Settings</value>
|
||||||
|
</data>
|
||||||
|
<data name="TrayMenuExit" xml:space="preserve">
|
||||||
|
<value>Exit</value>
|
||||||
|
</data>
|
||||||
|
<data name="SectionInterface" xml:space="preserve">
|
||||||
|
<value>Interface</value>
|
||||||
|
</data>
|
||||||
|
<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="SectionPopup" xml:space="preserve">
|
||||||
|
<value>Layout popup</value>
|
||||||
|
</data>
|
||||||
|
<data name="PlacementModeLabel" xml:space="preserve">
|
||||||
|
<value>Mode</value>
|
||||||
|
</data>
|
||||||
|
<data name="PopupPlacementMode_AtCursor" xml:space="preserve">
|
||||||
|
<value>Near the cursor</value>
|
||||||
|
</data>
|
||||||
|
<data name="PopupPlacementMode_AtCaret" xml:space="preserve">
|
||||||
|
<value>Near the text caret</value>
|
||||||
|
</data>
|
||||||
|
<data name="PopupPlacementMode_FixedPoint" xml:space="preserve">
|
||||||
|
<value>Fixed point on screen</value>
|
||||||
|
</data>
|
||||||
|
<data name="CursorCornerLabel" xml:space="preserve">
|
||||||
|
<value>Side</value>
|
||||||
|
</data>
|
||||||
|
<data name="AnchorSide_BottomRight" xml:space="preserve">
|
||||||
|
<value>Bottom right</value>
|
||||||
|
</data>
|
||||||
|
<data name="AnchorSide_BottomLeft" xml:space="preserve">
|
||||||
|
<value>Bottom left</value>
|
||||||
|
</data>
|
||||||
|
<data name="AnchorSide_TopRight" xml:space="preserve">
|
||||||
|
<value>Top right</value>
|
||||||
|
</data>
|
||||||
|
<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="CaretSide_Left" xml:space="preserve">
|
||||||
|
<value>Left</value>
|
||||||
|
</data>
|
||||||
|
<data name="CaretSide_Right" xml:space="preserve">
|
||||||
|
<value>Right</value>
|
||||||
|
</data>
|
||||||
|
<data name="CursorOffsetLabel" xml:space="preserve">
|
||||||
|
<value>Offset</value>
|
||||||
|
</data>
|
||||||
|
<data name="ScreenPositionLabel" xml:space="preserve">
|
||||||
|
<value>Position on screen</value>
|
||||||
|
</data>
|
||||||
|
<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>
|
||||||
|
<data name="ScreenPosition_BottomLeft" xml:space="preserve">
|
||||||
|
<value>Bottom left</value>
|
||||||
|
</data>
|
||||||
|
<data name="ScreenPosition_BottomRight" xml:space="preserve">
|
||||||
|
<value>Bottom right</value>
|
||||||
|
</data>
|
||||||
|
<data name="ScreenPosition_Center" xml:space="preserve">
|
||||||
|
<value>Center</value>
|
||||||
|
</data>
|
||||||
|
<data name="ScreenMarginLabel" xml:space="preserve">
|
||||||
|
<value>Margin from screen edge</value>
|
||||||
|
</data>
|
||||||
|
<data name="PopupLookPerModeHint" xml:space="preserve">
|
||||||
|
<value>The look below is remembered for the chosen placement mode alone.</value>
|
||||||
|
</data>
|
||||||
|
<data name="FontSizeLabel" xml:space="preserve">
|
||||||
|
<value>Font size</value>
|
||||||
|
</data>
|
||||||
|
<data name="OpacityLabel" xml:space="preserve">
|
||||||
|
<value>Opacity</value>
|
||||||
|
</data>
|
||||||
|
<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>
|
||||||
|
<data name="DurationLabel" xml:space="preserve">
|
||||||
|
<value>Display time</value>
|
||||||
|
</data>
|
||||||
|
<data name="PreviewLabel" xml:space="preserve">
|
||||||
|
<value>Preview</value>
|
||||||
|
</data>
|
||||||
|
<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>
|
||||||
|
</root>
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<root>
|
||||||
|
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
|
||||||
|
<xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
|
||||||
|
<xsd:element name="root" msdata:IsDataSet="true">
|
||||||
|
<xsd:complexType>
|
||||||
|
<xsd:choice maxOccurs="unbounded">
|
||||||
|
<xsd:element name="metadata">
|
||||||
|
<xsd:complexType>
|
||||||
|
<xsd:sequence>
|
||||||
|
<xsd:element name="value" type="xsd:string" minOccurs="0" />
|
||||||
|
</xsd:sequence>
|
||||||
|
<xsd:attribute name="name" use="required" type="xsd:string" />
|
||||||
|
<xsd:attribute name="type" type="xsd:string" />
|
||||||
|
<xsd:attribute name="mimetype" type="xsd:string" />
|
||||||
|
<xsd:attribute ref="xml:space" />
|
||||||
|
</xsd:complexType>
|
||||||
|
</xsd:element>
|
||||||
|
<xsd:element name="assembly">
|
||||||
|
<xsd:complexType>
|
||||||
|
<xsd:attribute name="alias" type="xsd:string" />
|
||||||
|
<xsd:attribute name="name" type="xsd:string" />
|
||||||
|
</xsd:complexType>
|
||||||
|
</xsd:element>
|
||||||
|
<xsd:element name="data">
|
||||||
|
<xsd:complexType>
|
||||||
|
<xsd:sequence>
|
||||||
|
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
|
||||||
|
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
|
||||||
|
</xsd:sequence>
|
||||||
|
<xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" />
|
||||||
|
<xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
|
||||||
|
<xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
|
||||||
|
<xsd:attribute ref="xml:space" />
|
||||||
|
</xsd:complexType>
|
||||||
|
</xsd:element>
|
||||||
|
<xsd:element name="resheader">
|
||||||
|
<xsd:complexType>
|
||||||
|
<xsd:sequence>
|
||||||
|
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
|
||||||
|
</xsd:sequence>
|
||||||
|
<xsd:attribute name="name" type="xsd:string" use="required" />
|
||||||
|
</xsd:complexType>
|
||||||
|
</xsd:element>
|
||||||
|
</xsd:choice>
|
||||||
|
</xsd:complexType>
|
||||||
|
</xsd:element>
|
||||||
|
</xsd:schema>
|
||||||
|
<resheader name="resmimetype">
|
||||||
|
<value>text/microsoft-resx</value>
|
||||||
|
</resheader>
|
||||||
|
<resheader name="version">
|
||||||
|
<value>2.0</value>
|
||||||
|
</resheader>
|
||||||
|
<resheader name="reader">
|
||||||
|
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||||
|
</resheader>
|
||||||
|
<resheader name="writer">
|
||||||
|
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||||
|
</resheader>
|
||||||
|
<data name="SettingsTitle" xml:space="preserve">
|
||||||
|
<value>CursorLang {0} — Настройки</value>
|
||||||
|
</data>
|
||||||
|
<data name="TrayMenuSettings" xml:space="preserve">
|
||||||
|
<value>Настройки</value>
|
||||||
|
</data>
|
||||||
|
<data name="TrayMenuExit" xml:space="preserve">
|
||||||
|
<value>Выход</value>
|
||||||
|
</data>
|
||||||
|
<data name="SectionInterface" xml:space="preserve">
|
||||||
|
<value>Интерфейс</value>
|
||||||
|
</data>
|
||||||
|
<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="SectionPopup" xml:space="preserve">
|
||||||
|
<value>Подсказка о раскладке</value>
|
||||||
|
</data>
|
||||||
|
<data name="PlacementModeLabel" xml:space="preserve">
|
||||||
|
<value>Режим</value>
|
||||||
|
</data>
|
||||||
|
<data name="PopupPlacementMode_AtCursor" xml:space="preserve">
|
||||||
|
<value>Рядом с курсором</value>
|
||||||
|
</data>
|
||||||
|
<data name="PopupPlacementMode_AtCaret" xml:space="preserve">
|
||||||
|
<value>Рядом с кареткой ввода</value>
|
||||||
|
</data>
|
||||||
|
<data name="PopupPlacementMode_FixedPoint" xml:space="preserve">
|
||||||
|
<value>В заданной точке экрана</value>
|
||||||
|
</data>
|
||||||
|
<data name="CursorCornerLabel" xml:space="preserve">
|
||||||
|
<value>Сторона</value>
|
||||||
|
</data>
|
||||||
|
<data name="AnchorSide_BottomRight" xml:space="preserve">
|
||||||
|
<value>Справа снизу</value>
|
||||||
|
</data>
|
||||||
|
<data name="AnchorSide_BottomLeft" xml:space="preserve">
|
||||||
|
<value>Слева снизу</value>
|
||||||
|
</data>
|
||||||
|
<data name="AnchorSide_TopRight" xml:space="preserve">
|
||||||
|
<value>Справа сверху</value>
|
||||||
|
</data>
|
||||||
|
<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="CaretSide_Left" xml:space="preserve">
|
||||||
|
<value>Слева</value>
|
||||||
|
</data>
|
||||||
|
<data name="CaretSide_Right" xml:space="preserve">
|
||||||
|
<value>Справа</value>
|
||||||
|
</data>
|
||||||
|
<data name="CursorOffsetLabel" xml:space="preserve">
|
||||||
|
<value>Отступ</value>
|
||||||
|
</data>
|
||||||
|
<data name="ScreenPositionLabel" xml:space="preserve">
|
||||||
|
<value>Позиция на экране</value>
|
||||||
|
</data>
|
||||||
|
<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>
|
||||||
|
<data name="ScreenPosition_BottomLeft" xml:space="preserve">
|
||||||
|
<value>Слева снизу</value>
|
||||||
|
</data>
|
||||||
|
<data name="ScreenPosition_BottomRight" xml:space="preserve">
|
||||||
|
<value>Справа снизу</value>
|
||||||
|
</data>
|
||||||
|
<data name="ScreenPosition_Center" xml:space="preserve">
|
||||||
|
<value>По центру</value>
|
||||||
|
</data>
|
||||||
|
<data name="ScreenMarginLabel" xml:space="preserve">
|
||||||
|
<value>Отступ от края экрана</value>
|
||||||
|
</data>
|
||||||
|
<data name="PopupLookPerModeHint" xml:space="preserve">
|
||||||
|
<value>Оформление ниже запоминается только для выбранного режима.</value>
|
||||||
|
</data>
|
||||||
|
<data name="FontSizeLabel" xml:space="preserve">
|
||||||
|
<value>Размер шрифта</value>
|
||||||
|
</data>
|
||||||
|
<data name="OpacityLabel" xml:space="preserve">
|
||||||
|
<value>Прозрачность</value>
|
||||||
|
</data>
|
||||||
|
<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>
|
||||||
|
<data name="DurationLabel" xml:space="preserve">
|
||||||
|
<value>Время отображения</value>
|
||||||
|
</data>
|
||||||
|
<data name="PreviewLabel" xml:space="preserve">
|
||||||
|
<value>Предпросмотр</value>
|
||||||
|
</data>
|
||||||
|
<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>
|
||||||
|
</root>
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
namespace CursorLang.Core.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Where the background half of the application lives on disk.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Two processes now share one folder, and each of them at some point needs the path
|
||||||
|
/// of the other: the settings window registers the agent for startup and must not
|
||||||
|
/// register itself, and the agent starts the settings window from the tray menu.
|
||||||
|
/// <c>Environment.ProcessPath</c> answers the wrong question for both, so the paths
|
||||||
|
/// are worked out from the folder the assemblies were loaded from.
|
||||||
|
/// </remarks>
|
||||||
|
internal static class AgentExecutable
|
||||||
|
{
|
||||||
|
/// <summary>The background process — the one Windows starts at sign-in.</summary>
|
||||||
|
internal const string AgentFileName = "CursorLang.exe";
|
||||||
|
|
||||||
|
/// <summary>The settings window, started on demand and gone when closed.</summary>
|
||||||
|
internal const string SettingsFileName = "CursorLang.Settings.exe";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The full path of the agent, or <c>null</c> when it is not next to us — which
|
||||||
|
/// happens in the tests and would happen to a half-copied installation.
|
||||||
|
/// </summary>
|
||||||
|
internal static string? AgentPath => Beside(AgentFileName);
|
||||||
|
|
||||||
|
/// <summary>The full path of the settings window, on the same terms.</summary>
|
||||||
|
internal static string? SettingsPath => Beside(SettingsFileName);
|
||||||
|
|
||||||
|
private static string? Beside(string fileName)
|
||||||
|
{
|
||||||
|
string path = Path.Combine(AppContext.BaseDirectory, fileName);
|
||||||
|
return File.Exists(path) ? path : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
using System.Reflection;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
using CursorLang.Core.Interop;
|
||||||
|
using Windows.ApplicationModel;
|
||||||
|
|
||||||
|
namespace CursorLang.Core.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The version of the running application.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The same application runs from a package and from a folder, and the two keep
|
||||||
|
/// their version in different places. The answer is computed once: it cannot
|
||||||
|
/// change while the process lives.
|
||||||
|
/// </remarks>
|
||||||
|
public static class AppVersion
|
||||||
|
{
|
||||||
|
public static Version Current { get; } = Detect();
|
||||||
|
|
||||||
|
private static Version Detect()
|
||||||
|
{
|
||||||
|
if (PackageIdentityNative.IsPackaged)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
using System.ComponentModel;
|
||||||
|
using CursorLang.Core.Models;
|
||||||
|
|
||||||
|
namespace CursorLang.Core.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,56 @@
|
|||||||
|
using System.Drawing;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace CursorLang.Core.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reads and writes a colour as "#AARRGGBB".
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// That is the form earlier versions wrote, when the colours were WPF ones and
|
||||||
|
/// <c>Color.ToString()</c> produced it, so files already on disk keep working. The
|
||||||
|
/// parsing is done here rather than by <c>ColorConverter</c> because that one lives in
|
||||||
|
/// PresentationCore, and Core is read by the agent. Named colours are accepted too:
|
||||||
|
/// nothing writes them, but the file is plain text and people edit it by hand.
|
||||||
|
/// </remarks>
|
||||||
|
internal sealed class ColorJsonConverter : JsonConverter<Color>
|
||||||
|
{
|
||||||
|
public override Color Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
|
||||||
|
{
|
||||||
|
string? value = reader.GetString();
|
||||||
|
if (string.IsNullOrWhiteSpace(value))
|
||||||
|
{
|
||||||
|
return Color.Black;
|
||||||
|
}
|
||||||
|
|
||||||
|
value = value.Trim();
|
||||||
|
|
||||||
|
if (!value.StartsWith('#'))
|
||||||
|
{
|
||||||
|
Color named = Color.FromName(value);
|
||||||
|
|
||||||
|
// Unpacked back into a plain colour on purpose: a known colour carries its
|
||||||
|
// name with it and does not compare equal to the same bytes written in hex,
|
||||||
|
// which would make "Red" and "#FFFF0000" two different settings
|
||||||
|
return named.IsKnownColor ? Color.FromArgb(named.ToArgb()) : Color.Black;
|
||||||
|
}
|
||||||
|
|
||||||
|
ReadOnlySpan<char> digits = value.AsSpan(1);
|
||||||
|
if (!uint.TryParse(digits, NumberStyles.HexNumber, CultureInfo.InvariantCulture, out uint packed))
|
||||||
|
{
|
||||||
|
return Color.Black;
|
||||||
|
}
|
||||||
|
|
||||||
|
return digits.Length switch
|
||||||
|
{
|
||||||
|
6 => Color.FromArgb((int)(packed | 0xFF000000)),
|
||||||
|
8 => Color.FromArgb((int)packed),
|
||||||
|
_ => Color.Black,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void Write(Utf8JsonWriter writer, Color value, JsonSerializerOptions options) =>
|
||||||
|
writer.WriteStringValue($"#{value.A:X2}{value.R:X2}{value.G:X2}{value.B:X2}");
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
namespace CursorLang.Core.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();
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
using CursorLang.Core.Models;
|
||||||
|
|
||||||
|
namespace CursorLang.Core.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tracks the layout of the active window — including in other applications —
|
||||||
|
/// and can switch it.
|
||||||
|
/// </summary>
|
||||||
|
public interface IKeyboardLayoutService
|
||||||
|
{
|
||||||
|
/// <summary>The layout of the active window at the moment.</summary>
|
||||||
|
KeyboardLayout Current { get; }
|
||||||
|
|
||||||
|
event EventHandler<LayoutChangedEventArgs>? LayoutChanged;
|
||||||
|
|
||||||
|
void Start();
|
||||||
|
|
||||||
|
void Stop();
|
||||||
|
|
||||||
|
void SwitchToNext();
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
using CursorLang.Core.Models;
|
||||||
|
|
||||||
|
namespace CursorLang.Core.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,28 @@
|
|||||||
|
namespace CursorLang.Core.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 with the given text at the place set by the settings.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The text is passed in rather than bound: there is no view model behind the
|
||||||
|
/// window any more, and no data binding either — it is a Win32 window that paints
|
||||||
|
/// one line of text itself.
|
||||||
|
/// </remarks>
|
||||||
|
void ShowPopup(string shortName);
|
||||||
|
|
||||||
|
/// <summary>Takes the window off the screen without destroying it.</summary>
|
||||||
|
void Hide();
|
||||||
|
|
||||||
|
/// <summary>Closes the window for good.</summary>
|
||||||
|
void Close();
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
using System.ComponentModel;
|
||||||
|
|
||||||
|
namespace CursorLang.Core.Services;
|
||||||
|
|
||||||
|
/// <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>The string for a resource key. Bindings update when the language changes.</summary>
|
||||||
|
string this[string key] { get; }
|
||||||
|
|
||||||
|
IReadOnlyList<LanguageOption> AvailableLanguages { get; }
|
||||||
|
|
||||||
|
string CurrentLanguage { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
using CursorLang.Core.Models;
|
||||||
|
|
||||||
|
namespace CursorLang.Core.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,121 @@
|
|||||||
|
using CursorLang.Core.Interop;
|
||||||
|
using CursorLang.Core.Models;
|
||||||
|
using CursorLang.Core.Threading;
|
||||||
|
|
||||||
|
namespace CursorLang.Core.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The settings of layout tracking.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class KeyboardLayoutOptions
|
||||||
|
{
|
||||||
|
/// <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>
|
||||||
|
/// 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.
|
||||||
|
///
|
||||||
|
/// The timer ticks on the message loop of whatever thread starts it, the same as a
|
||||||
|
/// dispatcher timer did: the agent has a plain Win32 loop and no dispatcher to offer.
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class KeyboardLayoutService : IKeyboardLayoutService, IDisposable
|
||||||
|
{
|
||||||
|
private readonly MessageTimer _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 MessageTimer { Interval = options.PollInterval };
|
||||||
|
_pollTimer.Tick += OnTick;
|
||||||
|
}
|
||||||
|
|
||||||
|
public event EventHandler<LayoutChangedEventArgs>? LayoutChanged;
|
||||||
|
|
||||||
|
public KeyboardLayout Current => KeyboardLayout.FromLocaleId(_getActiveLocaleId());
|
||||||
|
|
||||||
|
public void Start()
|
||||||
|
{
|
||||||
|
_lastForegroundWindow = _getForegroundWindow();
|
||||||
|
_lastLocaleId = _getActiveLocaleId();
|
||||||
|
_pollTimer.Start();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Stop() => _pollTimer.Stop();
|
||||||
|
|
||||||
|
public void SwitchToNext() => _requestNextLayout();
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_pollTimer.Tick -= OnTick;
|
||||||
|
_pollTimer.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = _getForegroundWindow();
|
||||||
|
if (foreground == IntPtr.Zero)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool appSwitched = foreground != _lastForegroundWindow;
|
||||||
|
_lastForegroundWindow = foreground;
|
||||||
|
|
||||||
|
int localeId = _getActiveLocaleId();
|
||||||
|
if (localeId == _lastLocaleId)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_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;
|
||||||
|
|
||||||
|
LayoutChanged?.Invoke(this, new LayoutChangedEventArgs(KeyboardLayout.FromLocaleId(localeId), reason));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
using CursorLang.Core.Models;
|
||||||
|
|
||||||
|
namespace CursorLang.Core.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
|
||||||
|
{
|
||||||
|
private readonly IKeyboardLayoutService _layoutService;
|
||||||
|
private readonly ILayoutPopupService _popupService;
|
||||||
|
|
||||||
|
public LayoutNotificationCoordinator(IKeyboardLayoutService layoutService, ILayoutPopupService popupService)
|
||||||
|
{
|
||||||
|
_layoutService = layoutService;
|
||||||
|
_popupService = popupService;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Start()
|
||||||
|
{
|
||||||
|
_layoutService.LayoutChanged += OnLayoutChanged;
|
||||||
|
_layoutService.Start();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_layoutService.LayoutChanged -= OnLayoutChanged;
|
||||||
|
_layoutService.Stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using System.Resources;
|
||||||
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
|
||||||
|
namespace CursorLang.Core.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Takes the strings from the resources and, when the language changes, asks WPF to
|
||||||
|
/// re-read every binding.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Both processes use it: the settings window for its whole interface, the agent for
|
||||||
|
/// the three captions of the tray menu. The theme no longer reaches that menu — Windows
|
||||||
|
/// draws it — but the language still does.
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class LocalizationService : ObservableObject, ILocalizationService
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The name WPF reports when an indexer changes. Spelt out rather than taken from
|
||||||
|
/// <c>Binding.IndexerName</c>: that constant lives in PresentationFramework, and
|
||||||
|
/// Core is read by the agent, which does not load WPF.
|
||||||
|
/// </summary>
|
||||||
|
public const string IndexerName = "Item[]";
|
||||||
|
|
||||||
|
private static readonly ResourceManager Resources =
|
||||||
|
new("CursorLang.Core.Resources.Strings", typeof(LocalizationService).Assembly);
|
||||||
|
|
||||||
|
private CultureInfo _culture = CultureInfo.GetCultureInfo("en");
|
||||||
|
|
||||||
|
public string this[string key] => Resources.GetString(key, _culture) ?? key;
|
||||||
|
|
||||||
|
public IReadOnlyList<LanguageOption> AvailableLanguages { get; } =
|
||||||
|
[
|
||||||
|
new LanguageOption("en", "English"),
|
||||||
|
new LanguageOption("ru", "Русский"),
|
||||||
|
];
|
||||||
|
|
||||||
|
public string CurrentLanguage
|
||||||
|
{
|
||||||
|
get => _culture.TwoLetterISOLanguageName;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(value) || value == CurrentLanguage)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_culture = CultureInfo.GetCultureInfo(value);
|
||||||
|
CultureInfo.CurrentUICulture = _culture;
|
||||||
|
|
||||||
|
OnPropertyChanged(nameof(CurrentLanguage));
|
||||||
|
|
||||||
|
// 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(IndexerName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
using CursorLang.Core.Interop;
|
||||||
|
using CursorLang.Core.Models;
|
||||||
|
|
||||||
|
namespace CursorLang.Core.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,130 @@
|
|||||||
|
using System.Security;
|
||||||
|
using CursorLang.Core.Models;
|
||||||
|
using Microsoft.Win32;
|
||||||
|
|
||||||
|
namespace CursorLang.Core.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 agent is not where it should be, and
|
||||||
|
/// there is nothing to write down.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The agent by name rather than <c>Environment.ProcessPath</c>: this setting is
|
||||||
|
/// switched from the settings window, and its own path would put the wrong process
|
||||||
|
/// into the startup list — one that shows a window and exits.
|
||||||
|
///
|
||||||
|
/// The argument is how the agent recognises a launch of this kind and goes straight
|
||||||
|
/// to the tray without the settings window: see <see cref="StartupLaunch"/>. The
|
||||||
|
/// user starting the application themselves passes no such thing and gets the window.
|
||||||
|
/// </remarks>
|
||||||
|
internal static string? GetCommand() =>
|
||||||
|
AgentExecutable.AgentPath is { Length: > 0 } path
|
||||||
|
? $"\"{path}\" {StartupLaunch.Argument}"
|
||||||
|
: null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
namespace CursorLang.Core.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tells the settings window that the agent is quitting and it is to close with it.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The one thing the agent says to the settings window, and the mirror image of
|
||||||
|
/// <see cref="SettingsSignal"/>. "Exit" in the tray menu means the application is done
|
||||||
|
/// with, and a settings window left alone on the screen after it is a window belonging
|
||||||
|
/// to nothing: the tray icon it was opened from is gone, and closing it would be the
|
||||||
|
/// user's only remaining move.
|
||||||
|
///
|
||||||
|
/// A named event rather than a window message, because the agent has no handle to send
|
||||||
|
/// one to: the settings window lives in a process the agent starts and deliberately
|
||||||
|
/// does not keep hold of. The name has no <c>Global</c> prefix, so it lives in the
|
||||||
|
/// session namespace — same reasoning as <see cref="SingleInstanceGate"/>, and the same
|
||||||
|
/// consequence: with fast user switching each user's halves talk to their own.
|
||||||
|
///
|
||||||
|
/// Only the settings window creates the object; the agent opens what is already there
|
||||||
|
/// and stays silent when there is nothing. Were it the other way round, the request
|
||||||
|
/// would sit in an auto-reset event waiting for the next settings window to open and
|
||||||
|
/// close it the moment it did.
|
||||||
|
/// </remarks>
|
||||||
|
internal sealed class SettingsCloseSignal : IDisposable
|
||||||
|
{
|
||||||
|
private const string EventName = "CursorLang.CloseSettings";
|
||||||
|
|
||||||
|
private readonly string _eventName;
|
||||||
|
|
||||||
|
private EventWaitHandle? _request;
|
||||||
|
private RegisteredWaitHandle? _wait;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Listens on the name the two halves agree on.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="nameSuffix">
|
||||||
|
/// A namespace of its own. Empty for the application; the tests pass one so that
|
||||||
|
/// they do not answer for — or worse, close — a settings window someone is using.
|
||||||
|
/// </param>
|
||||||
|
internal SettingsCloseSignal(string nameSuffix = "") => _eventName = EventName + nameSuffix;
|
||||||
|
|
||||||
|
/// <summary>The agent asks for the window to be closed.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Raised on a thread pool thread, wherever the wait happened to be answered — a
|
||||||
|
/// window obeys only its own, so the handler has to get back to it.
|
||||||
|
/// </remarks>
|
||||||
|
internal event EventHandler? CloseRequested;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Asks the settings window of this session, if one is open, to close. Silence is
|
||||||
|
/// a normal answer: most of the time the user quits with no window on the screen.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>Whether there was anybody to hear it.</returns>
|
||||||
|
internal static bool RequestClose(string nameSuffix = "")
|
||||||
|
{
|
||||||
|
if (!EventWaitHandle.TryOpenExisting(EventName + nameSuffix, out EventWaitHandle? request))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
using (request)
|
||||||
|
{
|
||||||
|
return request.Set();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Starts waiting for the request. Called once, by the settings window.</summary>
|
||||||
|
internal void Listen()
|
||||||
|
{
|
||||||
|
if (_request is not null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_request = new EventWaitHandle(false, EventResetMode.AutoReset, _eventName);
|
||||||
|
|
||||||
|
// As in the gate: the thread pool holds the wait, there is no reason to keep a
|
||||||
|
// thread of our own for a request that may never come
|
||||||
|
_wait = ThreadPool.RegisterWaitForSingleObject(
|
||||||
|
_request,
|
||||||
|
OnCloseSignalled,
|
||||||
|
state: null,
|
||||||
|
Timeout.Infinite,
|
||||||
|
executeOnlyOnce: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_wait?.Unregister(null);
|
||||||
|
_wait = null;
|
||||||
|
|
||||||
|
_request?.Dispose();
|
||||||
|
_request = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnCloseSignalled(object? state, bool timedOut) =>
|
||||||
|
CloseRequested?.Invoke(this, EventArgs.Empty);
|
||||||
|
}
|
||||||
@@ -0,0 +1,249 @@
|
|||||||
|
using System.ComponentModel;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using CursorLang.Core.Interop;
|
||||||
|
using CursorLang.Core.Models;
|
||||||
|
using CursorLang.Core.Threading;
|
||||||
|
using Windows.Storage;
|
||||||
|
|
||||||
|
namespace CursorLang.Core.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Keeps the settings in the settings.json file.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The file is the whole of the connection between the two processes, and they use it
|
||||||
|
/// from opposite ends. The settings window calls <see cref="TrackChanges"/> and is the
|
||||||
|
/// only writer; the agent only ever reads, and re-reads when the window tells it to.
|
||||||
|
/// A second writer would mean two processes racing for one file and an edit going missing.
|
||||||
|
///
|
||||||
|
/// The location 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()
|
||||||
|
{
|
||||||
|
WriteIndented = true,
|
||||||
|
Converters = { new ColorJsonConverter(), new JsonStringEnumConverter() },
|
||||||
|
};
|
||||||
|
|
||||||
|
private const string FileName = "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);
|
||||||
|
|
||||||
|
private readonly string _filePath;
|
||||||
|
private readonly string _inheritedFilePath;
|
||||||
|
private readonly MessageTimer _saveTimer;
|
||||||
|
|
||||||
|
private AppSettings? _settings;
|
||||||
|
private bool _isTrackingChanges;
|
||||||
|
|
||||||
|
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 MessageTimer { Interval = saveDelay };
|
||||||
|
_saveTimer.Tick += OnSaveTimerTick;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reads the settings from disk or returns the default values.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Asking twice hands out the same instance rather than reading again. Everything
|
||||||
|
/// binds to what this returns — the window, the popup, the hook — and a second
|
||||||
|
/// instance would mean one of them editing settings nobody else can see.
|
||||||
|
/// </remarks>
|
||||||
|
public AppSettings Load() => _settings ??= ReadOrInherit();
|
||||||
|
|
||||||
|
private AppSettings ReadOrInherit()
|
||||||
|
{
|
||||||
|
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();
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Starts saving every change, after a pause. For the settings window: it is the
|
||||||
|
/// only process allowed to write.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Reads the file if that has not happened yet. The settings window asks in exactly
|
||||||
|
/// that order — its container hands out this service first and the settings only
|
||||||
|
/// when something needs them — and a version of this that quietly did nothing
|
||||||
|
/// before the first read left the window saving nothing at all.
|
||||||
|
/// </remarks>
|
||||||
|
public void TrackChanges()
|
||||||
|
{
|
||||||
|
if (_isTrackingChanges)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Load().PropertyChanged += OnSettingsChanged;
|
||||||
|
_isTrackingChanges = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Re-reads the file. For the agent, when the settings window says it has written.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// There is nothing to wait for and nothing to debounce: the window writes the file
|
||||||
|
/// whole and moves it into place in one step, and only then says so. Nobody else
|
||||||
|
/// writes it — the agent does not watch the file, and an edit made behind the
|
||||||
|
/// application's back is not a case it is built for.
|
||||||
|
/// </remarks>
|
||||||
|
public void Reload()
|
||||||
|
{
|
||||||
|
if (_settings is not null && ReadFile(_filePath) is { } fresh)
|
||||||
|
{
|
||||||
|
_settings.CopyFrom(fresh);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Writes the settings and tells the agent to pick them up.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The file is written beside its destination and moved onto it, which on one
|
||||||
|
/// volume is a single step. That way the agent, which is told to re-read the moment
|
||||||
|
/// this returns, never meets a half-written file.
|
||||||
|
/// </remarks>
|
||||||
|
public void Save()
|
||||||
|
{
|
||||||
|
if (_settings is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_saveTimer.Stop();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(Path.GetDirectoryName(_filePath)!);
|
||||||
|
|
||||||
|
string temporary = _filePath + ".tmp";
|
||||||
|
File.WriteAllText(temporary, JsonSerializer.Serialize(_settings, SerializerOptions));
|
||||||
|
File.Move(temporary, _filePath, overwrite: true);
|
||||||
|
}
|
||||||
|
catch (Exception e) when (e is IOException or UnauthorizedAccessException)
|
||||||
|
{
|
||||||
|
// The settings are not the kind of thing worth bringing the application down for
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsSignal.NotifyAgent();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_saveTimer.Tick -= OnSaveTimerTick;
|
||||||
|
_saveTimer.Dispose();
|
||||||
|
|
||||||
|
if (_settings is not null && _isTrackingChanges)
|
||||||
|
{
|
||||||
|
_settings.PropertyChanged -= OnSettingsChanged;
|
||||||
|
_isTrackingChanges = false;
|
||||||
|
Save();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <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(path))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
return new AppSettings { Language = uiLanguage == "ru" ? "ru" : "en" };
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnSettingsChanged(object? sender, PropertyChangedEventArgs e)
|
||||||
|
{
|
||||||
|
_saveTimer.Stop();
|
||||||
|
_saveTimer.Start();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnSaveTimerTick(object? sender, EventArgs e) => Save();
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
using System.Runtime.InteropServices;
|
||||||
|
|
||||||
|
namespace CursorLang.Core.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tells the agent that settings.json has changed.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The message carries nothing but the fact. The temptation to send the changed values
|
||||||
|
/// along has to be resisted: the file would stop being the only source of truth, and
|
||||||
|
/// the two would part company the first time somebody edits it by hand. Nothing else
|
||||||
|
/// tells the agent — it does not watch the file — so a message that goes missing means
|
||||||
|
/// settings it does not pick up until it is restarted.
|
||||||
|
///
|
||||||
|
/// Order is what makes it safe. The settings window writes the file whole, moves it
|
||||||
|
/// into place in one step and only then signals, so by the time the agent reads there
|
||||||
|
/// is nothing half-written to read.
|
||||||
|
///
|
||||||
|
/// A registered message rather than <c>WM_APP + n</c>: the identifier is unique across
|
||||||
|
/// the system, so it cannot be confused with anything else that finds its way to that
|
||||||
|
/// window.
|
||||||
|
/// </remarks>
|
||||||
|
internal static class SettingsSignal
|
||||||
|
{
|
||||||
|
/// <summary>The window class the agent registers for its hidden window.</summary>
|
||||||
|
internal const string AgentWindowClass = "CursorLang.Agent.Window";
|
||||||
|
|
||||||
|
/// <summary>The message both sides agree on.</summary>
|
||||||
|
internal static uint Message { get; } = RegisterWindowMessage("CursorLang.SettingsChanged");
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Wakes the agent, if one is running in this session. Silence is a normal
|
||||||
|
/// answer: the settings window is perfectly usable with no agent behind it.
|
||||||
|
/// </summary>
|
||||||
|
internal static void NotifyAgent()
|
||||||
|
{
|
||||||
|
IntPtr agent = FindWindow(AgentWindowClass, null);
|
||||||
|
if (agent != IntPtr.Zero)
|
||||||
|
{
|
||||||
|
PostMessage(agent, Message, IntPtr.Zero, IntPtr.Zero);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[DllImport("user32.dll", CharSet = CharSet.Unicode, EntryPoint = "RegisterWindowMessageW")]
|
||||||
|
private static extern uint RegisterWindowMessage(string lpString);
|
||||||
|
|
||||||
|
[DllImport("user32.dll", CharSet = CharSet.Unicode, EntryPoint = "FindWindowW")]
|
||||||
|
private static extern IntPtr FindWindow(string lpClassName, string? lpWindowName);
|
||||||
|
|
||||||
|
[DllImport("user32.dll", CharSet = CharSet.Unicode, EntryPoint = "PostMessageW")]
|
||||||
|
private static extern bool PostMessage(IntPtr hWnd, uint message, IntPtr wParam, IntPtr lParam);
|
||||||
|
}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
using CursorLang.Core.Interop;
|
||||||
|
|
||||||
|
namespace CursorLang.Core.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.
|
||||||
|
///
|
||||||
|
/// Two processes use this now, and each guards its own slot: the agent so that one
|
||||||
|
/// background process runs, the settings window so that a second "Settings" from the
|
||||||
|
/// tray raises the window already open instead of a second one. Hence the name part.
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class SingleInstanceGate : IDisposable
|
||||||
|
{
|
||||||
|
/// <summary>The agent's slot — one background process per session.</summary>
|
||||||
|
public const string AgentName = ".Agent";
|
||||||
|
|
||||||
|
/// <summary>The settings window's slot — one window per session.</summary>
|
||||||
|
public const string SettingsName = ".Settings";
|
||||||
|
|
||||||
|
private const string MutexName = "CursorLang.SingleInstance";
|
||||||
|
private const string ActivationEventName = "CursorLang.ActivationRequest";
|
||||||
|
|
||||||
|
private readonly string _mutexName;
|
||||||
|
private readonly string _activationEventName;
|
||||||
|
|
||||||
|
private Mutex? _mutex;
|
||||||
|
private EventWaitHandle? _activationRequest;
|
||||||
|
private RegisteredWaitHandle? _activationWait;
|
||||||
|
private bool _isOwner;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Takes a named slot. The name tells the agent's slot from the settings window's,
|
||||||
|
/// and the tests use one of their own: otherwise they would share a slot with the
|
||||||
|
/// running application and get in its way.
|
||||||
|
/// </summary>
|
||||||
|
public SingleInstanceGate(string nameSuffix)
|
||||||
|
{
|
||||||
|
_mutexName = MutexName + nameSuffix;
|
||||||
|
_activationEventName = ActivationEventName + nameSuffix;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Another launch asks for the window to be shown.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Raised on a thread pool thread, wherever the wait happened to be answered. The
|
||||||
|
/// two hosts get back to their own thread differently — one through the dispatcher,
|
||||||
|
/// one by posting to its window — so neither is assumed here.
|
||||||
|
/// </remarks>
|
||||||
|
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() => TryAcquire(showRunningInstance: true);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The same, with a say in what is to happen to the application already running.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="showRunningInstance">
|
||||||
|
/// Whether the running application is to be brought up. A launch by Windows
|
||||||
|
/// itself passes <c>false</c>: it was not asked for a window, and the
|
||||||
|
/// application already in the tray is answer enough.
|
||||||
|
/// </param>
|
||||||
|
public bool TryAcquire(bool showRunningInstance)
|
||||||
|
{
|
||||||
|
_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)
|
||||||
|
{
|
||||||
|
if (showRunningInstance)
|
||||||
|
{
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnActivationSignalled(object? state, bool timedOut) =>
|
||||||
|
ActivationRequested?.Invoke(this, EventArgs.Empty);
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
using System.Runtime.InteropServices;
|
||||||
|
using CursorLang.Core.Interop;
|
||||||
|
using Windows.ApplicationModel;
|
||||||
|
using Windows.ApplicationModel.Activation;
|
||||||
|
|
||||||
|
namespace CursorLang.Core.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Whether Windows started the application by itself.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// A launch of its own accord ends up in the tray without a window: the user asked
|
||||||
|
/// for the application to be there when they sign in, not for a window to greet them
|
||||||
|
/// every morning. A launch by the user is another matter — the window is what they
|
||||||
|
/// clicked for.
|
||||||
|
///
|
||||||
|
/// The two builds tell the launches apart differently. A build in a folder is
|
||||||
|
/// started from the registry, and the command written there carries an argument of
|
||||||
|
/// its own — see <see cref="RegistryStartup"/>. A package has no say in its command
|
||||||
|
/// line, and Windows is asked about the activation instead.
|
||||||
|
/// </remarks>
|
||||||
|
internal static class StartupLaunch
|
||||||
|
{
|
||||||
|
/// <summary>What the registry entry adds to the path of the application.</summary>
|
||||||
|
internal const string Argument = "--startup";
|
||||||
|
|
||||||
|
/// <summary>Whether this launch is the doing of Windows rather than of the user.</summary>
|
||||||
|
internal static bool IsAutomatic(IReadOnlyList<string> arguments) =>
|
||||||
|
HasArgument(arguments) || IsStartupActivation();
|
||||||
|
|
||||||
|
/// <summary>The command line says the launch comes from the startup entry.</summary>
|
||||||
|
internal static bool HasArgument(IReadOnlyList<string> arguments) =>
|
||||||
|
arguments.Any(argument => string.Equals(argument, Argument, StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
|
private static bool IsStartupActivation()
|
||||||
|
{
|
||||||
|
if (!PackageIdentityNative.IsPackaged)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return AppInstance.GetActivatedEventArgs() is { Kind: ActivationKind.StartupTask };
|
||||||
|
}
|
||||||
|
catch (Exception e) when (e is COMException or InvalidOperationException or NotSupportedException)
|
||||||
|
{
|
||||||
|
// Windows has nothing to say about the activation. A window shown when it
|
||||||
|
// was not asked for is a smaller mishap than an application that hides
|
||||||
|
// when the user has just started it
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
using System.Runtime.InteropServices;
|
||||||
|
using CursorLang.Core.Interop;
|
||||||
|
using CursorLang.Core.Models;
|
||||||
|
using Windows.ApplicationModel;
|
||||||
|
|
||||||
|
namespace CursorLang.Core.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,66 @@
|
|||||||
|
using System.Runtime.InteropServices;
|
||||||
|
|
||||||
|
namespace CursorLang.Core.Threading;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The Win32 message loop — what the application has instead of a dispatcher.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// It lives in Core rather than in the agent because the things that need a pumping
|
||||||
|
/// thread do: <see cref="MessageTimer"/> is used by the layout polling and by saving
|
||||||
|
/// the settings, and both are Core's. The settings window has a loop of its own, run
|
||||||
|
/// by WPF, and everything here works inside it just the same.
|
||||||
|
/// </remarks>
|
||||||
|
public static class MessageLoop
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Pumps messages until <c>WM_QUIT</c> and returns its exit code.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// A -1 from GetMessage means the window handle has already gone; going round
|
||||||
|
/// again would spin forever, so the loop gives up instead.
|
||||||
|
/// </remarks>
|
||||||
|
public static int Run()
|
||||||
|
{
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
int result = GetMessage(out Message message, IntPtr.Zero, 0, 0);
|
||||||
|
if (result is 0 or -1)
|
||||||
|
{
|
||||||
|
return result == 0 ? (int)message.wParam : 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
TranslateMessage(ref message);
|
||||||
|
DispatchMessage(ref message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Asks the loop on this thread to finish.</summary>
|
||||||
|
public static void Quit(int exitCode = 0) => PostQuitMessage(exitCode);
|
||||||
|
|
||||||
|
|
||||||
|
[DllImport("user32.dll", CharSet = CharSet.Unicode, EntryPoint = "GetMessageW")]
|
||||||
|
private static extern int GetMessage(out Message lpMsg, IntPtr hWnd, uint filterMin, uint filterMax);
|
||||||
|
|
||||||
|
[DllImport("user32.dll")]
|
||||||
|
private static extern bool TranslateMessage(ref Message lpMsg);
|
||||||
|
|
||||||
|
[DllImport("user32.dll", CharSet = CharSet.Unicode, EntryPoint = "DispatchMessageW")]
|
||||||
|
private static extern IntPtr DispatchMessage(ref Message lpMsg);
|
||||||
|
|
||||||
|
[DllImport("user32.dll")]
|
||||||
|
private static extern void PostQuitMessage(int exitCode);
|
||||||
|
|
||||||
|
[StructLayout(LayoutKind.Sequential)]
|
||||||
|
private struct Message
|
||||||
|
{
|
||||||
|
public IntPtr hwnd;
|
||||||
|
public uint message;
|
||||||
|
public IntPtr wParam;
|
||||||
|
public IntPtr lParam;
|
||||||
|
public uint time;
|
||||||
|
public int x;
|
||||||
|
public int y;
|
||||||
|
public uint lPrivate;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
using System.Runtime.InteropServices;
|
||||||
|
|
||||||
|
namespace CursorLang.Core.Threading;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A timer that ticks on the message loop — the agent's stand-in for
|
||||||
|
/// <c>DispatcherTimer</c>.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <c>SetTimer</c> with a null window binds the timer to the thread rather than to a
|
||||||
|
/// window, and <c>DispatchMessage</c> calls the callback straight from the loop. The
|
||||||
|
/// upshot is the same as with a dispatcher timer: the tick arrives on the thread that
|
||||||
|
/// owns the hook and the popup, so nothing needs marshalling and nothing races.
|
||||||
|
///
|
||||||
|
/// The callback lives in a field for the reason a hook procedure does: the only
|
||||||
|
/// reference to it is held by Win32, and a collected delegate takes the process down
|
||||||
|
/// with it at the first tick.
|
||||||
|
/// </remarks>
|
||||||
|
internal sealed class MessageTimer : IDisposable
|
||||||
|
{
|
||||||
|
/// <summary>Windows will not go below this, and pretending otherwise misleads.</summary>
|
||||||
|
private const uint MinimumIntervalMilliseconds = 10;
|
||||||
|
|
||||||
|
private readonly TimerProc _callback;
|
||||||
|
|
||||||
|
private nuint _id;
|
||||||
|
|
||||||
|
internal MessageTimer() => _callback = OnTimer;
|
||||||
|
|
||||||
|
internal event EventHandler? Tick;
|
||||||
|
|
||||||
|
internal TimeSpan Interval { get; set; } = TimeSpan.FromMilliseconds(100);
|
||||||
|
|
||||||
|
internal bool IsRunning => _id != 0;
|
||||||
|
|
||||||
|
/// <summary>Starts the timer, or restarts it from zero when it is already running.</summary>
|
||||||
|
internal void Start()
|
||||||
|
{
|
||||||
|
Stop();
|
||||||
|
|
||||||
|
var milliseconds = (uint)Math.Clamp(
|
||||||
|
Math.Round(Interval.TotalMilliseconds), MinimumIntervalMilliseconds, int.MaxValue);
|
||||||
|
|
||||||
|
_id = SetTimer(IntPtr.Zero, 0, milliseconds, _callback);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal void Stop()
|
||||||
|
{
|
||||||
|
if (_id == 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
KillTimer(IntPtr.Zero, _id);
|
||||||
|
_id = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose() => Stop();
|
||||||
|
|
||||||
|
private void OnTimer(IntPtr window, uint message, nuint id, uint time) =>
|
||||||
|
Tick?.Invoke(this, EventArgs.Empty);
|
||||||
|
|
||||||
|
private delegate void TimerProc(IntPtr hWnd, uint message, nuint idEvent, uint time);
|
||||||
|
|
||||||
|
[DllImport("user32.dll", SetLastError = true)]
|
||||||
|
private static extern nuint SetTimer(IntPtr hWnd, nuint nIDEvent, uint uElapse, TimerProc lpTimerFunc);
|
||||||
|
|
||||||
|
[DllImport("user32.dll", SetLastError = true)]
|
||||||
|
private static extern bool KillTimer(IntPtr hWnd, nuint uIDEvent);
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
using CursorLang.Core.Models;
|
||||||
|
using CursorLang.Core.Services;
|
||||||
|
using CursorLang.Settings.Services;
|
||||||
|
using CursorLang.Settings.ViewModels;
|
||||||
|
using CursorLang.Settings.Views;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
||||||
|
namespace CursorLang.Settings.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// What the container of the settings process is made of. The tests cannot build the
|
||||||
|
/// application whole — it would raise a window 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(Version))]
|
||||||
|
[InlineData(typeof(SettingsViewModel))]
|
||||||
|
[InlineData(typeof(MainWindow))]
|
||||||
|
[InlineData(typeof(AppSettings))]
|
||||||
|
public void Everything_the_window_needs_is_declared_in_the_container(Type service)
|
||||||
|
{
|
||||||
|
Assert.Contains(Describe(), descriptor => descriptor.ServiceType == service);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The background half is not in here, and must not be.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The hook, the popup and the layout polling belong to the agent process now. A
|
||||||
|
/// registration of any of them here would mean two applications watching the
|
||||||
|
/// keyboard at once — and the second of them holding WPF while it did so.
|
||||||
|
/// </remarks>
|
||||||
|
[Theory]
|
||||||
|
[InlineData("KeyboardLayoutService")]
|
||||||
|
[InlineData("LayoutPopupService")]
|
||||||
|
[InlineData("CapsLockHotkeyService")]
|
||||||
|
[InlineData("LayoutNotificationCoordinator")]
|
||||||
|
[InlineData("CapsLockSwitchCoordinator")]
|
||||||
|
[InlineData("TrayIcon")]
|
||||||
|
public void The_background_half_is_not_in_the_settings_container(string name)
|
||||||
|
{
|
||||||
|
Assert.DoesNotContain(Describe(), descriptor => descriptor.ServiceType.Name.Contains(name));
|
||||||
|
}
|
||||||
|
|
||||||
|
// The settings and the theme have to be shared by the whole window: a second copy
|
||||||
|
// of them would mean lost edits or half the controls in the wrong colours
|
||||||
|
[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_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,58 @@
|
|||||||
|
<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>
|
||||||
|
<RootNamespace>CursorLang.Settings.Tests</RootNamespace>
|
||||||
|
<AssemblyName>CursorLang.Settings.Tests</AssemblyName>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Using Include="Xunit" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<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.Core\CursorLang.Core.csproj" />
|
||||||
|
<ProjectReference Include="..\CursorLang.Tests.Shared\CursorLang.Tests.Shared.csproj" />
|
||||||
|
<ProjectReference Include="..\CursorLang.Settings\CursorLang.Settings.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<!-- The markup is embedded as plain text so that the check for missing resource
|
||||||
|
keys reads what the window really says, without walking the folder tree -->
|
||||||
|
<PropertyGroup>
|
||||||
|
<MainWindowMarkup>$(MSBuildThisFileDirectory)..\CursorLang.Settings\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,16 @@
|
|||||||
|
using CursorLang.Core.Models;
|
||||||
|
using CursorLang.Settings.Services;
|
||||||
|
|
||||||
|
namespace CursorLang.Settings.Tests.Infrastructure;
|
||||||
|
|
||||||
|
/// <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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
using System.Windows;
|
||||||
|
using System.Windows.Threading;
|
||||||
|
|
||||||
|
namespace CursorLang.Settings.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.Settings;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,159 @@
|
|||||||
|
using System.Windows;
|
||||||
|
using System.Windows.Controls;
|
||||||
|
using System.Windows.Interop;
|
||||||
|
using CursorLang.Core.Interop;
|
||||||
|
using CursorLang.Settings.Interop;
|
||||||
|
using CursorLang.Settings.Tests.Infrastructure;
|
||||||
|
|
||||||
|
namespace CursorLang.Settings.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");
|
||||||
|
}
|
||||||
|
|
||||||
|
PopupWindowNative.Rect field =
|
||||||
|
WindowPlacementNative.TryGetBounds(input.Handle)!.Value;
|
||||||
|
|
||||||
|
Assert.True(CaretNative.IsInside(caret.Value, field),
|
||||||
|
"the caret reported by the input field is outside that field");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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,212 @@
|
|||||||
|
using System.Windows;
|
||||||
|
using System.Windows.Interop;
|
||||||
|
using CursorLang.Core.Interop;
|
||||||
|
using CursorLang.Settings.Interop;
|
||||||
|
using CursorLang.Settings.Tests.Infrastructure;
|
||||||
|
|
||||||
|
namespace CursorLang.Settings.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_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 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,288 @@
|
|||||||
|
using System.Windows;
|
||||||
|
using System.Windows.Interop;
|
||||||
|
using CursorLang.Core.Interop;
|
||||||
|
using CursorLang.Settings.Interop;
|
||||||
|
using CursorLang.Settings.Services;
|
||||||
|
using CursorLang.Settings.Tests.Infrastructure;
|
||||||
|
|
||||||
|
namespace CursorLang.Settings.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,458 @@
|
|||||||
|
using System.Windows;
|
||||||
|
using System.Windows.Controls;
|
||||||
|
using System.Windows.Interop;
|
||||||
|
using System.Windows.Media;
|
||||||
|
using System.Windows.Shapes;
|
||||||
|
using CursorLang.Core.Models;
|
||||||
|
using CursorLang.Settings.Services;
|
||||||
|
using CursorLang.Settings.Tests.Infrastructure;
|
||||||
|
using Microsoft.Win32;
|
||||||
|
|
||||||
|
namespace CursorLang.Settings.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.Current.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()
|
||||||
|
{
|
||||||
|
// The palettes live with the settings window, not with the agent next to it
|
||||||
|
Assert.Contains(
|
||||||
|
"CursorLang.Settings;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,56 @@
|
|||||||
|
using CursorLang.Core.Models;
|
||||||
|
using CursorLang.Settings.ViewModels;
|
||||||
|
|
||||||
|
namespace CursorLang.Settings.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,304 @@
|
|||||||
|
using CursorLang.Core.Models;
|
||||||
|
using CursorLang.Core.Services;
|
||||||
|
using CursorLang.Settings.ViewModels;
|
||||||
|
using CursorLang.Tests.Shared;
|
||||||
|
|
||||||
|
namespace CursorLang.Settings.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.Current.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));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Next to the caret the popup only goes beside it: above or below is where the next
|
||||||
|
// line of the text is
|
||||||
|
[Fact]
|
||||||
|
public void The_caret_is_offered_two_sides_and_no_more()
|
||||||
|
{
|
||||||
|
using SettingsViewModel viewModel = Create();
|
||||||
|
|
||||||
|
Assert.Equal(
|
||||||
|
[CaretSide.Left, CaretSide.Right],
|
||||||
|
viewModel.CaretSides.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:CaretSide_Left", viewModel.CaretSides[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.Current.BackgroundColor, viewModel.BackgroundPalette);
|
||||||
|
Assert.Contains(settings.Current.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<System.Drawing.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,
|
||||||
|
Version? version = null) =>
|
||||||
|
new(settings ?? new AppSettings(),
|
||||||
|
localization ?? new FakeLocalizationService(),
|
||||||
|
startup ?? new FakeStartupService(),
|
||||||
|
version ?? new Version(1, 0, 0, 0));
|
||||||
|
|
||||||
|
// 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,171 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using System.Windows;
|
||||||
|
using System.Windows.Data;
|
||||||
|
using System.Windows.Media;
|
||||||
|
using CursorLang.Core.Models;
|
||||||
|
using CursorLang.Settings.Views;
|
||||||
|
using DrawingColor = System.Drawing.Color;
|
||||||
|
|
||||||
|
namespace CursorLang.Settings.Tests.Views;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The binding converters: they decide what the settings window shows and what
|
||||||
|
/// it keeps out of sight — and they are the border between the colours the
|
||||||
|
/// settings hold, which are GDI ones, and the brushes WPF paints with.
|
||||||
|
/// </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(DrawingColor.FromArgb(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(DrawingColor.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(DrawingColor), null, Culture));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_colour_turns_into_a_brush()
|
||||||
|
{
|
||||||
|
var converter = new ColorToBrushConverter();
|
||||||
|
DrawingColor color = DrawingColor.FromArgb(0x10, 0x20, 0x30);
|
||||||
|
|
||||||
|
var brush = Assert.IsType<SolidColorBrush>(converter.Convert(color, typeof(Brush), null, Culture));
|
||||||
|
|
||||||
|
Assert.Equal(Color.FromArgb(color.A, color.R, color.G, color.B), 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();
|
||||||
|
DrawingColor color = DrawingColor.FromArgb(0x10, 0x20, 0x30);
|
||||||
|
var brush = new SolidColorBrush(Color.FromArgb(color.A, color.R, color.G, color.B));
|
||||||
|
|
||||||
|
Assert.Equal(color, converter.ConvertBack(brush, typeof(DrawingColor), 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(DrawingColor), 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,549 @@
|
|||||||
|
using System.Windows;
|
||||||
|
using System.Windows.Controls;
|
||||||
|
using System.Windows.Controls.Primitives;
|
||||||
|
using System.Windows.Data;
|
||||||
|
using System.Windows.Media;
|
||||||
|
using CursorLang.Core.Models;
|
||||||
|
using CursorLang.Core.Services;
|
||||||
|
using CursorLang.Settings.Services;
|
||||||
|
using CursorLang.Settings.Tests.Infrastructure;
|
||||||
|
using CursorLang.Settings.ViewModels;
|
||||||
|
using CursorLang.Settings.Views;
|
||||||
|
using CursorLang.Tests.Shared;
|
||||||
|
|
||||||
|
namespace CursorLang.Settings.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();
|
||||||
|
settings.Current.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));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The preview follows the mode: switching it shows the settings of the new one.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The look is bound through the current mode, so a switch has to reach the window.
|
||||||
|
/// Bound to a mode by name, the sliders would keep showing the cursor mode whatever
|
||||||
|
/// was chosen above them.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public void A_switch_of_the_mode_shows_the_settings_of_that_mode()
|
||||||
|
{
|
||||||
|
Sta.Run(() =>
|
||||||
|
{
|
||||||
|
var settings = new AppSettings();
|
||||||
|
settings.AtCursor.FontSize = 24;
|
||||||
|
settings.FixedPoint.FontSize = 56;
|
||||||
|
|
||||||
|
using SettingsViewModel viewModel = CreateViewModel(settings);
|
||||||
|
|
||||||
|
Open(viewModel, window =>
|
||||||
|
{
|
||||||
|
Assert.Contains(FindAll<TextBlock>(window), text => Math.Abs(text.FontSize - 24) < 0.001);
|
||||||
|
|
||||||
|
settings.PlacementMode = PopupPlacementMode.FixedPoint;
|
||||||
|
window.UpdateLayout();
|
||||||
|
|
||||||
|
Assert.Contains(FindAll<TextBlock>(window), text => Math.Abs(text.FontSize - 56) < 0.001);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// In the middle of the monitor the offset from the edge cannot be set, but it stays
|
||||||
|
/// in place.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// There is no edge to stand off from there, so the setting goes grey. Taking the row
|
||||||
|
/// out of the section instead would move everything below it on every switch of the
|
||||||
|
/// place — the section is not to jump about.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public void In_the_middle_of_the_monitor_the_offset_from_the_edge_goes_grey()
|
||||||
|
{
|
||||||
|
Sta.Run(() =>
|
||||||
|
{
|
||||||
|
var settings = new AppSettings { PlacementMode = PopupPlacementMode.FixedPoint };
|
||||||
|
using SettingsViewModel viewModel = CreateViewModel(settings);
|
||||||
|
|
||||||
|
Open(viewModel, window =>
|
||||||
|
{
|
||||||
|
Slider offset = Assert.Single(FindSlidersBoundTo(window, "Settings.FixedPoint.Offset"));
|
||||||
|
|
||||||
|
Assert.True(offset.IsVisible);
|
||||||
|
Assert.False(offset.IsEnabled);
|
||||||
|
|
||||||
|
// And it looks the part: the template draws from brushes that know
|
||||||
|
// nothing of the state, so the style has to fade it
|
||||||
|
Assert.True(offset.Opacity < 1);
|
||||||
|
|
||||||
|
settings.FixedPoint.Position = ScreenPosition.TopRight;
|
||||||
|
window.UpdateLayout();
|
||||||
|
|
||||||
|
Assert.True(offset.IsVisible);
|
||||||
|
Assert.True(offset.IsEnabled);
|
||||||
|
Assert.Equal(1, offset.Opacity);
|
||||||
|
|
||||||
|
settings.FixedPoint.Position = ScreenPosition.Center;
|
||||||
|
window.UpdateLayout();
|
||||||
|
|
||||||
|
Assert.True(offset.IsVisible);
|
||||||
|
Assert.False(offset.IsEnabled);
|
||||||
|
Assert.True(offset.Opacity < 1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Another mode has no fixed point at all, and its offset has no place on screen
|
||||||
|
[Fact]
|
||||||
|
public void Away_from_the_fixed_point_its_offset_is_not_shown()
|
||||||
|
{
|
||||||
|
Sta.Run(() =>
|
||||||
|
{
|
||||||
|
var settings = new AppSettings { PlacementMode = PopupPlacementMode.AtCursor };
|
||||||
|
using SettingsViewModel viewModel = CreateViewModel(settings);
|
||||||
|
|
||||||
|
Open(viewModel, window =>
|
||||||
|
Assert.All(FindSlidersBoundTo(window, "Settings.FixedPoint.Offset"), slider =>
|
||||||
|
Assert.False(slider.IsVisible)));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// What the look belongs to is explained in a tooltip next to the mode.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The sliders of the look show other numbers after a switch of the mode, and that
|
||||||
|
/// has to be explained. A line of text saying so would sit in the section forever,
|
||||||
|
/// while the question is asked once.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public void The_window_explains_that_the_look_belongs_to_the_mode()
|
||||||
|
{
|
||||||
|
Sta.Run(() =>
|
||||||
|
{
|
||||||
|
using SettingsViewModel viewModel = CreateViewModel();
|
||||||
|
|
||||||
|
Open(viewModel, window =>
|
||||||
|
{
|
||||||
|
// The text of a tooltip is bound but not computed until the tooltip is
|
||||||
|
// shown, so what it is bound to is what gets checked
|
||||||
|
List<TextBlock> hints =
|
||||||
|
[
|
||||||
|
.. FindAll<TextBlock>(window)
|
||||||
|
.Where(text => text.ToolTip is ToolTip tip &&
|
||||||
|
tip.Content is TextBlock content &&
|
||||||
|
PathOf(content, TextBlock.TextProperty) == "Localization[PopupLookPerModeHint]"),
|
||||||
|
];
|
||||||
|
|
||||||
|
TextBlock link = Assert.Single(hints);
|
||||||
|
|
||||||
|
Assert.Equal("en:MoreInfoLink", link.Text);
|
||||||
|
Assert.True(link.IsVisible);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Beside the caret and nowhere else: the list offers the two sides it has
|
||||||
|
[Fact]
|
||||||
|
public void The_caret_is_offered_two_sides_in_the_window()
|
||||||
|
{
|
||||||
|
Sta.Run(() =>
|
||||||
|
{
|
||||||
|
var settings = new AppSettings { PlacementMode = PopupPlacementMode.AtCaret };
|
||||||
|
using SettingsViewModel viewModel = CreateViewModel(settings);
|
||||||
|
|
||||||
|
Open(viewModel, window =>
|
||||||
|
{
|
||||||
|
ComboBox sides = Assert.Single(
|
||||||
|
FindAll<ComboBox>(window),
|
||||||
|
box => BindingOperations.GetBinding(box, Selector.SelectedValueProperty)?.Path.Path
|
||||||
|
== "Settings.AtCaret.Side");
|
||||||
|
|
||||||
|
Assert.Equal(2, sides.Items.Count);
|
||||||
|
|
||||||
|
sides.SelectedValue = CaretSide.Left;
|
||||||
|
|
||||||
|
Assert.Equal(CaretSide.Left, settings.AtCaret.Side);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// The sliders of the look write into the mode chosen above them
|
||||||
|
[Fact]
|
||||||
|
public void The_look_is_edited_in_the_mode_that_is_chosen()
|
||||||
|
{
|
||||||
|
Sta.Run(() =>
|
||||||
|
{
|
||||||
|
var settings = new AppSettings { PlacementMode = PopupPlacementMode.AtCaret };
|
||||||
|
using SettingsViewModel viewModel = CreateViewModel(settings);
|
||||||
|
|
||||||
|
Open(viewModel, window =>
|
||||||
|
{
|
||||||
|
Slider slider = Assert.Single(FindSlidersBoundTo(window, "Settings.Current.FontSize"));
|
||||||
|
|
||||||
|
slider.Value = 44;
|
||||||
|
|
||||||
|
Assert.Equal(44, settings.AtCaret.FontSize);
|
||||||
|
Assert.Equal(20, settings.AtCursor.FontSize);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[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
|
||||||
|
System.Drawing.Color chosen = viewModel.BackgroundPalette[2];
|
||||||
|
settings.Current.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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// The version is nowhere else in the window, so the title has to carry it
|
||||||
|
[Fact]
|
||||||
|
public void The_title_of_the_window_shows_the_version()
|
||||||
|
{
|
||||||
|
Sta.Run(() =>
|
||||||
|
{
|
||||||
|
var localization = new FakeLocalizationService();
|
||||||
|
localization.Strings["SettingsTitle"] = "CursorLang {0} — Settings";
|
||||||
|
|
||||||
|
using SettingsViewModel viewModel = CreateViewModel(
|
||||||
|
localization: localization,
|
||||||
|
version: new Version(1, 2, 3, 0));
|
||||||
|
|
||||||
|
Open(viewModel, window => Assert.Equal("CursorLang 1.2.3 — Settings", window.Title));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// The application lives in the tray, and the settings window is a guest on the
|
||||||
|
// screen: its close button puts it away rather than ends anything
|
||||||
|
/// <summary>
|
||||||
|
/// Closing the window closes it. The tray it used to hide into belongs to another
|
||||||
|
/// process now, and there is nothing shared left to keep alive.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void Closing_the_window_really_closes_it()
|
||||||
|
{
|
||||||
|
Sta.Run(() =>
|
||||||
|
{
|
||||||
|
using SettingsViewModel viewModel = CreateViewModel();
|
||||||
|
|
||||||
|
Open(viewModel, window =>
|
||||||
|
{
|
||||||
|
window.Close();
|
||||||
|
|
||||||
|
Assert.False(window.IsVisible);
|
||||||
|
|
||||||
|
Assert.Throws<InvalidOperationException>(window.Show);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[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,
|
||||||
|
Version? version = null) =>
|
||||||
|
new(settings ?? new AppSettings(),
|
||||||
|
localization ?? new FakeLocalizationService(),
|
||||||
|
startup ?? new FakeStartupService(),
|
||||||
|
version ?? new Version(1, 0, 0, 0));
|
||||||
|
|
||||||
|
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 IEnumerable<Button> FindButtonsBoundTo(DependencyObject root, string path) =>
|
||||||
|
FindAll<Button>(root).Where(button =>
|
||||||
|
BindingOperations.GetBinding(button, ButtonBase.CommandProperty)?.Path.Path == path);
|
||||||
|
|
||||||
|
private static string? PathOf(DependencyObject element, DependencyProperty property) =>
|
||||||
|
BindingOperations.GetBinding(element, property)?.Path.Path;
|
||||||
|
|
||||||
|
private static IEnumerable<Slider> FindSlidersBoundTo(DependencyObject root, string path) =>
|
||||||
|
FindAll<Slider>(root).Where(slider =>
|
||||||
|
BindingOperations.GetBinding(slider, RangeBase.ValueProperty)?.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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user