diff --git a/.github/workflows/docs_deploy.yml b/.github/workflows/docs_deploy.yml index 8db84333a..088b4f555 100644 --- a/.github/workflows/docs_deploy.yml +++ b/.github/workflows/docs_deploy.yml @@ -55,6 +55,10 @@ jobs: run: | dotnet tool restore + - name: Build schema + run: | + .\scripts\Generate-SourceFromSchema.ps1 + - run: dotnet tool update -g docfx - run: docfx docs/docfx.json diff --git a/docs/docs/customize/yaml.md b/docs/docs/customize/yaml.md index e97705e46..90a987cfe 100644 --- a/docs/docs/customize/yaml.md +++ b/docs/docs/customize/yaml.md @@ -81,7 +81,6 @@ To treat key modifiers like `LWin` and `RWin` the same, set `unify_key_modifiers ### Keybinds Example ```yaml -# yaml-language-server: $schema=https://raw.githubusercontent.com/dalyIsaac/Whim/main/src/Whim.Yaml/schema.json keybinds: entries: - command: whim.core.focus_next_monitor @@ -107,3 +106,135 @@ keybinds: unify_key_modifiers: true ``` + +## Filters + +By default, Whim ignores a built-in list of windows that are known to cause problems with dynamic tiling window manager. Behind the scenes, Whim automatically updates the built-in list of ignored windows based on a subset of the rules from the community-driven [collection of application rules](https://github.com/LGUG2Z/komorebi-application-specific-configuration) managed by komorebi. + +### Custom Filtering Behavior + +The filters configuration tells Whim to ignore windows that match the specified criteria. + +You can filter windows by: + +- `window_class` +- `process_file_name` +- `title` +- `title_regex` + +### Window Class Filter + +For example, to filter out Chromium windows with the class `Chrome_WidgetWin_1`, add the following to your configuration: + +```yaml +filters: + entries: + - filter_type: window_class + value: Chrome_WidgetWin_1 +``` + +### Process File Name Filter + +For example, to filter out windows with the process file name `explorer.exe`, add the following to your configuration: + +```yaml +filters: + entries: + - filter_type: process_file_name + value: explorer.exe +``` + +### Title Filter + +For example, to filter out windows with the title `Untitled - Notepad`, add the following to your configuration: + +```yaml +filters: + entries: + - filter_type: title + value: Untitled - Notepad +``` + +### Title Match Filter + +For example, to filter out windows with the title that matches the regex `^Untitled - Notepad$`, add the following to your configuration: + +```yaml +filters: + entries: + - filter_type: title_regex + value: ^Untitled - Notepad$ +``` + +## Routers + +The routers configuration tells Whim to route windows that match the specified criteria to the first workspace with name `workspace_name`. + +### Default Routing Behavior + +To customize the default window routing behavior, you can use the `routing_behavior` property. The default routing behavior is `route_to_launched_workspace`. + +The available routing behaviors are: + +- `route_to_launched_workspace` +- `route_to_active_workspace` +- `route_to_last_tracked_active_workspace` + +### Custom Routing Behavior + +You can also define custom routing behavior by specifying a list of routing entries. Each routing entry has a `router_type`, `value`, and `workspace_name`. + +The available router types are: + +- `window_class` +- `process_file_name` +- `title` +- `title_regex` + +#### Window Class Router + +For example, to route Chromium windows with the class `Chrome_WidgetWin_1` to the workspace `web`, add the following to your configuration: + +```yaml +routers: + entries: + - router_type: window_class + value: Chrome_WidgetWin_1 + workspace_name: web +``` + +#### Process File Name Router + +For example, to route windows with the process file name `explorer.exe` to the workspace `file_explorer`, add the following to your configuration: + +```yaml +routers: + entries: + - router_type: process_file_name + value: explorer.exe + workspace_name: file_explorer +``` + +#### Title Router + +For example, to route windows with the title `Untitled - Notepad` to the workspace `notepad`, add the following to your configuration: + +```yaml +routers: + entries: + - router_type: title + value: Untitled - Notepad + workspace_name: notepad +``` + +#### Title Match Router + +For example, to route windows with the title that matches the regex `^Untitled - Notepad$` to the workspace `notepad`, add the following to your configuration: + +```yaml +routers: + entries: + - router_type: title_regex + value: ^Untitled - Notepad$ + workspace_name: notepad +``` diff --git a/scripts/Generate-SourceFromSchema.ps1 b/scripts/Generate-SourceFromSchema.ps1 index 46272c6dd..cc6d1ecb3 100644 --- a/scripts/Generate-SourceFromSchema.ps1 +++ b/scripts/Generate-SourceFromSchema.ps1 @@ -2,11 +2,63 @@ Write-Host "Generating source from schema..." $schemaPath = ".\src\Whim.Yaml\schema.json" $outputPath = ".\src\Whim.Yaml\Generated" +$metadataPath = "$outputPath\metadata.json" +$now = Get-Date +if ($env:CI) { + $gitSha = $env:GITHUB_SHA +} +else { + $gitSha = (git rev-parse HEAD) +} -New-Item $outputPath -ItemType Directory | Out-Null +function Test-Regenerate { + param ( + [string]$schemaPath = ".\src\Whim.Yaml\schema.json", + [string]$outputPath = ".\src\Whim.Yaml\Generated" + ) + + if (!(Test-Path $outputPath)) { + Write-Host "Output directory does not exist, generating..." + New-Item $outputPath -ItemType Directory + return $true + } + + Write-Host "Output directory exists..." + + if ((Test-Path $schemaPath) -eq $false) { + Write-Host "Schema file does not exist, skipping..." + return $false + } + + $metadata = Get-Content $metadataPath | ConvertFrom-Json + if ($metadata.gitRef -ne $gitSha) { + Write-Host "Git ref has changed since last generation, regenerating..." + return $true + } + + $schemaLastWriteTime = (Get-Item $schemaPath).LastWriteTime + if ($metadata.lastWriteTime -lt $schemaLastWriteTime) { + Write-Host "Schema has changed since last generation, regenerating..." + return $true + } + + Write-Host "Schema has not changed since last generation, skipping..." + return $false +} + +if (!(Test-Regenerate -schemaPath $schemaPath -outputPath $outputPath)) { + Write-Host "Skipping generation..." + return +} dotnet tool run generatejsonschematypes ` $schemaPath ` --rootNamespace Whim.Yaml ` --useSchema Draft7 ` --outputPath $outputPath + +# If not in CI, write metadata file +if ($null -eq $env:CI) { + Write-Host "Writing metadata file..." + @{ gitRef = $gitSha; lastWriteTime = $now } | ConvertTo-Json | Set-Content $metadataPath +} diff --git a/src/Whim.Tests/Router/RouterManagerTests.cs b/src/Whim.Tests/Router/RouterManagerTests.cs index 9f7eccb63..eef81c717 100644 --- a/src/Whim.Tests/Router/RouterManagerTests.cs +++ b/src/Whim.Tests/Router/RouterManagerTests.cs @@ -1,18 +1,28 @@ +using System.Diagnostics.CodeAnalysis; using AutoFixture; namespace Whim.Tests; -public class RouterManagerCustomization : ICustomization +public class RouterManagerCustomization : StoreCustomization { - public void Customize(IFixture fixture) - { - IContext ctx = fixture.Freeze<IContext>(); - - IWorkspace workspace = fixture.Freeze<IWorkspace>(); - workspace.Name.Returns("Test"); - - ctx.WorkspaceManager.TryGet("Test").Returns(workspace); + public IRouterManager? RouterManager { get; private set; } + [SuppressMessage("Reliability", "CA2000:Dispose objects before losing scope")] + protected override void PostCustomize(IFixture fixture) + { + // System under test + RouterManager = new RouterManager(_ctx); + fixture.Inject(RouterManager); + + // Setup workspace + Workspace workspace = CreateWorkspace(_ctx) with + { + BackingName = "Test", + }; + fixture.Inject(workspace); + AddWorkspaceToManager(_ctx, _store._root.MutableRootSector, workspace); + + // Setup window IWindow window = fixture.Freeze<IWindow>(); window.WindowClass.Returns("Test"); window.ProcessFileName.Returns("Test.exe"); @@ -23,49 +33,45 @@ public void Customize(IFixture fixture) public class RouterManagerTests { [Theory, AutoSubstituteData<RouterManagerCustomization>] - public void AddWindowClassRouteString(IContext ctx, IWindow window) + internal void AddWindowClassRouteString(RouterManager routerManager, IWindow window) { // Given - RouterManager routerManager = new(ctx); // When routerManager.AddWindowClassRoute("Test", "Test"); // Then - Assert.Equal("Test", routerManager.RouteWindow(window)?.Name); + Assert.Equal("Test", routerManager.RouteWindow(window)?.BackingName); } [Theory, AutoSubstituteData<RouterManagerCustomization>] - public void AddWindowClassRoute(IContext ctx, IWindow window, IWorkspace workspace) + internal void AddWindowClassRoute(RouterManager routerManager, IWindow window, Workspace workspace) { // Given - RouterManager routerManager = new(ctx); // When routerManager.AddWindowClassRoute("Test", workspace); // Then - Assert.Equal("Test", routerManager.RouteWindow(window)?.Name); + Assert.Equal("Test", routerManager.RouteWindow(window)?.BackingName); } [Theory, AutoSubstituteData<RouterManagerCustomization>] - public void AddProcessFileNameRouteString(IContext ctx, IWindow window) + internal void AddProcessFileNameRouteString(RouterManager routerManager, IWindow window) { // Given - RouterManager routerManager = new(ctx); // When routerManager.AddProcessFileNameRoute("Test.exe", "Test"); // Then - Assert.Equal("Test", routerManager.RouteWindow(window)?.Name); + Assert.Equal("Test", routerManager.RouteWindow(window)?.BackingName); } [Theory, AutoSubstituteData<RouterManagerCustomization>] - public void AddProcessFileNameRouteString_ProcessFileNameIsNull(IContext ctx, IWindow window) + internal void AddProcessFileNameRouteString_ProcessFileNameIsNull(RouterManager routerManager, IWindow window) { // Given - RouterManager routerManager = new(ctx); routerManager.AddProcessFileNameRoute("Test.exe", "Test"); // When @@ -76,23 +82,25 @@ public void AddProcessFileNameRouteString_ProcessFileNameIsNull(IContext ctx, IW } [Theory, AutoSubstituteData<RouterManagerCustomization>] - public void AddProcessFileNameRoute(IContext ctx, IWindow window, IWorkspace workspace) + internal void AddProcessFileNameRoute(RouterManager routerManager, IWindow window, Workspace workspace) { // Given - RouterManager routerManager = new(ctx); // When routerManager.AddProcessFileNameRoute("Test.exe", workspace); // Then - Assert.Equal("Test", routerManager.RouteWindow(window)?.Name); + Assert.Equal("Test", routerManager.RouteWindow(window)?.BackingName); } [Theory, AutoSubstituteData<RouterManagerCustomization>] - public void AddProcessFileNameRoute_ProcessFileNameIsNull(IContext ctx, IWindow window, IWorkspace workspace) + internal void AddProcessFileNameRoute_ProcessFileNameIsNull( + RouterManager routerManager, + IWindow window, + Workspace workspace + ) { // Given - RouterManager routerManager = new(ctx); routerManager.AddProcessFileNameRoute("Test.exe", workspace); // When @@ -103,62 +111,57 @@ public void AddProcessFileNameRoute_ProcessFileNameIsNull(IContext ctx, IWindow } [Theory, AutoSubstituteData<RouterManagerCustomization>] - public void AddTitleRouteString(IContext ctx, IWindow window) + internal void AddTitleRouteString(RouterManager routerManager, IWindow window) { // Given - RouterManager routerManager = new(ctx); // When routerManager.AddTitleRoute("Test", "Test"); // Then - Assert.Equal("Test", routerManager.RouteWindow(window)?.Name); + Assert.Equal("Test", routerManager.RouteWindow(window)?.BackingName); } [Theory, AutoSubstituteData<RouterManagerCustomization>] - public void AddTitleRoute(IContext ctx, IWindow window, IWorkspace workspace) + internal void AddTitleRoute(RouterManager routerManager, IWindow window, Workspace workspace) { // Given - RouterManager routerManager = new(ctx); // When routerManager.AddTitleRoute("Test", workspace); // Then - Assert.Equal("Test", routerManager.RouteWindow(window)?.Name); + Assert.Equal("Test", routerManager.RouteWindow(window)?.BackingName); } [Theory, AutoSubstituteData<RouterManagerCustomization>] - public void AddTitleMatchRouteString(IContext ctx, IWindow window) + internal void AddTitleMatchRouteString(RouterManager routerManager, IWindow window) { // Given - RouterManager routerManager = new(ctx); // When routerManager.AddTitleMatchRoute("Test", "Test"); // Then - Assert.Equal("Test", routerManager.RouteWindow(window)?.Name); + Assert.Equal("Test", routerManager.RouteWindow(window)?.BackingName); } [Theory, AutoSubstituteData<RouterManagerCustomization>] - public void AddTitleMatchRoute(IContext ctx, IWindow window, IWorkspace workspace) + internal void AddTitleMatchRoute(RouterManager routerManager, IWindow window, Workspace workspace) { // Given - RouterManager routerManager = new(ctx); // When routerManager.AddTitleMatchRoute("Test", workspace); // Then - Assert.Equal("Test", routerManager.RouteWindow(window)?.Name); + Assert.Equal("Test", routerManager.RouteWindow(window)?.BackingName); } [Theory, AutoSubstituteData<RouterManagerCustomization>] - public void Clear(IContext ctx, IWindow window) + internal void Clear(RouterManager routerManager, IWindow window) { // Given - RouterManager routerManager = new(ctx); routerManager.AddWindowClassRoute("Test", "Test"); // When @@ -169,17 +172,15 @@ public void Clear(IContext ctx, IWindow window) } [Theory, AutoSubstituteData<RouterManagerCustomization>] - public void CustomRouter(IContext ctx, IWindow window, IWorkspace workspace) + internal void CustomRouter(RouterManager routerManager, IWindow window, Workspace workspace) { // Given - RouterManager routerManager = new(ctx); - routerManager.Add((w) => w.WindowClass == "Not Test" ? Substitute.For<IWorkspace>() : null); // When routerManager.Add((w) => w.WindowClass == "Test" ? workspace : null); // Then - Assert.Equal("Test", routerManager.RouteWindow(window)?.Name); + Assert.Equal("Test", routerManager.RouteWindow(window)?.BackingName); } } diff --git a/src/Whim.Yaml.Tests/YamlLoaderTests.cs b/src/Whim.Yaml.Tests/YamlLoaderTests.cs index c92b77187..7955ee3ef 100644 --- a/src/Whim.Yaml.Tests/YamlLoaderTests.cs +++ b/src/Whim.Yaml.Tests/YamlLoaderTests.cs @@ -93,4 +93,18 @@ public void Load_InvalidYamlConfig(IContext ctx) Assert.False(result); ctx.KeybindManager.DidNotReceive().SetKeybind(Arg.Any<string>(), Arg.Any<IKeybind>()); } + + [Theory] + [InlineData("route_to_launched_workspace", "RouteToLaunchedWorkspace")] + [InlineData("route_to_active_workspace", "RouteToActiveWorkspace")] + [InlineData(" ", " ")] + public void SnakeToPascal(string snake, string expected) + { + // Given a snake case string + // When converting it to Pascal case + string result = snake.SnakeToPascal(); + + // Then the string is converted to camel case + Assert.Equal(expected, result); + } } diff --git a/src/Whim.Yaml.Tests/YamlLoader_UpdateFiltersTests.cs b/src/Whim.Yaml.Tests/YamlLoader_UpdateFiltersTests.cs new file mode 100644 index 000000000..b581f7d54 --- /dev/null +++ b/src/Whim.Yaml.Tests/YamlLoader_UpdateFiltersTests.cs @@ -0,0 +1,138 @@ +using NSubstitute; +using Whim.TestUtils; +using Xunit; + +namespace Whim.Yaml.Tests; + +public class YamlLoader_UpdateFiltersTests +{ + public static TheoryData<string, bool> FilterConfig => + new() + { + { + """ + filters: + entries: + - filter_type: window_class + value: class1 + - filter_type: process_file_name + value: process1 + - filter_type: title + value: title1 + - filter_type: title_regex + value: titleMatch1 + """, + true + }, + { + """ + { + "filters": { + "entries": [ + { + "filter_type": "window_class", + "value": "class1" + }, + { + "filter_type": "process_file_name", + "value": "process1" + }, + { + "filter_type": "title", + "value": "title1" + }, + { + "filter_type": "title_regex", + "value": "titleMatch1" + } + ] + } + } + """, + false + }, + }; + + [Theory, MemberAutoSubstituteData<YamlLoaderCustomization>(nameof(FilterConfig))] + public void Load_Filters(string config, bool isYaml, IContext ctx) + { + // Given a valid config with filters set + ctx.FileManager.FileExists(Arg.Is<string>(s => s.EndsWith(isYaml ? "yaml" : "json"))).Returns(true); + ctx.FileManager.ReadAllText(Arg.Any<string>()).Returns(config); + + // When loading the config + bool result = YamlLoader.Load(ctx); + + // Then the filters should be updated + Assert.True(result); + ctx.FilterManager.Received(1).AddWindowClassFilter("class1"); + ctx.FilterManager.Received(1).AddProcessFileNameFilter("process1"); + ctx.FilterManager.Received(1).AddTitleFilter("title1"); + ctx.FilterManager.Received(1).AddTitleMatchFilter("titleMatch1"); + } + + public static TheoryData<string, bool> InvalidFilterConfig => + new() + { + { + """ + filters: + entries: + - filter_type: invalid + value: class1 + """, + true + }, + { + """ + filters: + entries: + - type: windowClass + value: false + """, + true + }, + { + """ + { + "filters": { + "entries": [ + { + "filter_type": "invalid", + "value": "class1" + } + ] + } + } + """, + false + }, + { + // Not technically invalid, but no filters are set + """ + { + "filters": {} + } + """, + false + }, + }; + + [Theory, MemberAutoSubstituteData<YamlLoaderCustomization>(nameof(InvalidFilterConfig))] + public void Load_InvalidFilters(string config, bool isYaml, IContext ctx) + { + // Given an invalid config with filters set + ctx.FileManager.FileExists(Arg.Is<string>(s => s.EndsWith(isYaml ? "yaml" : "json"))).Returns(true); + ctx.FileManager.ReadAllText(Arg.Any<string>()).Returns(config); + + // When loading the config + bool result = YamlLoader.Load(ctx); + + // Then the filters should not be updated + Assert.True(result); + ctx.FilterManager.DidNotReceive().AddWindowClassFilter(Arg.Any<string>()); + ctx.FilterManager.DidNotReceive().AddProcessFileNameFilter(Arg.Any<string>()); + ctx.FilterManager.DidNotReceive().AddTitleFilter(Arg.Any<string>()); + ctx.FilterManager.DidNotReceive().AddTitleMatchFilter(Arg.Any<string>()); + } +} diff --git a/src/Whim.Yaml.Tests/YamlLoader_UpdateRoutersTests.cs b/src/Whim.Yaml.Tests/YamlLoader_UpdateRoutersTests.cs new file mode 100644 index 000000000..ad9fd927f --- /dev/null +++ b/src/Whim.Yaml.Tests/YamlLoader_UpdateRoutersTests.cs @@ -0,0 +1,177 @@ +using NSubstitute; +using Whim.TestUtils; +using Xunit; + +namespace Whim.Yaml.Tests; + +public class YamlLoader_UpdateRoutersTests +{ + public static TheoryData<string, bool> RouterConfig => + new() + { + { + """ + routers: + entries: + - router_type: window_class + value: class1 + workspace_name: workspace1 + - router_type: process_file_name + value: process1 + workspace_name: workspace2 + - router_type: title + value: title1 + workspace_name: workspace3 + - router_type: title_regex + value: titleMatch1 + workspace_name: workspace4 + """, + true + }, + { + """ + { + "routers": { + "entries": [ + { + "router_type": "window_class", + "value": "class1", + "workspace_name": "workspace1" + }, + { + "router_type": "process_file_name", + "value": "process1", + "workspace_name": "workspace2" + }, + { + "router_type": "title", + "value": "title1", + "workspace_name": "workspace3" + }, + { + "router_type": "title_regex", + "value": "titleMatch1", + "workspace_name": "workspace4" + } + ] + } + } + """, + false + }, + }; + + [Theory, MemberAutoSubstituteData<YamlLoaderCustomization>(nameof(RouterConfig))] + public void Load_Routers(string config, bool isYaml, IContext ctx) + { + // Given a valid config with routers set + ctx.FileManager.FileExists(Arg.Is<string>(s => s.EndsWith(isYaml ? "yaml" : "json"))).Returns(true); + ctx.FileManager.ReadAllText(Arg.Any<string>()).Returns(config); + + // When loading the config + bool result = YamlLoader.Load(ctx); + + // Then the routers are updated + Assert.True(result); + ctx.RouterManager.Received(1).AddWindowClassRoute("class1", "workspace1"); + ctx.RouterManager.Received(1).AddProcessFileNameRoute("process1", "workspace2"); + ctx.RouterManager.Received(1).AddTitleRoute("title1", "workspace3"); + ctx.RouterManager.Received(1).AddTitleMatchRoute("titleMatch1", "workspace4"); + } + + public static TheoryData<string, bool> InvalidRouterConfig => + new() + { + { + """ + routers: + entries: + - router_type: invalid + value: class1 + workspace_name: workspace1 + """, + true + }, + { + """ + { + "routers": { + "entries": [ + { + "router_type": "invalid", + "value": "class1", + "workspace_name": "workspace1" + } + ] + } + } + """, + false + }, + }; + + [Theory, MemberAutoSubstituteData<YamlLoaderCustomization>(nameof(InvalidRouterConfig))] + public void Load_InvalidRouters(string config, bool isYaml, IContext ctx) + { + // Given an invalid config with routers set + ctx.FileManager.FileExists(Arg.Is<string>(s => s.EndsWith(isYaml ? "yaml" : "json"))).Returns(true); + ctx.FileManager.ReadAllText(Arg.Any<string>()).Returns(config); + + // When loading the config + bool result = YamlLoader.Load(ctx); + + // Then the routers are not updated + Assert.True(result); + ctx.RouterManager.DidNotReceive().AddWindowClassRoute(Arg.Any<string>(), Arg.Any<string>()); + ctx.RouterManager.DidNotReceive().AddProcessFileNameRoute(Arg.Any<string>(), Arg.Any<string>()); + ctx.RouterManager.DidNotReceive().AddTitleRoute(Arg.Any<string>(), Arg.Any<string>()); + ctx.RouterManager.DidNotReceive().AddTitleMatchRoute(Arg.Any<string>(), Arg.Any<string>()); + } + + [Theory] + [InlineAutoSubstituteData<YamlLoaderCustomization>( + "route_to_launched_workspace", + RouterOptions.RouteToLaunchedWorkspace + )] + [InlineAutoSubstituteData<YamlLoaderCustomization>( + "route_to_active_workspace", + RouterOptions.RouteToActiveWorkspace + )] + [InlineAutoSubstituteData<YamlLoaderCustomization>( + "route_to_last_tracked_active_workspace", + RouterOptions.RouteToLastTrackedActiveWorkspace + )] + public void Load_RouterBehavior(string routerOption, RouterOptions expected, IContext ctx) + { + // Given a valid config with a router option set + string config = "routers:\n" + " routing_behavior: " + routerOption + "\n"; + ctx.FileManager.FileExists(Arg.Is<string>(s => s.EndsWith("yaml"))).Returns(true); + ctx.FileManager.ReadAllText(Arg.Any<string>()).Returns(config); + + // When loading the config + bool result = YamlLoader.Load(ctx); + + // Then the router option is updated + Assert.True(result); + ctx.RouterManager.Received(1).RouterOptions = expected; + } + + [Theory, AutoSubstituteData<YamlLoaderCustomization>] + public void Load_InvalidRouterBehavior(IContext ctx) + { + // Given an invalid config with a router option set + string config = """ + routers: + routing_behavior: route_to_bogus_workspace + """; + ctx.FileManager.FileExists(Arg.Is<string>(s => s.EndsWith("yaml"))).Returns(true); + ctx.FileManager.ReadAllText(Arg.Any<string>()).Returns(config); + + // When loading the config + bool result = YamlLoader.Load(ctx); + + // Then the router option is not updated + Assert.True(result); + ctx.RouterManager.DidNotReceive().RouterOptions = Arg.Any<RouterOptions>(); + } +} diff --git a/src/Whim.Yaml/Whim.Yaml.csproj b/src/Whim.Yaml/Whim.Yaml.csproj index f27a3cba3..aefe4838f 100644 --- a/src/Whim.Yaml/Whim.Yaml.csproj +++ b/src/Whim.Yaml/Whim.Yaml.csproj @@ -44,7 +44,7 @@ <PackageReference Include="YamlDotNet" /> </ItemGroup> - <Target Name="GenerateSchema" BeforeTargets="BeforeCompile;CoreCompile;PreBuildEvent"> + <Target Name="GenerateSchema" BeforeTargets="BeforeCompile"> <Exec Command="powershell.exe -NonInteractive -ExecutionPolicy Unrestricted .\scripts\Generate-SourceFromSchema.ps1" WorkingDirectory="..\..\" ConsoleToMsBuild="true"> diff --git a/src/Whim.Yaml/YamlLoader.cs b/src/Whim.Yaml/YamlLoader.cs index 21c15439c..0da106d1d 100644 --- a/src/Whim.Yaml/YamlLoader.cs +++ b/src/Whim.Yaml/YamlLoader.cs @@ -1,3 +1,4 @@ +using System.Text; using System.Text.Json; using System.Text.Json.Nodes; using Corvus.Json; @@ -19,7 +20,7 @@ public static class YamlLoader /// </summary> /// <param name="ctx">The <see cref="IContext"/> to operate on.</param> /// <returns> - /// <see langword="true"/> if the configuration was loaded successfully; otherwise, <see langword="false"/>. + /// <see langword="true"/> if the configuration was parsed successfully; otherwise, <see langword="false"/>. /// </returns> public static bool Load(IContext ctx) { @@ -29,6 +30,8 @@ public static bool Load(IContext ctx) } UpdateKeybinds(ctx, schema); + UpdateFilters(ctx, schema); + UpdateRouters(ctx, schema); return true; } @@ -89,4 +92,96 @@ private static void UpdateKeybinds(IContext ctx, Schema schema) ctx.KeybindManager.SetKeybind((string)pair.Command, keybind); } } + + private static void UpdateFilters(IContext ctx, Schema schema) + { + if (!schema.Filters.IsValid() || schema.Filters.Entries.AsOptional() is not { } entries) + { + return; + } + + foreach (var filter in entries) + { + string value = (string)filter.Value; + + switch ((string)filter.FilterType) + { + case "window_class": + ctx.FilterManager.AddWindowClassFilter(value); + break; + case "process_file_name": + ctx.FilterManager.AddProcessFileNameFilter(value); + break; + case "title": + ctx.FilterManager.AddTitleFilter(value); + break; + case "title_regex": + ctx.FilterManager.AddTitleMatchFilter(value); + break; + default: + Logger.Error($"Invalid filter type: {filter.FilterType}"); + break; + } + } + } + + private static void UpdateRouters(IContext ctx, Schema schema) + { + if (!schema.Routers.IsValid()) + { + return; + } + + if ( + schema.Routers.RoutingBehavior.TryGetString(out string? routingBehavior) + && Enum.TryParse(routingBehavior?.SnakeToPascal(), out RouterOptions routerOptions) + ) + { + ctx.RouterManager.RouterOptions = routerOptions; + } + + if (schema.Routers.Entries.AsOptional() is not { } entries) + { + return; + } + + foreach (var router in entries) + { + string value = (string)router.Value; + string workspaceName = (string)router.WorkspaceName; + + switch ((string)router.RouterType) + { + case "window_class": + ctx.RouterManager.AddWindowClassRoute(value, workspaceName); + break; + case "process_file_name": + ctx.RouterManager.AddProcessFileNameRoute(value, workspaceName); + break; + case "title": + ctx.RouterManager.AddTitleRoute(value, workspaceName); + break; + case "title_regex": + ctx.RouterManager.AddTitleMatchRoute(value, workspaceName); + break; + default: + Logger.Error($"Invalid router type: {router.RouterType}"); + break; + } + } + } + + internal static string SnakeToPascal(this string snake) + { + string[] parts = snake.Split('_'); + StringBuilder builder = new(snake.Length); + + foreach (string part in parts) + { + builder.Append(char.ToUpper(part[0])); + builder.Append(part.AsSpan(1)); + } + + return builder.ToString(); + } } diff --git a/src/Whim.Yaml/schema.json b/src/Whim.Yaml/schema.json index 1aeade28a..5e17c40cb 100644 --- a/src/Whim.Yaml/schema.json +++ b/src/Whim.Yaml/schema.json @@ -17,7 +17,35 @@ "unify_key_modifiers": { "type": "boolean", - "description": "Whether to treat key modifiers like `LWin` and `RWin` as the same key modifier. Defaults to `true`." + "description": "Whether to treat key modifiers like `LWin` and `RWin` as the same key modifier. Defaults to `true`.", + "default": true + } + } + }, + + "filters": { + "type": "object", + "properties": { + "entries": { + "type": "array", + "items": { "$ref": "#/$defs/Filter" }, + "description": "Filters to apply to windows to determine whether they should be managed by Whim" + } + } + }, + + "routers": { + "type": "object", + "properties": { + "entries": { + "type": "array", + "items": { "$ref": "#/$defs/Router" }, + "description": "Routers to determine which workspace a window should be placed in" + }, + + "routing_behavior": { + "$ref": "#/$defs/RoutingBehavior", + "description": "The behavior to use when routing windows to workspaces" } } } @@ -38,6 +66,89 @@ "description": "The keybind to trigger the command. For more, see https://dalyisaac.github.io/Whim/docs/customize/keybinds.html" } } + }, + + "Filter": { + "type": "object", + "required": ["filter_type", "value"], + "additionalProperties": false, + "properties": { + "filter_type": { + "anyOf": [ + { + "const": "window_class", + "description": "Ignores windows with the specified window class" + }, + { + "const": "process_file_name", + "description": "Ignores windows with the specified process file name" + }, + { + "const": "title", + "description": "Ignores windows with the specified title" + }, + { + "const": "title_regex", + "description": "Ignores windows with a title that matches the specified regex" + } + ] + }, + "value": { + "type": "string", + "description": "The value to filter by" + } + } + }, + + "Router": { + "type": "object", + "required": ["router_type", "value", "workspace_name"], + "additionalProperties": false, + "properties": { + "router_type": { + "anyOf": [ + { + "const": "window_class", + "description": "Routes windows with the specified window class to the specified workspace" + }, + { + "const": "process_file_name", + "description": "Routes windows with the specified process file name to the specified workspace" + }, + { + "const": "title", + "description": "Routes windows with the specified title to the specified workspace" + }, + { + "const": "title_regex", + "description": "Routes windows with a title that matches the specified regex to the specified workspace" + } + ] + }, + "value": { "type": "string", "description": "The value to route by" }, + "workspace_name": { + "type": "string", + "description": "The workspace to route to" + } + } + }, + + "RoutingBehavior": { + "anyOf": [ + { + "const": "route_to_launched_workspace", + "description": "Routes windows to the workspace that the window was launched on by Windows." + }, + { + "const": "route_to_active_workspace", + "description": "Routes windows to the workspace which last received an event sent by Windows." + }, + { + "const": "route_to_last_tracked_active_workspace", + "description": "Routes windows to the workspace which last received an event sent by Windows which Whim did not ignore." + } + ], + "default": "route_to_launched_workspace" } } } diff --git a/src/Whim/Router/RouterManager.cs b/src/Whim/Router/RouterManager.cs index 39d1d37ed..41435e14d 100644 --- a/src/Whim/Router/RouterManager.cs +++ b/src/Whim/Router/RouterManager.cs @@ -29,7 +29,7 @@ public IRouterManager AddProcessFileNameRoute(string processFileName, string wor { if (window.ProcessFileName?.ToLower() == processFileName) { - return _context.WorkspaceManager.TryGet(workspaceName); + return _context.Store.Pick(PickWorkspaceByName(workspaceName)).ValueOrDefault; } return null; }); @@ -59,7 +59,7 @@ public IRouterManager AddTitleRoute(string title, string workspaceName) { if (window.Title.Equals(title, StringComparison.CurrentCultureIgnoreCase)) { - return _context.WorkspaceManager.TryGet(workspaceName); + return _context.Store.Pick(PickWorkspaceByName(workspaceName)).ValueOrDefault; } return null; }); @@ -89,7 +89,7 @@ public IRouterManager AddTitleMatchRoute(string match, string workspaceName) { if (regex.IsMatch(window.Title)) { - return _context.WorkspaceManager.TryGet(workspaceName); + return _context.Store.Pick(PickWorkspaceByName(workspaceName)).ValueOrDefault; } return null; }); @@ -136,7 +136,7 @@ public IRouterManager AddWindowClassRoute(string windowClass, string workspaceNa { if (window.WindowClass.Equals(windowClass, StringComparison.CurrentCultureIgnoreCase)) { - return _context.WorkspaceManager.TryGet(workspaceName); + return _context.Store.Pick(PickWorkspaceByName(workspaceName)).ValueOrDefault; } return null; }); diff --git a/src/Whim/Router/RouterOptions.cs b/src/Whim/Router/RouterOptions.cs index fafcb639b..4a4d85a0c 100644 --- a/src/Whim/Router/RouterOptions.cs +++ b/src/Whim/Router/RouterOptions.cs @@ -6,19 +6,19 @@ namespace Whim; public enum RouterOptions { /// <summary> - /// Routes windows to the workspace which is currently active on the monitor the window is on. + /// Routes windows to the workspace that the window was launched on by Windows. /// </summary> RouteToLaunchedWorkspace, /// <summary> /// Routes windows to the active workspace. This may lead to unexpected results, as the - /// <see cref="IMonitorManager.ActiveMonitor"/> and thus <see cref="IWorkspaceManager.ActiveWorkspace"/> + /// <see cref="IMonitorSector.ActiveMonitorHandle"/> and thus the active workspace /// will be updated by every window event sent by Windows - even those which Whim ignores. /// /// <br/> /// /// For example, launching an app from the taskbar on Windows 11 will cause <c>Shell_TrayWnd</c> - /// to focus on the main monitor, overriding the <see cref="IMonitorManager.ActiveMonitor"/>. + /// to focus on the main monitor, overriding the <see cref="IMonitorSector.ActiveMonitorHandle"/>. /// As a result, the window will be routed to the workspace on the main monitor. /// </summary> RouteToActiveWorkspace,