From 3cbfd9099697000fb6c0e8bd252ab47147c389e6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 20 Mar 2026 11:10:23 +0000 Subject: [PATCH 1/4] Initial plan From cb6a4c736b997155e4973c97e64ce6cfdc38b0a8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 20 Mar 2026 11:17:31 +0000 Subject: [PATCH 2/4] =?UTF-8?q?fix:=20=E3=83=87=E3=82=B9=E3=82=AF=E3=83=88?= =?UTF-8?q?=E3=83=83=E3=83=97=E4=B8=80=E8=A6=A7=E3=83=80=E3=82=A4=E3=82=A2?= =?UTF-8?q?=E3=83=AD=E3=82=B0=E3=81=AE=E3=83=95=E3=83=AA=E3=83=BC=E3=82=BA?= =?UTF-8?q?=E3=82=92=E4=BF=AE=E6=AD=A3=20-=20ConfigureAwait(false)?= =?UTF-8?q?=E3=82=92=E5=89=8A=E9=99=A4=E3=81=97=E3=81=A6UI=E3=82=B9?= =?UTF-8?q?=E3=83=AC=E3=83=83=E3=83=89=E3=81=A7COM=E5=91=BC=E3=81=B3?= =?UTF-8?q?=E5=87=BA=E3=81=97=E3=82=92=E5=AE=9F=E8=A1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Freeesia <9002657+Freeesia@users.noreply.github.com> --- VdLabel/DesktopCatalogViewModel.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/VdLabel/DesktopCatalogViewModel.cs b/VdLabel/DesktopCatalogViewModel.cs index 325b4da..0063d0a 100644 --- a/VdLabel/DesktopCatalogViewModel.cs +++ b/VdLabel/DesktopCatalogViewModel.cs @@ -39,7 +39,7 @@ public DesktopCatalogViewModel(IVirualDesktopService virualDesktopService, IComm private async void Setup() { - var config = await this.configStore.Load().ConfigureAwait(false); + var config = await this.configStore.Load(); var pos = config.NamePosition switch { NamePosition.Top => Dock.Top, From ce219ca8292e71f31b048e5cde3c4ff4254dc30e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 5 Apr 2026 06:54:31 +0000 Subject: [PATCH 3/4] =?UTF-8?q?rebase:=20origin/master=E3=81=AB=E3=83=AA?= =?UTF-8?q?=E3=83=99=E3=83=BC=E3=82=B9=E3=80=81=E3=82=B3=E3=83=B3=E3=83=95?= =?UTF-8?q?=E3=83=AA=E3=82=AF=E3=83=88=E8=A7=A3=E6=B1=BA=EF=BC=88=E4=BF=AE?= =?UTF-8?q?=E6=AD=A3=E6=B8=88=E3=81=BF=E3=82=B3=E3=83=BC=E3=83=89=E3=81=AB?= =?UTF-8?q?STA=E7=90=86=E7=94=B1=E3=82=B3=E3=83=A1=E3=83=B3=E3=83=88?= =?UTF-8?q?=E8=BF=BD=E5=8A=A0=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agent-Logs-Url: https://github.com/Freeesia/VdLabel/sessions/3985c38d-8e04-4f14-99ac-f5b6d822fe1d Co-authored-by: Freeesia <9002657+Freeesia@users.noreply.github.com> --- .config/dotnet-tools.json | 4 +- .github/copilot-instructions.md | 1 + .github/workflows/dotnet-desktop.yml | 10 +- VdLabel/CommandService.cs | 226 +++++++++++++++++++++++ VdLabel/Config.cs | 11 ++ VdLabel/DesktopCatalog.xaml | 59 ++++-- VdLabel/DesktopCatalogViewModel.cs | 97 +++++++--- VdLabel/MainViewModel.cs | 120 +++++++++++- VdLabel/MainWindow.xaml | 119 ++++++++++++ VdLabel/OverlayViewModel.cs | 36 +++- VdLabel/OverlayWindow.xaml | 21 ++- VdLabel/OverlayWindow.xaml.cs | 32 ++++ VdLabel/Program.cs | 6 +- VdLabel/Properties/Resources.Designer.cs | 45 +++++ VdLabel/Properties/Resources.resx | 16 ++ VdLabel/ResolvedBadge.cs | 9 + VdLabel/Themes/DefaultStyles.xaml | 21 +++ VdLabel/VdLabel.csproj | 5 +- VdLabel/VirtualDesktopService.cs | 37 ++-- store.csv | 4 +- 20 files changed, 803 insertions(+), 76 deletions(-) create mode 100644 VdLabel/CommandService.cs create mode 100644 VdLabel/ResolvedBadge.cs diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index 037ca2e..aa93bda 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -10,14 +10,14 @@ "rollForward": false }, "nuget-license": { - "version": "4.0.8", + "version": "4.0.9", "commands": [ "nuget-license" ], "rollForward": false }, "gitversion.tool": { - "version": "6.6.2", + "version": "6.7.0", "commands": [ "dotnet-gitversion" ], diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 8b3778e..03c0620 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -5,6 +5,7 @@ VdLabel は Windows 仮想デスクトップの拡張ユーティリティ(.NE ## コーディング規約 - **コードコメントは日本語で記述する。** +- **PR の説明(タイトル・本文)は日本語で記述する。** ## ビルド・公開コマンド diff --git a/.github/workflows/dotnet-desktop.yml b/.github/workflows/dotnet-desktop.yml index b816d19..4f8ecaa 100644 --- a/.github/workflows/dotnet-desktop.yml +++ b/.github/workflows/dotnet-desktop.yml @@ -68,14 +68,15 @@ jobs: ${{ runner.os }}-nuget- - uses: actions/setup-dotnet@v5 with: - dotnet-version: 8.0.x - - uses: gittools/actions/gitversion/setup@v4.3.3 + dotnet-version: 10.0.x + - uses: gittools/actions/gitversion/setup@v4.4.2 with: versionSpec: "6.x" - id: gitversion - uses: gittools/actions/gitversion/execute@v4.3.3 + uses: gittools/actions/gitversion/execute@v4.4.2 - run: | dotnet publish VdLabel -c Release -o publish --sc ${{ matrix.self }} ` + -p:PublishSingleFile=${{ matrix.self }} ` -p:Version=${{ steps.gitversion.outputs.fullSemVer }} ` -p:AssemblyVersion=${{ steps.gitversion.outputs.assemblySemVer }} ` -p:FileVersion=${{ steps.gitversion.outputs.assemblySemFileVer }} ` @@ -127,7 +128,7 @@ jobs: ${{ runner.os }}-nuget- - uses: actions/setup-dotnet@v5 with: - dotnet-version: 8.0.x + dotnet-version: 10.0.x - uses: actions/download-artifact@v8 with: path: artifacts @@ -139,7 +140,6 @@ jobs: name: licenses - run: | dotnet tool restore - dotnet wix extension -g add WixToolset.Util.wixext WixToolset.UI.wixext dotnet run -c Release working-directory: VdLabel.Wix - uses: actions/upload-artifact@v7 diff --git a/VdLabel/CommandService.cs b/VdLabel/CommandService.cs new file mode 100644 index 0000000..800467e --- /dev/null +++ b/VdLabel/CommandService.cs @@ -0,0 +1,226 @@ +using Cysharp.Diagnostics; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using System.Collections.Concurrent; +using System.Drawing; +using System.Text; +using System.Text.Json; +using System.Text.RegularExpressions; + +namespace VdLabel; + +partial class CommandService(App app, IConfigStore configStore, ILogger logger) : BackgroundService, ICommandService +{ + private readonly App app = app; + private readonly IConfigStore configStore = configStore; + private readonly ILogger logger = logger; + private readonly ConcurrentDictionary commandCache = new(); + private readonly ConcurrentDictionary<(Guid BadgeId, Guid? DesktopId), ResolvedBadge> badgeCommandCache = new(); + + public event EventHandler? BadgeResultsUpdated; + public event EventHandler? LabelResultUpdated; + + [GeneratedRegex(@"^(""[^""]+""|\S+)", RegexOptions.Compiled)] + private static partial Regex FilePathRegex(); + + public async ValueTask ExecuteCommand(string command, bool utf8, CancellationToken token = default) + { + // 最初のスペースで区切られた部分または全体をファイルとする。"で囲まれているときはスペースを無視する + // それ以降は引数として渡す + var match = FilePathRegex().Match(command); + if (!match.Success) + { + throw new InvalidOperationException("ファイルパスの解析に失敗しました"); + } + var fileName = match.Value; + var args = command[match.Length..].Trim(); + var lines = await ProcessX.StartAsync(fileName: fileName, args, encoding: utf8 ? Encoding.UTF8 : null).ToTask(token).ConfigureAwait(false); + return string.Join(Environment.NewLine, lines); + } + + public async ValueTask ExecuteBadgeCommand(string command, bool utf8, Guid desktopId, string fallbackLabel, Color fallbackColor, CancellationToken token = default) + { + // {desktopId} プレースホルダーを指定されたデスクトップIDに置換する + var resolved = command.Replace("{desktopId}", desktopId.ToString(), StringComparison.OrdinalIgnoreCase); + var output = await ExecuteCommand(resolved, utf8, token).ConfigureAwait(false); + return ParseBadgeOutput(output, fallbackLabel, fallbackColor); + } + + public string? GetCacheResult(Guid desktopId) + => this.commandCache.TryGetValue(desktopId, out var result) ? result : null; + + public ResolvedBadge? GetBadgeResult(Guid badgeId, Guid desktopId) + { + if (this.badgeCommandCache.TryGetValue((badgeId, desktopId), out var result)) + { + return result; + } + return this.badgeCommandCache.TryGetValue((badgeId, null), out var shared) ? shared : null; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + PeriodicTimer? timer = null; + await this.app.WaitForStartupAsync(); + while (!stoppingToken.IsCancellationRequested) + { + var config = await this.configStore.Load(); + stoppingToken.ThrowIfCancellationRequested(); + var span = TimeSpan.FromSeconds(config.CommandInterval); + timer ??= new PeriodicTimer(span); + if (timer.Period != span) + { + timer.Period = span; + } + foreach (var desktopConfig in config.DesktopConfigs) + { + if (desktopConfig.Command is not { Length: > 0 }) + { + continue; + } + try + { + var result = await ExecuteCommand(desktopConfig.Command, desktopConfig.Utf8Command, stoppingToken).ConfigureAwait(false); + this.commandCache[desktopConfig.Id] = result; + LabelResultUpdated?.Invoke(this, new LabelResultUpdatedEventArgs(desktopConfig.Id, result)); + } + catch (Exception e) + { + this.logger.LogError(e, "コマンド実行エラー"); + } + stoppingToken.ThrowIfCancellationRequested(); + } + + // バッジキャッシュの失効処理:設定に存在しないバッジ、コマンド未設定のバッジ、 + // または割り当てが外れたデスクトップのキャッシュエントリを削除する + foreach (var key in this.badgeCommandCache.Keys.ToList()) + { + var badge = config.Badges.FirstOrDefault(b => b.Id == key.BadgeId); + if (badge is null || badge.Command is not { Length: > 0 }) + { + // バッジが削除されたかコマンドが未設定になったためキャッシュを削除 + this.badgeCommandCache.TryRemove(key, out _); + } + else if (key.DesktopId.HasValue) + { + // デスクトップへのバッジ割り当てが外れた場合はキャッシュを削除 + var desktopHasBadge = config.DesktopConfigs.Any(d => d.Id == key.DesktopId.Value && d.BadgeIds.Contains(key.BadgeId)); + if (!desktopHasBadge) + { + this.badgeCommandCache.TryRemove(key, out _); + } + } + } + + var badgeResultsChanged = false; + foreach (var badgeConfig in config.Badges) + { + if (badgeConfig.Command is not { Length: > 0 }) + { + continue; + } + var hasPlaceholder = badgeConfig.Command.Contains("{desktopId}", StringComparison.OrdinalIgnoreCase); + if (hasPlaceholder) + { + // Execute per desktop that has this badge assigned + foreach (var desktopConfig in config.DesktopConfigs.Where(d => d.BadgeIds.Contains(badgeConfig.Id))) + { + try + { + this.badgeCommandCache[(badgeConfig.Id, desktopConfig.Id)] = await ExecuteBadgeCommand(badgeConfig.Command, badgeConfig.Utf8Command, desktopConfig.Id, badgeConfig.Label, badgeConfig.Color, stoppingToken).ConfigureAwait(false); + badgeResultsChanged = true; + } + catch (Exception e) + { + this.logger.LogError(e, "バッジコマンド実行エラー (Badge ID: {BadgeId}, Desktop ID: {DesktopId}, Command: {Command})", badgeConfig.Id, desktopConfig.Id, badgeConfig.Command); + } + stoppingToken.ThrowIfCancellationRequested(); + } + } + else + { + // プレースホルダーなし — 一度だけ実行して null デスクトップIDのキャッシュに保存 + try + { + this.badgeCommandCache[(badgeConfig.Id, null)] = await ExecuteBadgeCommand(badgeConfig.Command, badgeConfig.Utf8Command, Guid.Empty, badgeConfig.Label, badgeConfig.Color, stoppingToken).ConfigureAwait(false); + badgeResultsChanged = true; + } + catch (Exception e) + { + this.logger.LogError(e, "バッジコマンド実行エラー (Badge ID: {BadgeId}, Command: {Command})", badgeConfig.Id, badgeConfig.Command); + } + stoppingToken.ThrowIfCancellationRequested(); + } + } + + if (badgeResultsChanged) + { + BadgeResultsUpdated?.Invoke(this, EventArgs.Empty); + } + + await timer.WaitForNextTickAsync(stoppingToken); + } + timer?.Dispose(); + } + + private static ResolvedBadge ParseBadgeOutput(string output, string fallbackLabel, Color fallbackColor) + { + try + { + var parsed = JsonSerializer.Deserialize(output, new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + if (parsed is not null) + { + return new ResolvedBadge(string.IsNullOrEmpty(parsed.Label) ? fallbackLabel : parsed.Label, TryParseColor(parsed.Color, fallbackColor)); + } + } + catch (JsonException) + { + // JSONデシリアライズ失敗時はそのままラベルとして使用 + } + return new ResolvedBadge(output, fallbackColor); + } + + private static Color TryParseColor(string? htmlColor, Color fallback) + { + if (htmlColor is null) + { + return fallback; + } + try + { + return ColorTranslator.FromHtml(htmlColor); + } + catch + { + return fallback; + } + } +} + + +interface ICommandService +{ + ValueTask ExecuteCommand(string command, bool utf8, CancellationToken token = default); + string? GetCacheResult(Guid desktopId); + ResolvedBadge? GetBadgeResult(Guid badgeId, Guid desktopId); + ValueTask ExecuteBadgeCommand(string command, bool utf8, Guid desktopId, string fallbackLabel, Color fallbackColor, CancellationToken token = default); + event EventHandler? BadgeResultsUpdated; + event EventHandler? LabelResultUpdated; +} + +class LabelResultUpdatedEventArgs(Guid desktopId, string label) : EventArgs +{ + public Guid DesktopId { get; } = desktopId; + public string Label { get; } = label; +} + +/// +/// バッジ用コマンドの JSON 出力形式を表します。 +/// +record BadgeCommandResult +{ + public string Label { get; init; } = string.Empty; + + /// HTML カラー文字列(例: "#ff6600")。 + public string? Color { get; init; } +} \ No newline at end of file diff --git a/VdLabel/Config.cs b/VdLabel/Config.cs index 9d525ed..6a21374 100644 --- a/VdLabel/Config.cs +++ b/VdLabel/Config.cs @@ -13,9 +13,19 @@ class Config public NamePosition NamePosition { get; set; } = NamePosition.Bottom; public double CommandInterval { get; set; } = 30; + public List Badges { get; init; } = []; public List DesktopConfigs { get; init; } = []; } +record BadgeConfig +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public string Label { get; set; } = string.Empty; + public Color Color { get; set; } = Color.FromArgb(255, 0, 120, 212); + public string? Command { get; set; } + public bool Utf8Command { get; set; } +} + enum OverlayPosition { Center, @@ -31,6 +41,7 @@ record DesktopConfig public bool Utf8Command { get; set; } public string? ImagePath { get; set; } public IReadOnlyList TargetWindows { get; init; } = []; + public IReadOnlyList BadgeIds { get; init; } = []; } record WindowConfig(WindowMatchType MatchType, WindowPatternType PatternType, string Pattern); diff --git a/VdLabel/DesktopCatalog.xaml b/VdLabel/DesktopCatalog.xaml index 724387b..2639214 100644 --- a/VdLabel/DesktopCatalog.xaml +++ b/VdLabel/DesktopCatalog.xaml @@ -42,22 +42,49 @@ - - - - - + + + + + + + + + + + + + + + + + + + + + + + diff --git a/VdLabel/DesktopCatalogViewModel.cs b/VdLabel/DesktopCatalogViewModel.cs index 0063d0a..f039454 100644 --- a/VdLabel/DesktopCatalogViewModel.cs +++ b/VdLabel/DesktopCatalogViewModel.cs @@ -1,13 +1,14 @@ using System.Windows; using System.Windows.Controls; using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; namespace VdLabel; internal sealed partial class DesktopCatalogViewModel : ObservableObject, IDisposable { private readonly IVirualDesktopService virualDesktopService; - private readonly ICommandLabelService commandLabelService; + private readonly ICommandService commandService; private readonly IConfigStore configStore; private readonly int maxColumns; [ObservableProperty] @@ -16,7 +17,7 @@ internal sealed partial class DesktopCatalogViewModel : ObservableObject, IDispo private DesktopViewModel? selectedDesktop; [ObservableProperty] - private int columns = 0; + private int columns = 1; [ObservableProperty] private double top; @@ -27,18 +28,28 @@ internal sealed partial class DesktopCatalogViewModel : ObservableObject, IDispo [ObservableProperty] private double height; - public DesktopCatalogViewModel(IVirualDesktopService virualDesktopService, ICommandLabelService commandLabelService, IConfigStore configStore) + public DesktopCatalogViewModel(IVirualDesktopService virualDesktopService, ICommandService commandService, IConfigStore configStore) { this.virualDesktopService = virualDesktopService; - this.commandLabelService = commandLabelService; + this.commandService = commandService; this.configStore = configStore; this.configStore.Saved += ConfigStore_Saved; + this.commandService.BadgeResultsUpdated += CommandService_BadgeResultsUpdated; this.maxColumns = (int)(SystemParameters.PrimaryScreenWidth * 0.8 / 280); Setup(); } private async void Setup() { + var count = this.virualDesktopService.DesktopCount; + this.Columns = Math.Min(this.maxColumns, count); + this.Width = this.Columns * 280 + 2; + var rows = (count / this.Columns) + (count % this.Columns == 0 ? 0 : 1); + this.Height = Math.Min(SystemParameters.PrimaryScreenHeight * 0.8, 280 * rows) + 2; + this.Top = (SystemParameters.PrimaryScreenHeight - this.Height) / 2; + this.Left = (SystemParameters.PrimaryScreenWidth - this.Width) / 2; + + // ConfigureAwait(false) を使わない:後続の GetWallpaperPath や GetCurrent は COM 呼び出しで STA スレッドが必要 var config = await this.configStore.Load(); var pos = config.NamePosition switch { @@ -48,23 +59,54 @@ private async void Setup() }; this.Desktops = config.DesktopConfigs .Where(c => c.Id != Guid.Empty) - .Select((c, i) => new DesktopViewModel(i + 1, c, this.commandLabelService.GetCacheResult(c.Id), this.virualDesktopService.GetWallpaperPath(c.Id), pos)) + .Select((c, i) => + { + var commandLabel = this.commandService.GetCacheResult(c.Id); + var wallpaperPath = this.virualDesktopService.GetWallpaperPath(c.Id); + var resolvedBadges = config.Badges.ToDictionary( + b => b.Id, + b => + { + var cached = this.commandService.GetBadgeResult(b.Id, c.Id); + return cached ?? new ResolvedBadge(b.Label, b.Color); + }); + return new DesktopViewModel(i + 1, c, commandLabel, wallpaperPath, pos, resolvedBadges, ToggleBadgeAsync); + }) .ToArray(); var currentDesktop = this.virualDesktopService.GetCurrent(); this.SelectedDesktop = this.Desktops.FirstOrDefault(d => d.Id == currentDesktop); - this.Columns = Math.Min(this.maxColumns, this.Desktops.Count); - this.Width = this.Columns * 280; - var rows = (this.Desktops.Count / this.Columns) + (this.Desktops.Count % this.Columns == 0 ? 0 : 1); - this.Height = Math.Min(SystemParameters.PrimaryScreenHeight * 0.8, 280 * rows); - this.Top = (SystemParameters.PrimaryScreenHeight - this.Height) / 2; - this.Left = (SystemParameters.PrimaryScreenWidth - this.Width) / 2; + } + + private async Task ToggleBadgeAsync(Guid desktopId, Guid badgeId) + { + var config = await this.configStore.Load().ConfigureAwait(false); + var desktopConfig = config.DesktopConfigs.FirstOrDefault(c => c.Id == desktopId); + if (desktopConfig is null) + { + return; + } + var newBadgeIds = desktopConfig.BadgeIds.Contains(badgeId) + ? desktopConfig.BadgeIds.Where(id => id != badgeId).ToArray() + : [.. desktopConfig.BadgeIds, badgeId]; + var idx = config.DesktopConfigs.IndexOf(desktopConfig); + config.DesktopConfigs[idx] = desktopConfig with { BadgeIds = newBadgeIds }; + await this.configStore.Save(config).ConfigureAwait(false); } public void Loaded() => Setup(); - private void ConfigStore_Saved(object? sender, EventArgs e) => Setup(); + // バックグラウンドスレッドから発火する可能性があるため UI スレッドへディスパッチ + private void ConfigStore_Saved(object? sender, EventArgs e) + => System.Windows.Application.Current.Dispatcher.BeginInvoke(Setup); - public void Dispose() => this.configStore.Saved -= ConfigStore_Saved; + private void CommandService_BadgeResultsUpdated(object? sender, EventArgs e) + => System.Windows.Application.Current.Dispatcher.BeginInvoke(Setup); + + public void Dispose() + { + this.configStore.Saved -= ConfigStore_Saved; + this.commandService.BadgeResultsUpdated -= CommandService_BadgeResultsUpdated; + } partial void OnSelectedDesktopChanged(DesktopViewModel? value) { @@ -74,21 +116,13 @@ partial void OnSelectedDesktopChanged(DesktopViewModel? value) } } - partial void OnHeightChanged(double value) - { - var rows = (this.Desktops.Count / this.Columns) + (this.Desktops.Count % this.Columns == 0 ? 0 : 1); - this.Height = Math.Min(SystemParameters.PrimaryScreenHeight * 0.8, 280 * rows) + 2; - } - - partial void OnWidthChanged(double value) - => this.Width = this.Columns * 280 + 2; partial void OnTopChanged(double value) => this.Top = (SystemParameters.PrimaryScreenHeight - this.Height) / 2; partial void OnLeftChanged(double value) => this.Left = (SystemParameters.PrimaryScreenWidth - this.Width) / 2; } -internal class DesktopViewModel(int index, DesktopConfig desktopConfig, string? commandLabel, string? wallpaperPath, Dock pos) +internal class DesktopViewModel(int index, DesktopConfig desktopConfig, string? commandLabel, string? wallpaperPath, Dock pos, IReadOnlyDictionary resolvedBadges, Func toggleBadge) { private readonly int index = index; private DesktopConfig desktopConfig = desktopConfig; @@ -102,4 +136,23 @@ internal class DesktopViewModel(int index, DesktopConfig desktopConfig, string? public string Label => this.commandLabel ?? this.desktopConfig.Name ?? string.Format(VdLabel.Properties.Resources.Desktop, this.index); public string? ImagePath => this.desktopConfig.ImagePath ?? this.wallpaperPath; + + public IReadOnlyList AssignedBadges { get; } = desktopConfig.BadgeIds + .Where(id => resolvedBadges.ContainsKey(id)) + .Select(id => resolvedBadges[id]) + .ToArray(); + + public IReadOnlyList BadgeMenuItems { get; } = resolvedBadges + .Select(kvp => new BadgeMenuItem(kvp.Key, kvp.Value, desktopConfig.Id, desktopConfig.BadgeIds.Contains(kvp.Key), toggleBadge)) + .ToArray(); +} + +internal class BadgeMenuItem(Guid badgeId, ResolvedBadge resolved, Guid desktopId, bool isAssigned, Func toggleAction) +{ + public string Label => resolved.Label; + public System.Drawing.Color Color => resolved.Color; + + public bool IsAssigned { get; } = isAssigned; + + public AsyncRelayCommand ToggleCommand { get; } = new AsyncRelayCommand(() => toggleAction(desktopId, badgeId)); } \ No newline at end of file diff --git a/VdLabel/MainViewModel.cs b/VdLabel/MainViewModel.cs index 53a94f2..f5bb53f 100644 --- a/VdLabel/MainViewModel.cs +++ b/VdLabel/MainViewModel.cs @@ -3,6 +3,7 @@ using Microsoft.Win32; using System.Collections.ObjectModel; using System.Diagnostics; +using System.Drawing; using System.Reflection; using System.Windows; using System.Windows.Data; @@ -16,7 +17,7 @@ partial class MainViewModel : ObservableObject private readonly IConfigStore configStore; private readonly IContentDialogService dialogService; private readonly IVirualDesktopService virualDesktopService; - private readonly ICommandLabelService commandLabelService; + private readonly ICommandService commandService; private readonly IPresentationService presentationService; private readonly IUpdateChecker updateChecker; @@ -39,6 +40,10 @@ partial class MainViewModel : ObservableObject [ObservableProperty] private string? newVersion; + [ObservableProperty] + [NotifyCanExecuteChangedFor(nameof(RemoveBadgeCommand))] + private BadgeConfigViewModel? selectedBadge; + private string? newVersionUrl; private string? installPath; @@ -52,6 +57,8 @@ partial class MainViewModel : ObservableObject public ObservableCollection DesktopConfigs { get; } = []; + public ObservableCollection Badges { get; } = []; + public IReadOnlyList OverlayPositions { get; } = Enum.GetValues(); public IReadOnlyList NamePositions { get; } = Enum.GetValues(); @@ -60,15 +67,16 @@ public MainViewModel( IPresentationService presentationService, IContentDialogService dialogService, IVirualDesktopService virualDesktopService, - ICommandLabelService commandLabelService, + ICommandService commandService, IUpdateChecker updateChecker) { BindingOperations.EnableCollectionSynchronization(this.DesktopConfigs, new()); + BindingOperations.EnableCollectionSynchronization(this.Badges, new()); this.configStore = configStore; this.presentationService = presentationService; this.dialogService = dialogService; this.virualDesktopService = virualDesktopService; - this.commandLabelService = commandLabelService; + this.commandService = commandService; this.updateChecker = updateChecker; this.DesktopDragDropHandler = new DesktopListDragDropHandler(virualDesktopService); this.virualDesktopService.DesktopChanged += VirualDesktopService_DesktopChanged; @@ -93,7 +101,16 @@ private async void SetUpUpdateInfo() } private void VirualDesktopService_DesktopChanged(object? sender, DesktopChangedEventArgs e) - => this.SelectedDesktopConfig = this.DesktopConfigs.FirstOrDefault(c => c.Id == e.DesktopId) ?? this.DesktopConfigs.FirstOrDefault(); + { + if (Application.Current.Dispatcher.CheckAccess()) + { + this.SelectedDesktopConfig = this.DesktopConfigs.FirstOrDefault(c => c.Id == e.DesktopId) ?? this.DesktopConfigs.FirstOrDefault(); + } + else + { + Application.Current.Dispatcher.BeginInvoke(() => VirualDesktopService_DesktopChanged(sender, e)); + } + } private async void Load() { @@ -106,9 +123,17 @@ private async void Load() this.DesktopConfigs.Clear(); foreach (var desktopConfig in this.Config.DesktopConfigs) { - this.DesktopConfigs.Add(new(desktopConfig, this.presentationService, this.dialogService, this.virualDesktopService, this.commandLabelService)); + this.DesktopConfigs.Add(new(desktopConfig, this.presentationService, this.dialogService, this.virualDesktopService, this.commandService)); } this.SelectedDesktopConfig = this.DesktopConfigs.FirstOrDefault(c => c.Id == selectedId) ?? this.DesktopConfigs.FirstOrDefault(); + + var selectedBadgeId = this.SelectedBadge?.Id; + this.Badges.Clear(); + foreach (var badge in this.Config.Badges) + { + this.Badges.Add(new(badge, this.commandService, this.dialogService, this.virualDesktopService)); + } + this.SelectedBadge = this.Badges.FirstOrDefault(b => b.Id == selectedBadgeId) ?? this.Badges.FirstOrDefault(); } finally { @@ -132,6 +157,11 @@ public async Task Save() { this.Config.DesktopConfigs.Add(desktopConfig.GetSaveConfig()); } + this.Config!.Badges.Clear(); + foreach (var badge in this.Badges) + { + this.Config.Badges.Add(badge.GetSaveConfig()); + } await this.configStore.Save(this.Config); } finally @@ -188,6 +218,28 @@ public void RemoveDesktop() private bool CanRemoveDesktop() => this.SelectedDesktopConfig is not null && this.SelectedDesktopConfig.IsNotPin; + [RelayCommand] + public void AddBadge() + { + var badge = new BadgeConfigViewModel(new BadgeConfig(), this.commandService, this.dialogService, this.virualDesktopService); + this.Badges.Add(badge); + this.SelectedBadge = badge; + } + + [RelayCommand(CanExecute = nameof(CanRemoveBadge))] + public void RemoveBadge() + { + if (this.SelectedBadge is null) + { + return; + } + this.Badges.Remove(this.SelectedBadge); + this.SelectedBadge = this.Badges.FirstOrDefault(); + } + + private bool CanRemoveBadge() + => this.SelectedBadge is not null; + partial void OnIsStartupChanged(bool value) { var exe = Assembly.GetExecutingAssembly(); @@ -221,13 +273,13 @@ partial class DesktopConfigViewModel( IPresentationService presentationService, IContentDialogService dialogService, IVirualDesktopService virualDesktopService, - ICommandLabelService commandLabelService) + ICommandService commandService) : ObservableObject { private readonly IPresentationService presentationService = presentationService; private readonly IContentDialogService dialogService = dialogService; private readonly IVirualDesktopService virualDesktopService = virualDesktopService; - private readonly ICommandLabelService commandLabelService = commandLabelService; + private readonly ICommandService commandService = commandService; public Guid Id { get; } = desktopConfig.Id; @@ -298,7 +350,7 @@ public async Task TestCommand() var command = this.Command ?? throw new InvalidOperationException(); try { - var result = await this.commandLabelService.ExecuteCommand(command, this.Utf8Command); + var result = await this.commandService.ExecuteCommand(command, this.Utf8Command); await this.dialogService.ShowAlertAsync(Properties.Resources.CommandSuccess, result, Properties.Resources.OK); } catch (Exception e) @@ -345,6 +397,7 @@ public DesktopConfig GetSaveConfig() Command = this.Command, ImagePath = this.ImagePath, TargetWindows = this.TargetWindows.Select(c => new WindowConfig(c.MatchType, c.PatternType, c.Pattern)).ToArray(), + BadgeIds = desktopConfig.BadgeIds, }; } @@ -357,3 +410,54 @@ partial class WindowConfigViewModel(WindowConfig? config = null) : ObservableObj [ObservableProperty] private string pattern = config?.Pattern ?? string.Empty; } + +partial class BadgeConfigViewModel(BadgeConfig badgeConfig, ICommandService commandService, IContentDialogService dialogService, IVirualDesktopService virualDesktopService) : ObservableObject +{ + public Guid Id { get; } = badgeConfig.Id; + + [ObservableProperty] + private string label = badgeConfig.Label; + + [ObservableProperty] + private System.Drawing.Color color = badgeConfig.Color; + + [ObservableProperty] + [NotifyCanExecuteChangedFor(nameof(TestBadgeCommandCommand))] + private string? command = badgeConfig.Command; + + [ObservableProperty] + private bool utf8Command = badgeConfig.Utf8Command; + + [RelayCommand(CanExecute = nameof(CanTestBadgeCommand))] + public async Task TestBadgeCommand() + { + var cmd = this.Command ?? throw new InvalidOperationException(); + var currentDesktopId = virualDesktopService.GetCurrent(); + try + { + var badge = await commandService.ExecuteBadgeCommand(cmd, this.Utf8Command, currentDesktopId, this.Label, this.Color); + await dialogService.ShowSimpleDialogAsync(new SimpleContentDialogCreateOptions + { + Title = Properties.Resources.CommandSuccess, + Content = badge, + CloseButtonText = Properties.Resources.OK, + }); + } + catch (Exception e) + { + await dialogService.ShowAlertAsync(Properties.Resources.CommandFailed, e.Message, Properties.Resources.OK); + } + } + + private bool CanTestBadgeCommand => !string.IsNullOrEmpty(this.Command); + + public BadgeConfig GetSaveConfig() + => new() + { + Id = this.Id, + Label = this.Label, + Color = this.Color, + Command = this.Command, + Utf8Command = this.Utf8Command, + }; +} diff --git a/VdLabel/MainWindow.xaml b/VdLabel/MainWindow.xaml index 93c3833..e84f0d1 100644 --- a/VdLabel/MainWindow.xaml +++ b/VdLabel/MainWindow.xaml @@ -468,6 +468,125 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +