Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .agents/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ Use Microsoft.Extensions.DependencyInjection:

```csharp
services.AddSingleton<ConfigurationManager>();
services.AddTransient<Switcher>(); // Switcher is transient, NOT singleton
services.AddSingleton<Switcher>(); // session state: next-window cycle, toggle memory, start debounce
services.AddSingleton<StatsService>();
```

Expand Down
17 changes: 15 additions & 2 deletions AppSwitcher.Tests/Configuration/ConfigurationValidatorTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,9 @@ public void ValidateAndLog_ReturnsError_WhenProcessFileDoesNotExist()
[InlineData(Key.F1)]
[InlineData(Key.Return)]
[InlineData(Key.Space)]
public void ValidateAndLog_ReturnsError_WhenApplicationKeyIsNotALetter(Key key)
[InlineData(Key.Oem8)]
[InlineData(Key.OemBackslash)]
public void ValidateAndLog_ReturnsError_WhenApplicationKeyIsNotALetterOrPunctuation(Key key)
{
var tempFile = Path.GetTempFileName();
try
Expand All @@ -108,7 +110,18 @@ public void ValidateAndLog_ReturnsError_WhenApplicationKeyIsNotALetter(Key key)
[InlineData(Key.A)]
[InlineData(Key.M)]
[InlineData(Key.Z)]
public void ValidateAndLog_ReturnsSuccess_WhenApplicationKeyIsALetter(Key key)
[InlineData(Key.OemTilde)]
[InlineData(Key.OemMinus)]
[InlineData(Key.OemPlus)]
[InlineData(Key.OemOpenBrackets)]
[InlineData(Key.OemCloseBrackets)]
[InlineData(Key.OemPipe)]
[InlineData(Key.OemSemicolon)]
[InlineData(Key.OemQuotes)]
[InlineData(Key.OemComma)]
[InlineData(Key.OemPeriod)]
[InlineData(Key.OemQuestion)]
public void ValidateAndLog_ReturnsSuccess_WhenApplicationKeyIsALetterOrPunctuation(Key key)
{
var tempFile = Path.GetTempFileName();
try
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ public void FromApplicationConfiguration_MapsPackagedFields()
[InlineData(CycleMode.NextApp)]
[InlineData(CycleMode.Hide)]
[InlineData(CycleMode.NextWindow)]
[InlineData(CycleMode.ToggleWindow)]
public void RoundTrip_PreservesAllData_Win32(CycleMode cycleMode)
{
var original = new ApplicationConfiguration(Key.C, @"C:\apps\tool.exe", cycleMode, false);
Expand Down
46 changes: 45 additions & 1 deletion AppSwitcher.Tests/Extensions/KeyExtensionsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,52 @@ public class KeyExtensionsTests
[InlineData(Key.D0, "0")]
[InlineData(Key.D1, "1")]
[InlineData(Key.D9, "9")]
public void KeyExtensions_ToFriendlyString_ReturnsProperString(Key key, string expected)
[InlineData(Key.OemTilde, "`")]
[InlineData(Key.OemMinus, "-")]
[InlineData(Key.OemPlus, "=")]
[InlineData(Key.OemOpenBrackets, "[")]
[InlineData(Key.OemCloseBrackets, "]")]
[InlineData(Key.OemPipe, "\\")]
[InlineData(Key.OemSemicolon, ";")]
[InlineData(Key.OemQuotes, "'")]
[InlineData(Key.OemComma, ",")]
[InlineData(Key.OemPeriod, ".")]
[InlineData(Key.OemQuestion, "/")]
public void ToFriendlyString_ReturnsProperString(Key key, string expected)
{
key.ToFriendlyString().Should().Be(expected);
}

[Theory]
[InlineData(Key.A)]
[InlineData(Key.Z)]
[InlineData(Key.OemTilde)]
[InlineData(Key.OemMinus)]
[InlineData(Key.OemPlus)]
[InlineData(Key.OemOpenBrackets)]
[InlineData(Key.OemCloseBrackets)]
[InlineData(Key.OemPipe)]
[InlineData(Key.OemSemicolon)]
[InlineData(Key.OemQuotes)]
[InlineData(Key.OemComma)]
[InlineData(Key.OemPeriod)]
[InlineData(Key.OemQuestion)]
public void IsApplicationKey_ReturnsTrue_ForLettersAndPunctuation(Key key)
{
key.IsApplicationKey().Should().BeTrue();
}

[Theory]
[InlineData(Key.None)]
[InlineData(Key.D0)]
[InlineData(Key.D9)]
[InlineData(Key.F1)]
[InlineData(Key.Space)]
[InlineData(Key.Return)]
[InlineData(Key.Oem8)]
[InlineData(Key.OemBackslash)]
public void IsApplicationKey_ReturnsFalse_ForNonApplicationKeys(Key key)
{
key.IsApplicationKey().Should().BeFalse();
}
}
22 changes: 18 additions & 4 deletions AppSwitcher.Tests/Input/KeyStateMachineTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,18 @@ public void ProcessKeyUp_ReturnsNoOp_ForNonModifierKey()
[Theory]
[InlineData(Key.A)]
[InlineData(Key.Z)]
public void ProcessKeyDown_ReturnsLetterKeyPressed_WhenLetterPressedWhileModifierHeld(Key key)
[InlineData(Key.OemTilde)]
[InlineData(Key.OemMinus)]
[InlineData(Key.OemPlus)]
[InlineData(Key.OemOpenBrackets)]
[InlineData(Key.OemCloseBrackets)]
[InlineData(Key.OemPipe)]
[InlineData(Key.OemSemicolon)]
[InlineData(Key.OemQuotes)]
[InlineData(Key.OemComma)]
[InlineData(Key.OemPeriod)]
[InlineData(Key.OemQuestion)]
public void ProcessKeyDown_ReturnsLetterKeyPressed_WhenLetterOrPunctuationPressedWhileModifierHeld(Key key)
{
_sut.ProcessKeyDown(Key.Apps);

Expand Down Expand Up @@ -217,12 +228,15 @@ public void ProcessKeyUp_ReturnsModifierReleasedAfterAction_WhenMultipleLettersP

// ── Unrelated key handling ──────────────────────────────────────────────

[Fact]
public void ProcessKeyDown_ReturnsUnrelatedKeyReset_WhenUnrelatedKeyPressedWhileModifierHeld()
[Theory]
[InlineData(Key.F5)]
[InlineData(Key.Oem8)]
[InlineData(Key.OemBackslash)]
public void ProcessKeyDown_ReturnsUnrelatedKeyReset_WhenUnrelatedKeyPressedWhileModifierHeld(Key key)
{
_sut.ProcessKeyDown(Key.Apps);

var result = _sut.ProcessKeyDown(Key.F5);
var result = _sut.ProcessKeyDown(key);

result.Should().BeOfType<UnrelatedKeyReset>();
_sut.IsModifierHeld.Should().BeFalse();
Expand Down
152 changes: 152 additions & 0 deletions AppSwitcher.Tests/ToggleWindowTargetResolverTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
using AppSwitcher;
using AwesomeAssertions;
using Windows.Win32.Foundation;
using Xunit;

namespace AppSwitcher.Tests;

public class ToggleWindowTargetResolverTests
{
private static HWND H(int value) => new((nint)value);

[Fact]
public void Resolve_ReturnsNull_WhenNoMatchingWindows()
{
var result = ToggleWindowTargetResolver.Resolve([], H(1), H(2));

result.Should().BeNull();
}

[Fact]
public void Resolve_HidesSibling_WhenSiblingIsForeground()
{
var remembered = H(10);
var sibling = H(20);

var result = ToggleWindowTargetResolver.Resolve(
[remembered, sibling],
currentHandle: sibling,
rememberedHandle: remembered);

result.Should().Be(new ToggleWindowTargetResolver.Result(sibling, ToggleWindowAction.Hide));
}

[Fact]
public void Resolve_HidesRemembered_WhenForegroundIsRemembered()
{
var remembered = H(10);
var sibling = H(20);

var result = ToggleWindowTargetResolver.Resolve(
[remembered, sibling],
currentHandle: remembered,
rememberedHandle: remembered);

result.Should().Be(new ToggleWindowTargetResolver.Result(remembered, ToggleWindowAction.Hide));
}

[Fact]
public void Resolve_AfterHide_RestoresRemembered_WhenSiblingTookFocus()
{
var remembered = H(10);
var sibling = H(20);
HWND[] matching = [remembered, sibling];

var hide = ToggleWindowTargetResolver.Resolve(matching, currentHandle: remembered, rememberedHandle: remembered);

hide.Should().Be(new ToggleWindowTargetResolver.Result(remembered, ToggleWindowAction.Hide));

var restore = ToggleWindowTargetResolver.Resolve(
matching,
currentHandle: sibling,
rememberedHandle: remembered,
rememberedIsMinimized: true);

restore.Should().Be(new ToggleWindowTargetResolver.Result(remembered, ToggleWindowAction.Activate));
}

[Fact]
public void Resolve_ActivatesRemembered_WhenForegroundIsOtherApp()
{
var w1 = H(10);
var w2 = H(20);
var other = H(99);

var result = ToggleWindowTargetResolver.Resolve([w1, w2], currentHandle: other, rememberedHandle: w2);

result.Should().Be(new ToggleWindowTargetResolver.Result(w2, ToggleWindowAction.Activate));
}

[Fact]
public void Resolve_ActivatesFirst_WhenNoRememberedAndForegroundOtherApp()
{
var w1 = H(10);
var w2 = H(20);
var other = H(99);

var result = ToggleWindowTargetResolver.Resolve([w1, w2], currentHandle: other, rememberedHandle: null);

result.Should().Be(new ToggleWindowTargetResolver.Result(w1, ToggleWindowAction.Activate));
}

[Fact]
public void Resolve_ActivatesFirst_WhenRememberedMissingFromMatching()
{
var w1 = H(10);
var w2 = H(20);
var closed = H(30);
var other = H(99);

var result = ToggleWindowTargetResolver.Resolve([w1, w2], currentHandle: other, rememberedHandle: closed);

result.Should().Be(new ToggleWindowTargetResolver.Result(w1, ToggleWindowAction.Activate));
}

[Fact]
public void Resolve_UsesCurrent_WhenRememberedMissingAndForegroundMatches()
{
var w1 = H(10);
var w2 = H(20);
var closed = H(30);

var result = ToggleWindowTargetResolver.Resolve([w1, w2], currentHandle: w2, rememberedHandle: closed);

result.Should().Be(new ToggleWindowTargetResolver.Result(w2, ToggleWindowAction.Hide));
}

[Fact]
public void Resolve_UsesRemembered_WhenCurrentHandleUnknown()
{
var w1 = H(10);
var w2 = H(20);

var result = ToggleWindowTargetResolver.Resolve([w1, w2], currentHandle: null, rememberedHandle: w2);

result.Should().Be(new ToggleWindowTargetResolver.Result(w2, ToggleWindowAction.Activate));
}

[Fact]
public void Resolve_HidesFirst_WhenForegroundIsFirstMatching()
{
var w1 = H(10);
var w2 = H(20);

var result = ToggleWindowTargetResolver.Resolve([w1, w2], currentHandle: w1, rememberedHandle: null);

result.Should().Be(new ToggleWindowTargetResolver.Result(w1, ToggleWindowAction.Hide));
}

[Fact]
public void Resolve_Activates_WhenForegroundMatchingWindowIsMinimized()
{
var weixin = H(0x1073a);

var result = ToggleWindowTargetResolver.Resolve(
[weixin],
currentHandle: weixin,
rememberedHandle: weixin,
currentIsMinimized: true);

result.Should().Be(new ToggleWindowTargetResolver.Result(weixin, ToggleWindowAction.Activate));
}
}
3 changes: 2 additions & 1 deletion AppSwitcher/Configuration/Configuration.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ public enum CycleMode
{
NextApp = 0,
Hide,
NextWindow
NextWindow,
ToggleWindow
}

internal record Configuration(
Expand Down
9 changes: 5 additions & 4 deletions AppSwitcher/Configuration/ConfigurationValidator.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using Microsoft.Extensions.Logging;
using AppSwitcher.Extensions;
using Microsoft.Extensions.Logging;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using System.Windows.Input;
Expand Down Expand Up @@ -62,13 +63,13 @@ public ValidationResult ValidateApplication(IApplicationConfiguration app)
{
if (!File.Exists(app.ProcessPath))
{
return ValidationResult.Error(app.ProcessName,"Application process path missing or invalid. Re-add the application to fix it.");
return ValidationResult.Error(app.ProcessName, "Application process path missing or invalid. Re-add the application to fix it.");
}
}

if (app.Key is Key.None or not (>=Key.A and <= Key.Z))
if (!app.Key.IsApplicationKey())
{
return ValidationResult.Error(app.ProcessName, "Application key invalid - it should be a single letter");
return ValidationResult.Error(app.ProcessName, "Application key invalid - it should be a single letter or punctuation key");
}

return ValidationResult.Success;
Expand Down
38 changes: 30 additions & 8 deletions AppSwitcher/Extensions/KeyExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,35 @@ public static class KeyExtensions
public static bool IsLetter(this Key key) => key is >= Key.A and <= Key.Z;
public static bool IsDigit(this Key key) => key is >= Key.D0 and <= Key.D9;

public static string ToFriendlyString(this Key key)
{
if (key.IsDigit())
{
return ((char)('0'+ key - Key.D0)).ToString();
}
public static bool IsPunctuation(this Key key) => key is
Key.OemTilde or
Key.OemMinus or
Key.OemPlus or
Key.OemOpenBrackets or
Key.OemCloseBrackets or
Key.OemPipe or
Key.OemSemicolon or
Key.OemQuotes or
Key.OemComma or
Key.OemPeriod or
Key.OemQuestion;

public static bool IsApplicationKey(this Key key) => key.IsLetter() || key.IsPunctuation();

return key.ToString();
}
public static string ToFriendlyString(this Key key) => key switch
{
_ when key.IsDigit() => ((char)('0' + key - Key.D0)).ToString(),
Key.OemTilde => "`",
Key.OemMinus => "-",
Key.OemPlus => "=",
Key.OemOpenBrackets => "[",
Key.OemCloseBrackets => "]",
Key.OemPipe => "\\",
Key.OemSemicolon => ";",
Key.OemQuotes => "'",
Key.OemComma => ",",
Key.OemPeriod => ".",
Key.OemQuestion => "/",
_ => key.ToString()
};
}
Loading