69 Commits
Author SHA1 Message Date
alexandClaude Sonnet 5 b6733f37fa Add SEO basics: separate EN/RU pages, sitemap, OG banner
Splits the landing page into /  and /ru/ so each language can rank on
its own, with hreflang links between them; boot.js/main.js now do a
real navigation between the two instead of toggling text in place.
Adds robots.txt and sitemap.xml, JSON-LD SoftwareApplication markup,
Twitter Card tags, and a proper 1200x630 og-image.png in place of the
SVG favicon for social previews.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 23:57:28 +05:00
alex 8fe0e72664 added link for downloading app
Pull request / build (pull_request) Successful in 45s
2026-09-04 10:00:36 +05:00
alex 246751a4fd Merge pull request 'Fix certification comment' (#15) from fix-certification-comment into master
Release / release (push) Failing after 7m3s
Reviewed-on: #15
2026-09-03 05:00:16 +00:00
alex 53720e95db fixed width resize when change screen scale
Pull request / build (pull_request) Successful in 37s
2026-09-03 09:55:29 +05:00
alex ccd914d6fe fix resize on different screens try -1 2026-09-03 09:40:58 +05:00
alex cd63644a83 added material for certification 2026-09-03 08:18:05 +05:00
alex 1bad7785d0 add Store listing images: 9:16 poster and 300x300 logo
Partner Center's screenshots and logos steps needed sizes the MSIX package
doesn't produce, so they are drawn separately with the same mark.
2026-09-01 11:01:45 +05:00
alex 0ba581946f add site address to og:url, og:image and canonical link
The site is now live at cursor-lang.alrakis.kz, so these tags can be filled in.
2026-09-01 10:19:07 +05:00
alex 9b4ac00da9 Merge pull request 'add landing site for the Microsoft Store submission' (#14) from landing-site into master
Reviewed-on: #14
2026-09-01 04:46:20 +00:00
alex 936e53d55b add landing site for the Microsoft Store submission
Pull request / build (pull_request) Successful in 47s
A bilingual (EN/RU) static page in site/, split into index.html, styles.css,
boot.js and main.js, with a favicon.svg. Documents placement modes, per-mode
look, the Caps Lock shortcut and a privacy policy — the last is meant to
double as the Partner Center privacy policy URL.

STORE_URL is still a placeholder in both download buttons until the Store
listing exists.
2026-09-01 09:36:37 +05:00
alex 42e93346ab Merge pull request 'fixed a bug with displaying the application version in the window title' (#13) from fix-app-version into master
Release / release (push) Successful in 3m32s
Reviewed-on: #13
2026-08-31 06:52:05 +00:00
alex 9ab26afc16 fixed a bug with displaying the application version in the window title
Pull request / build (pull_request) Successful in 29s
2026-08-31 11:51:13 +05:00
alex 743974a0c1 added comments to readme (#12)
Release / release (push) Successful in 3m47s
Reviewed-on: #12
Co-authored-by: Aleksandr Neychev <alexnejchev73@gmail.com>
2026-08-15 02:56:58 +00:00
alex 9c6ec489e1 modified caret mode (#11)
Reviewed-on: #11
Co-authored-by: Aleksandr Neychev <alexnejchev73@gmail.com>
2026-08-14 23:07:20 +00:00
alex b2c7b31eb5 take the artifact action from a fork of our own (#10)
Release / release (push) Successful in 4m31s
Upstream v4 refuses to run anywhere but github.com: it reads
GITHUB_SERVER_URL, calls everything else a GitHub Enterprise Server and
throws before making a request. A probe run settled the rest — the name
cannot be overridden from a step, because the runner sets the GITHUB_
ones last. So the action now comes from this Gitea, forked with that one
check removed, on a fixed tag rather than a floating one.

Reviewed-on: #10
Co-authored-by: Aleksandr Neychev <alexnejchev73@gmail.com>
2026-08-14 00:26:27 +00:00
alex 5670ad0e64 fixed isGhes func in release pipeline (#9)
Release / release (push) Failing after 4m12s
Reviewed-on: #9
Co-authored-by: Aleksandr Neychev <alexnejchev73@gmail.com>
2026-08-13 23:34:38 +00:00
alex 1540be3b79 fix wix installer bug (#8)
Release / release (push) Failing after 4m3s
Reviewed-on: #8
Co-authored-by: Aleksandr Neychev <alexnejchev73@gmail.com>
2026-08-13 22:02:29 +00:00
alex 88dd931cb4 added sending closing event message to settings windows (#7)
Release / release (push) Successful in 5m7s
Reviewed-on: #7
Co-authored-by: Aleksandr Neychev <alexnejchev73@gmail.com>
2026-08-13 21:20:03 +00:00
alex 70b83a5fec reflow to vertical (#6)
Reviewed-on: #6
Co-authored-by: Aleksandr Neychev <alexnejchev73@gmail.com>
2026-08-13 21:13:28 +00:00
alex 0682f2b875 fixed warning 2026-08-14 02:07:56 +05:00
alex 5dea510bbc fixed msi pipeline
Release / release (push) Successful in 5m10s
2026-08-13 17:13:51 +05:00
alex 03a3dd4c6e Add package sign to pipeline (#5)
Release / release (push) Failing after 4m39s
leftovers

Reviewed-on: #5
Co-authored-by: Aleksandr Neychev <alexnejchev73@gmail.com>
2026-08-13 11:55:20 +00:00
alex 42974ecc8f modified release pipeline (#4)
Release / release (push) Canceled after 47s
await certificate from ssl.com

Reviewed-on: #4
Co-authored-by: Aleksandr Neychev <alexnejchev73@gmail.com>
2026-08-13 11:17:22 +00:00
alex 5e11b16758 changed settings saving (#3)
Reviewed-on: #3
Co-authored-by: Aleksandr Neychev <alexnejchev73@gmail.com>
2026-08-12 17:15:12 +00:00
alex 68b3d544d2 modified layout (#2)
Reviewed-on: #2
Co-authored-by: Aleksandr Neychev <alexnejchev73@gmail.com>
2026-08-12 14:25:05 +00:00
alex 55ac8e6556 lightweight variant (#1)
Reviewed-on: #1
Co-authored-by: Aleksandr Neychev <alexnejchev73@gmail.com>
2026-08-12 13:37:30 +00:00
alex a0d3098fe4 added tray menu 2026-08-12 04:59:22 +05:00
alex 08192d317e fixed tests
Release / release (push) Successful in 8m58s
2026-08-11 16:34:28 +05:00
alex 3bf2018a62 fixed release version in pipeline
Release / release (push) Failing after 2m19s
2026-08-11 15:56:23 +05:00
alex cb39198e0a fixed pipeline
Release / release (push) Failing after 20s
2026-08-11 15:44:26 +05:00
alex a777b71b1a modified README.md
Release / release (push) Canceled after 2m15s
2026-08-11 15:02:04 +05:00
alex 71a8d3e6fb added CI 2026-08-11 15:01:47 +05:00
alex aee93a4ea3 removed "soap" from ico 2026-08-11 15:01:24 +05:00
alex d6886eae7c added packaging 2026-08-11 15:00:52 +05:00
alex 86c0085472 modified README 2026-08-09 20:28:39 +05:00
alex 8f1b02770c registrated new services in DI container 2026-08-09 20:27:59 +05:00
alex af43aec773 added updater 2026-08-09 20:27:26 +05:00
alex b2873139ae prepared project to MSIX 2026-08-09 20:26:44 +05:00
alex 7ecad05e1c rearranging the settings window layout 2026-08-09 20:25:56 +05:00
alex 163b86d239 modified PopupLayout 2026-08-09 20:24:21 +05:00
alex 1fc9765a3d modified single instance feature 2026-08-09 20:21:30 +05:00
alex 0c2f0bd618 added statup with Windows feature 2026-08-09 20:17:43 +05:00
alex bb19306168 modified CaretNative 2026-08-09 20:15:22 +05:00
alex 81684b55c9 modified settings services 2026-08-09 20:14:43 +05:00
alex 1053932cfd modified ThemeService 2026-08-09 20:11:14 +05:00
alex 71ad8879ac modified resources 2026-08-09 20:08:28 +05:00
alex 1a91d837cf extended models 2026-08-09 20:07:20 +05:00
alex 7e4e569d53 fixed comment into code 2026-08-09 20:02:23 +05:00
alex 778fc1034b modified Controls - added new controls 2026-08-09 18:35:56 +05:00
alex d5dc228ebe added tests to project 2026-08-09 18:31:43 +05:00
alex 6da32504f9 added git attributes
LFS turn on
2026-08-09 03:46:20 +05:00
alex 4aa2a1e023 added editor configuration 2026-08-09 03:45:38 +05:00
alex ef13f4c7ef added saving app placement 2026-08-09 01:55:45 +05:00
alex 0b121827ed fixed window resizing 2026-08-09 01:37:52 +05:00
alex 9e0b00dd98 added a ban on running a second instance of the application. 2026-08-09 01:29:25 +05:00
alex 4a2ebd9a70 added administrator grant 2026-08-09 01:12:58 +05:00
alex e99f30fcbc added layour swithing by caps key 2026-08-09 01:02:52 +05:00
alex 7c5675f69f added foreground color for text in tooltip 2026-08-09 00:29:05 +05:00
alex 253373d47e fixed bug - combox show correct current value when swithed language 2026-08-09 00:20:11 +05:00
alex b581d560b0 added interface themes 2026-08-09 00:09:47 +05:00
alex 8a19cb6a9c added top center and bottom center to side in point to screen mode 2026-08-08 23:37:49 +05:00
alex 2af494d2f6 modified each other mode has own side and offset 2026-08-08 23:27:28 +05:00
alex 5ecc46f7a3 fixed bug - careet in notepad and menu start OS 2026-08-08 23:00:37 +05:00
alex ba0748920b added feature - show tootip near careet 2026-08-08 22:42:31 +05:00
alex 240ffe7687 fixed bug - change tooltip size when change font size 2026-08-08 22:13:32 +05:00
alex 1fdfe77d78 fixed bug drop list is empty after language switch 2026-08-08 21:55:34 +05:00
alex d146fd442a added multilang and settings 2026-08-08 21:48:16 +05:00
alex 3d614255d8 moved to MVVM architecture 2026-08-08 21:24:02 +05:00
alex a1ff8cc923 implemeted main feature - first try 2026-08-08 21:03:13 +05:00
175 changed files with 20471 additions and 82 deletions
+335
View File
@@ -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
+65
View File
@@ -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
+82
View File
@@ -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
+207
View File
@@ -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>
+317
View File
@@ -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();
}
}
+136
View File
@@ -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();
}
}
+54
View File
@@ -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>$(Version)</AssemblyVersion>
<FileVersion>$(Version)</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>
+161
View File
@@ -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;
}
}
+98
View File
@@ -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);
}
+229
View File
@@ -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;
}
}
+180
View File
@@ -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;
}
}
+38
View File
@@ -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;
}
}
}
+85
View File
@@ -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;
}
}
}
+128
View File
@@ -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;
}
}
}
+104
View File
@@ -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);
}
}
+19
View File
@@ -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);
});
}
}
+10
View File
@@ -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")]
+40
View File
@@ -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>
+237
View File
@@ -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;
}
}
+145
View File
@@ -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);
}
+12
View File
@@ -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
}
+41
View File
@@ -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;
}
+141
View File
@@ -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;
}
}
}
+62
View File
@@ -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,
}
+30
View File
@@ -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,
}
+29
View File
@@ -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.
+214
View File
@@ -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>
+214
View File
@@ -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;
}
}
+37
View File
@@ -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);
}
}
}
+95
View File
@@ -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);
}
+130
View File
@@ -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);
}
+249
View File
@@ -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);
}
+54
View File
@@ -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,
};
}
+66
View File
@@ -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;
}
}
+70
View File
@@ -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);
}
+120
View File
@@ -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