From ddc30b0fe9d885d289ad6fbd524bed617fcd5885 Mon Sep 17 00:00:00 2001 From: M_Viper Date: Sat, 3 Oct 2026 19:51:56 +0000 Subject: [PATCH] Upload via GUI (180 Dateien) --- App.xaml.cs | 319 +- Backup.cs | 6 + Commands.cs | 70 + DesktopIcons.cs | 233 ++ DesktopOverlays.cs | 261 ++ DesktopToggle.cs | 272 ++ Directory.Build.props | 4 +- FenceManager.cs | 394 +++ FenceWindow.xaml | 49 +- FenceWindow.xaml.cs | 980 ++++-- GameLibraries.cs | 101 + Hotkey.cs | 278 +- MainWindow.Cleanup.cs | 114 + MainWindow.Fences.cs | 119 + MainWindow.LaunchGroups.cs | 413 +++ MainWindow.Pages.cs | 134 + MainWindow.xaml | 1610 +++++---- MainWindow.xaml.cs | 3073 +++++++++-------- Migration.cs | 5 + Models.cs | 206 +- README.md | 283 +- Screens.cs | 78 +- Settings.cs | 444 ++- Storage.cs | 868 ++--- Theme.cs | 148 +- Watcher.cs | 251 +- artifacts/app/DeskCore.exe | Bin 0 -> 711841 bytes artifacts/app/LICENSE.txt | 38 + artifacts/setup/DeskCore-Setup.exe | Bin 0 -> 978944 bytes artifacts/setup/DeskCore-Setup.exe.config | 6 + bin/Release/net9.0-windows/DeskCore.deps.json | 23 + bin/Release/net9.0-windows/DeskCore.dll | Bin 0 -> 474112 bytes bin/Release/net9.0-windows/DeskCore.exe | Bin 0 -> 233984 bytes bin/Release/net9.0-windows/DeskCore.pdb | Bin 0 -> 103336 bytes .../DeskCore.runtimeconfig.json | 20 + bin/Release/net9.0-windows/LICENSE.txt | 38 + .../net9.0-windows/win-x64/DeskCore.deps.json | 24 + .../net9.0-windows/win-x64/DeskCore.dll | Bin 0 -> 473088 bytes 38 files changed, 7174 insertions(+), 3688 deletions(-) create mode 100644 Commands.cs create mode 100644 DesktopIcons.cs create mode 100644 DesktopOverlays.cs create mode 100644 DesktopToggle.cs create mode 100644 FenceManager.cs create mode 100644 GameLibraries.cs create mode 100644 MainWindow.Cleanup.cs create mode 100644 MainWindow.Fences.cs create mode 100644 MainWindow.LaunchGroups.cs create mode 100644 MainWindow.Pages.cs create mode 100644 artifacts/app/DeskCore.exe create mode 100644 artifacts/app/LICENSE.txt create mode 100644 artifacts/setup/DeskCore-Setup.exe create mode 100644 artifacts/setup/DeskCore-Setup.exe.config create mode 100644 bin/Release/net9.0-windows/DeskCore.deps.json create mode 100644 bin/Release/net9.0-windows/DeskCore.dll create mode 100644 bin/Release/net9.0-windows/DeskCore.exe create mode 100644 bin/Release/net9.0-windows/DeskCore.pdb create mode 100644 bin/Release/net9.0-windows/DeskCore.runtimeconfig.json create mode 100644 bin/Release/net9.0-windows/LICENSE.txt create mode 100644 bin/Release/net9.0-windows/win-x64/DeskCore.deps.json create mode 100644 bin/Release/net9.0-windows/win-x64/DeskCore.dll diff --git a/App.xaml.cs b/App.xaml.cs index 83fceb7..ca0aff3 100644 --- a/App.xaml.cs +++ b/App.xaml.cs @@ -1,128 +1,191 @@ -using System.IO; -using System.Windows; -using System.Windows.Interop; - -namespace DeskCore; - -public partial class App : Application -{ - private const string MutexName = "DeskCore.Instanz"; - private const string ShowEventName = "DeskCore.Anzeigen"; - - public static MainWindow? Win { get; private set; } - public static Tray? TrayIcon { get; private set; } - public static bool Quitting { get; private set; } - - private static Hotkey? _hotkey; - private static Watcher? _watcher; - private Mutex? _mutex; - private EventWaitHandle? _showEvent; - - protected override void OnStartup(StartupEventArgs e) - { - // Nur eine Instanz: eine zweite holt einfach das vorhandene Fenster nach vorne - _mutex = new Mutex(true, MutexName, out bool first); - if (!first) - { - try { EventWaitHandle.OpenExisting(ShowEventName).Set(); } catch { } - Shutdown(); - return; - } - Migration.Run(); // alte "Desktop Sortierer"-Ordner übernehmen – vor allem anderen - _showEvent = new EventWaitHandle(false, EventResetMode.AutoReset, ShowEventName); - new Thread(() => - { - while (_showEvent.WaitOne()) - Dispatcher.BeginInvoke(() => Win?.ShowFromBackground()); - }) { IsBackground = true, Name = "ShowListener" }.Start(); - - DispatcherUnhandledException += (_, args) => - { - var log = Path.Combine(Storage.Root, "fehler.log"); - try { File.AppendAllText(log, $"[{DateTime.Now}] {args.Exception}\n\n"); } catch { } - MessageBox.Show($"Unerwarteter Fehler:\n\n{args.Exception.Message}\n\nDetails: {log}", "DeskCore", - MessageBoxButton.OK, MessageBoxImage.Error); - args.Handled = true; - }; - - base.OnStartup(e); - ShutdownMode = ShutdownMode.OnExplicitShutdown; - - Storage.FlushHolding(); // Reste eines Absturzes in den Papierkorb - Theme.Init(); - Theme.Changed += FenceManager.RefreshAll; - - Win = new MainWindow(); - MainWindow = Win; - var hwnd = new WindowInteropHelper(Win).EnsureHandle(); - - _hotkey = new Hotkey(hwnd); - _hotkey.Pressed += () => Win.ToggleFromHotkey(); - ApplyHotkey(); - - TrayIcon = new Tray(); - TrayIcon.Clicked += () => Win.ShowFromBackground(); - TrayIcon.BalloonClicked += () => Win.ShowFromBackground(); - TrayIcon.MenuRequested += () => Win.ShowTrayMenu(); - ApplyTray(); - - _watcher = new Watcher(Dispatcher); - _watcher.Changed += () => - { - Win.OnStorageChanged(); - FenceManager.RefreshAll(); - }; - _watcher.NewOnDesktop += Win.AutoSortNew; - - Autostart.RefreshPath(); - - bool minimized = e.Args.Contains("--minimized", StringComparer.OrdinalIgnoreCase); - if (!minimized || !AppSettings.Current.TrayEnabled) Win.Show(); - FenceManager.Sync(); - - // Bildschirm abgesteckt/Auflösung geändert → Fenster und Boxen wieder in den sichtbaren Bereich holen - Microsoft.Win32.SystemEvents.DisplaySettingsChanged += (_, _) => Dispatcher.BeginInvoke(() => - { - Win.EnsureOnScreen(); - FenceManager.EnsureOnScreen(); - }); - - Win.CheckForUpdatesOnStartup(); - } - - /// Tastenkürzel neu registrieren. false = von anderem Programm belegt. - public static bool ApplyHotkey() - { - if (_hotkey is null) return false; - var s = AppSettings.Current; - if (!s.HotkeyEnabled) { _hotkey.Unregister(); return true; } - return _hotkey.Register(s.HotkeyModifiers, s.HotkeyKey); - } - - public static void ApplyTray() - { - if (TrayIcon is not null) TrayIcon.Visible = AppSettings.Current.TrayEnabled; - } - - public static void Quit() - { - if (Quitting) return; - Quitting = true; - Win?.CancelBusyAndWait(); // z. B. große Kopie abbrechen, halbe Dateien aufräumen - Win?.SavePlacement(); - Win?.Undo.FlushAll(); - FenceManager.CloseAll(); - _watcher?.Dispose(); - _hotkey?.Dispose(); - TrayIcon?.Dispose(); - Win?.Close(); - Current.Shutdown(); - } - - protected override void OnExit(ExitEventArgs e) - { - TrayIcon?.Dispose(); - _mutex?.Dispose(); - base.OnExit(e); - } -} +using System.IO; +using System.Windows; +using System.Windows.Interop; + +namespace DeskCore; + +public partial class App : Application +{ + private const string MutexName = "DeskCore.Instanz"; + + public static MainWindow? Win { get; private set; } + public static Tray? TrayIcon { get; private set; } + public static bool Quitting { get; private set; } + + private static Hotkey? _hotkey; + private static Hotkey? _peekHotkey; + private static Hotkey? _pagePrev, _pageNext; + private static System.Windows.Threading.DispatcherTimer? _desktopTimer; + private static Watcher? _watcher; + private Mutex? _mutex; + + protected override void OnStartup(StartupEventArgs e) + { + // Nur eine Instanz: weitere Aufrufe reichen ihren Befehl (oder "Fenster zeigen") an die laufende weiter + _mutex = new Mutex(true, MutexName, out bool first); + if (!first) + { + Commands.Send(e.Args); + Shutdown(); + return; + } + Migration.Run(); // alte "Desktop Sortierer"-Ordner übernehmen – vor allem anderen + Commands.Listen(args => Dispatcher.BeginInvoke(() => HandleCommand(args))); + + DispatcherUnhandledException += (_, args) => + { + var log = Path.Combine(Storage.Root, "fehler.log"); + try { File.AppendAllText(log, $"[{DateTime.Now}] {args.Exception}\n\n"); } catch { } + MessageBox.Show($"Unerwarteter Fehler:\n\n{args.Exception.Message}\n\nDetails: {log}", "DeskCore", + MessageBoxButton.OK, MessageBoxImage.Error); + args.Handled = true; + }; + + base.OnStartup(e); + ShutdownMode = ShutdownMode.OnExplicitShutdown; + + Storage.FlushHolding(); // Reste eines Absturzes in den Papierkorb + Theme.Init(); + Theme.Changed += FenceManager.RefreshAll; + + Win = new MainWindow(); + MainWindow = Win; + var hwnd = new WindowInteropHelper(Win).EnsureHandle(); + + _hotkey = new Hotkey(hwnd); + _hotkey.Pressed += () => Win.ToggleFromHotkey(); + ApplyHotkey(); + _peekHotkey = new Hotkey(hwnd, 0xB002); + _peekHotkey.Pressed += FenceManager.TogglePeek; + ApplyPeekHotkey(); + // Desktop-Seiten: Win + Alt + Pfeil links/rechts + _pagePrev = new Hotkey(hwnd, 0xB003); + _pagePrev.Pressed += () => FenceManager.NextPage(-1); + _pagePrev.Register(Hotkey.ModWin | Hotkey.ModAlt, 0x25); + _pageNext = new Hotkey(hwnd, 0xB004); + _pageNext.Pressed += () => FenceManager.NextPage(1); + _pageNext.Register(Hotkey.ModWin | Hotkey.ModAlt, 0x27); + + TrayIcon = new Tray(); + TrayIcon.Clicked += () => Win.ShowFromBackground(); + TrayIcon.BalloonClicked += () => Win.ShowFromBackground(); + TrayIcon.MenuRequested += () => Win.ShowTrayMenu(); + ApplyTray(); + + _watcher = new Watcher(Dispatcher); + _watcher.Changed += () => + { + Win.OnStorageChanged(); + FenceManager.RefreshAll(); + Win.ScheduleDeadShortcutScan(); // z. B. Deinstallation hat die Desktop-Verknüpfung entfernt + }; + _watcher.NewOnDesktop += Win.AutoSortNew; + + Autostart.RefreshPath(); + + // Mit einem Befehl gestartet (z. B. Start-Gruppe per Desktop-Verknüpfung)? Dann ohne Fenster. + if (Commands.WantsWindow(e.Args) || !AppSettings.Current.TrayEnabled) Win.Show(); + FenceManager.Sync(); + + // Bildschirm abgesteckt/Auflösung geändert → Fenster und Boxen wieder in den sichtbaren Bereich holen + Microsoft.Win32.SystemEvents.DisplaySettingsChanged += (_, _) => Dispatcher.BeginInvoke(() => + { + Win.EnsureOnScreen(); + FenceManager.OnDisplayChanged(); + }); + + Win.CheckForUpdatesOnStartup(); + if (!Commands.WantsWindow(e.Args)) Dispatcher.BeginInvoke(() => HandleCommand(e.Args)); + Win.StartDeadShortcutWatch(); + DesktopToggle.Apply(); + + // Positionen der Desktop-Icons regelmäßig lesen (für Aufziehen, Doppelklick und die Icon-Tönung) + _desktopTimer = new System.Windows.Threading.DispatcherTimer { Interval = TimeSpan.FromSeconds(2) }; + _desktopTimer.Tick += (_, _) => RefreshDesktopIcons(); + _desktopTimer.Start(); + RefreshDesktopIcons(); + } + + private static void RefreshDesktopIcons() + { + var s = AppSettings.Current; + if (Quitting) return; + // Nur lesen, wenn eine Funktion die Positionen braucht + if (s.DesktopTint is null && !s.DrawFences && !s.QuickHide) return; + try { DesktopIcons.RefreshCache(); } catch { /* Explorer gerade neu gestartet o. Ä. */ } + DesktopTint.Refresh(); + } + + /// Befehl von der Befehlszeile oder einem weiteren Aufruf ausführen. + private static void HandleCommand(string[] args) + { + if (Win is null || Quitting) return; + if (Commands.Value(args, "--launch") is { } group) Win.LaunchGroupByName(group); + else if (Commands.Value(args, "--snapshot") is { } snap) Win.RestoreSnapshotByName(snap); + else if (Commands.Has(args, "--toggle-desktop")) DesktopToggle.Toggle(); + else if (Commands.Has(args, "--peek")) FenceManager.TogglePeek(); + else if (Commands.Value(args, "--page") is { } page) SwitchPageByName(page); + else if (Commands.WantsWindow(args)) Win.ShowFromBackground(); + } + + /// DeskCore.exe --page "Name" oder --page 2. + private static void SwitchPageByName(string page) + { + var pages = AppSettings.Current.Pages; + int i = pages.FindIndex(p => string.Equals(p, page, StringComparison.OrdinalIgnoreCase)); + if (i < 0 && int.TryParse(page, out var n)) i = n - 1; + if (i >= 0 && i < pages.Count) FenceManager.SwitchPage(i); + else TrayIcon?.Notify("Seite nicht gefunden", $"Es gibt keine Desktop-Seite „{page}“."); + } + + /// Tastenkürzel neu registrieren. false = von anderem Programm belegt. + public static bool ApplyHotkey() + { + if (_hotkey is null) return false; + var s = AppSettings.Current; + if (!s.HotkeyEnabled) { _hotkey.Unregister(); return true; } + return _hotkey.Register(s.HotkeyModifiers, s.HotkeyKey); + } + + /// Peek-Kürzel neu registrieren. false = von anderem Programm belegt. + public static bool ApplyPeekHotkey() + { + if (_peekHotkey is null) return false; + var s = AppSettings.Current; + if (!s.PeekEnabled) { _peekHotkey.Unregister(); return true; } + return _peekHotkey.Register(s.PeekModifiers, s.PeekKey); + } + + public static void ApplyTray() + { + if (TrayIcon is not null) TrayIcon.Visible = AppSettings.Current.TrayEnabled; + } + + public static void Quit() + { + if (Quitting) return; + Quitting = true; + Win?.CancelBusyAndWait(); // z. B. große Kopie abbrechen, halbe Dateien aufräumen + Win?.SavePlacement(); + _desktopTimer?.Stop(); + DesktopToggle.Stop(); + DesktopToggle.RestoreOnExit(); + _peekHotkey?.Dispose(); + _pagePrev?.Dispose(); + _pageNext?.Dispose(); + Win?.Undo.FlushAll(); + FenceManager.CloseAll(); + _watcher?.Dispose(); + _hotkey?.Dispose(); + TrayIcon?.Dispose(); + Win?.Close(); + Current.Shutdown(); + } + + protected override void OnExit(ExitEventArgs e) + { + TrayIcon?.Dispose(); + _mutex?.Dispose(); + base.OnExit(e); + } +} diff --git a/Backup.cs b/Backup.cs index 6269354..a9fdec7 100644 --- a/Backup.cs +++ b/Backup.cs @@ -170,6 +170,12 @@ public static class Backup if (!s.Rules.Any(x => string.Equals(x.Contains, r.Contains, StringComparison.OrdinalIgnoreCase) && string.Equals(x.Category, r.Category, StringComparison.OrdinalIgnoreCase))) s.Rules.Add(r); + foreach (var g in from.LaunchGroups ?? []) + if (!s.LaunchGroups.Any(x => string.Equals(x.Name, g.Name, StringComparison.OrdinalIgnoreCase))) + s.LaunchGroups.Add(g); + foreach (var snap in from.Snapshots ?? []) + if (!s.Snapshots.Any(x => string.Equals(x.Name, snap.Name, StringComparison.OrdinalIgnoreCase))) + s.Snapshots.Add(snap); s.Save(); } } diff --git a/Commands.cs b/Commands.cs new file mode 100644 index 0000000..8e85bae --- /dev/null +++ b/Commands.cs @@ -0,0 +1,70 @@ +using System.IO; +using System.IO.Pipes; + +namespace DeskCore; + +/// +/// Befehlszeile von DeskCore, z. B. für Desktop-Verknüpfungen und Skripte: +/// DeskCore.exe --launch "Arbeit" Start-Gruppe starten +/// DeskCore.exe --snapshot "Name" Box-Anordnung wiederherstellen +/// DeskCore.exe --toggle-desktop Desktop-Icons und Boxen ein-/ausblenden +/// DeskCore.exe --peek Boxen vor alle Fenster holen +/// DeskCore.exe --minimized im Infobereich starten +/// Läuft DeskCore schon, wird der Befehl an die laufende Instanz weitergereicht. +/// +public static class Commands +{ + private const string PipeName = "DeskCore.Befehle"; + private const char Separator = '\u001F'; + + public static string? Value(string[] args, string name) + { + int i = Array.FindIndex(args, a => a.Equals(name, StringComparison.OrdinalIgnoreCase)); + return i >= 0 && i + 1 < args.Length ? args[i + 1] : null; + } + + public static bool Has(string[] args, string name) => args.Any(a => a.Equals(name, StringComparison.OrdinalIgnoreCase)); + + /// Soll für diesen Aufruf das Hauptfenster erscheinen? + public static bool WantsWindow(string[] args) => + !Has(args, "--minimized") && Value(args, "--launch") is null && Value(args, "--snapshot") is null && + !Has(args, "--toggle-desktop") && !Has(args, "--peek"); + + /// An die laufende Instanz senden. + public static bool Send(string[] args) + { + try + { + using var client = new NamedPipeClientStream(".", PipeName, PipeDirection.Out); + client.Connect(3000); + using var w = new StreamWriter(client); + w.Write(string.Join(Separator, args)); + w.Flush(); + return true; + } + catch { return false; } + } + + /// + /// Empfängt Befehle weiterer Aufrufe (im Hintergrund). Die Pipe ist standardmäßig nur für den + /// eigenen Benutzer beschreibbar – andere Benutzer können DeskCore keine Befehle schicken. + /// + public static void Listen(Action onCommand) + { + new Thread(() => + { + while (true) + { + try + { + using var server = new NamedPipeServerStream(PipeName, PipeDirection.In, 1); + server.WaitForConnection(); + using var r = new StreamReader(server); + var text = r.ReadToEnd(); + onCommand(text.Length == 0 ? [] : text.Split(Separator)); + } + catch { Thread.Sleep(500); } + } + }) { IsBackground = true, Name = "Befehle" }.Start(); + } +} diff --git a/DesktopIcons.cs b/DesktopIcons.cs new file mode 100644 index 0000000..f8b1c36 --- /dev/null +++ b/DesktopIcons.cs @@ -0,0 +1,233 @@ +using System.Runtime.InteropServices; +using System.Text; + +namespace DeskCore; + +/// Ein Icon auf dem echten Windows-Desktop (Bildschirm-Pixel). +public record DesktopIcon(string Path, int X, int Y, int IconSize, int CellWidth); + +/// +/// Liest Positionen und Größe der echten Desktop-Icons über die offizielle Shell-Schnittstelle (IFolderView) +/// aus – ohne etwas in den Explorer einzuschleusen. Nur auf dem UI-Thread (STA) aufrufen. +/// +public static class DesktopIcons +{ + /// Alle Desktop-Icons mit Position – oder leer, wenn der Desktop gerade nicht erreichbar ist. + public static List Read() + { + var result = new List(); + try + { + var view = DesktopFolderView(out var listView); + if (view is null || listView == IntPtr.Zero) return result; + var view2 = view as IFolderView2; + int iconSize = 48; + if (view2 is not null && view2.GetViewModeAndIconSize(out _, out var size) == 0) iconSize = size; + view.GetSpacing(out var spacing); + var origin = new POINT(); + ClientToScreen(listView, ref origin); + + view.ItemCount(SVGIO_ALLVIEW, out int count); + for (int i = 0; i < count; i++) + { + if (view.Item(i, out var pidl) != 0 || pidl == IntPtr.Zero) continue; + try + { + if (view.GetItemPosition(pidl, out var pos) != 0) continue; + string path = ""; + if (view2 is not null && view2.GetItem(i, typeof(IShellItem).GUID, out var obj) == 0 && obj is IShellItem item) + { + if (item.GetDisplayName(SIGDN_FILESYSPATH, out var p) == 0) + { + path = Marshal.PtrToStringUni(p) ?? ""; + Marshal.FreeCoTaskMem(p); + } + Marshal.ReleaseComObject(item); + } + result.Add(new DesktopIcon(path, origin.X + pos.X, origin.Y + pos.Y, iconSize, spacing.X)); + } + finally { Marshal.FreeCoTaskMem(pidl); } + } + } + catch { } + return result; + } + + /// + /// Rechteck des Icon-Bildes in Bildschirm-Pixeln. Die Position aus IFolderView ist bereits die linke obere + /// Ecke des Bildes (gemessen: Eintrag beginnt (Abstand − Bildgröße) / 2 weiter links, 3 px weiter oben). + /// + public static (int L, int T, int Size) IconRect(DesktopIcon i) => (i.X, i.Y + IconTopOffset, i.IconSize); + + /// Feinkorrektur nach unten (Windows zeichnet das Bild mit kleinem Rand). + public static int IconTopOffset { get; set; } = 3; + + /// Gesamte Fläche eines Eintrags (Bild + Beschriftung), z. B. für Klick-Tests. + public static (int L, int T, int W, int H) CellRect(DesktopIcon i) + { + int pad = Math.Max(0, (i.CellWidth - i.IconSize) / 2); + return (i.X - pad, i.Y, i.CellWidth, i.IconSize + 44); + } + + /// Zuletzt gelesene Icons (für schnelle Prüfungen aus dem Maus-Hook, ohne COM-Aufruf). + public static volatile IReadOnlyList Cached = []; + + /// Zwischenspeicher auffrischen (UI-Thread). Gibt true zurück, wenn sich etwas geändert hat. + public static bool RefreshCache() + { + var fresh = Read(); + bool changed = fresh.Count != Cached.Count || fresh.Where((x, n) => x != Cached[n]).Any(); + Cached = fresh; + return changed; + } + + public static bool HitsIcon(int x, int y) => Cached.Any(i => + { + var (l, t, w, h) = CellRect(i); + return x >= l && x < l + w && y >= t && y < t + h; + }); + + // ---------- Desktop-Ansicht finden ---------- + + private static IFolderView? DesktopFolderView(out IntPtr listView) + { + listView = IntPtr.Zero; + var shellWindows = (IShellWindows)Activator.CreateInstance(Type.GetTypeFromCLSID(CLSID_ShellWindows)!)!; + try + { + object loc = CSIDL_DESKTOP, empty = new object(); + var disp = shellWindows.FindWindowSW(ref loc, ref empty, SWC_DESKTOP, out _, SWFO_NEEDDISPATCH); + if (disp is not IServiceProvider sp) return null; + var sidBrowser = SID_STopLevelBrowser; + var iidBrowser = typeof(IShellBrowser).GUID; + if (sp.QueryService(ref sidBrowser, ref iidBrowser, out var browserObj) != 0) return null; + var browser = (IShellBrowser)browserObj; + if (browser.QueryActiveShellView(out var shellView) != 0 || shellView is null) return null; + shellView.GetWindow(out var defView); + listView = FindWindowEx(defView, IntPtr.Zero, "SysListView32", null); + return shellView as IFolderView; + } + finally { Marshal.ReleaseComObject(shellWindows); } + } + + // ---------- COM-Schnittstellen (nur die benötigten Methoden in vtable-Reihenfolge) ---------- + + private const int CSIDL_DESKTOP = 0, SWC_DESKTOP = 8, SWFO_NEEDDISPATCH = 1, SVGIO_ALLVIEW = 2; + private const uint SIGDN_FILESYSPATH = 0x80058000; + private static readonly Guid CLSID_ShellWindows = new("9BA05972-F6A8-11CF-A442-00A0C90A8F39"); + private static readonly Guid SID_STopLevelBrowser = new("4C96BE40-915C-11CF-99D3-00AA004AE837"); + + [StructLayout(LayoutKind.Sequential)] private struct POINT { public int X, Y; } + + [ComImport, Guid("85CB6900-4D95-11CF-960C-0080C7F4EE85"), InterfaceType(ComInterfaceType.InterfaceIsDual)] + private interface IShellWindows + { + int Count { get; } + [return: MarshalAs(UnmanagedType.IDispatch)] object Item(object index); + [return: MarshalAs(UnmanagedType.IUnknown)] object _NewEnum(); + void Register([MarshalAs(UnmanagedType.IDispatch)] object pid, int hwnd, int swClass, out int plCookie); + void RegisterPending(int lThreadId, ref object pvarloc, ref object pvarlocRoot, int swClass, out int plCookie); + void Revoke(int lCookie); + void OnNavigate(int lCookie, ref object pvarLoc); + void OnActivated(int lCookie, short fActive); + [return: MarshalAs(UnmanagedType.IDispatch)] + object FindWindowSW(ref object pvarLoc, ref object pvarLocRoot, int swClass, out int pHWND, int swfwOptions); + } + + [ComImport, Guid("6D5140C1-7436-11CE-8034-00AA006009FA"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + private interface IServiceProvider + { + [PreserveSig] int QueryService(ref Guid guidService, ref Guid riid, [MarshalAs(UnmanagedType.Interface)] out object ppvObject); + } + + [ComImport, Guid("000214E2-0000-0000-C000-000000000046"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + private interface IShellBrowser + { + void GetWindow(out IntPtr phwnd); + void ContextSensitiveHelp(bool fEnterMode); + void InsertMenusSB(IntPtr hmenuShared, IntPtr lpMenuWidths); + void SetMenuSB(IntPtr hmenuShared, IntPtr holemenuRes, IntPtr hwndActiveObject); + void RemoveMenusSB(IntPtr hmenuShared); + void SetStatusTextSB(IntPtr pszStatusText); + void EnableModelessSB(bool fEnable); + void TranslateAcceleratorSB(IntPtr pmsg, ushort wID); + void BrowseObject(IntPtr pidl, uint wFlags); + void GetViewStateStream(uint grfMode, out IntPtr ppStrm); + void GetControlWindow(uint id, out IntPtr phwnd); + void SendControlMsg(uint id, uint uMsg, IntPtr wParam, IntPtr lParam, out IntPtr pret); + [PreserveSig] int QueryActiveShellView(out IShellView ppshv); + } + + [ComImport, Guid("000214E3-0000-0000-C000-000000000046"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + private interface IShellView + { + void GetWindow(out IntPtr phwnd); + } + + [ComImport, Guid("CDE725B0-CCC9-4519-917E-325D72FAB4CE"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + private interface IFolderView + { + [PreserveSig] int GetCurrentViewMode(out uint pViewMode); + [PreserveSig] int SetCurrentViewMode(uint ViewMode); + [PreserveSig] int GetFolder(ref Guid riid, out IntPtr ppv); + [PreserveSig] int Item(int iItemIndex, out IntPtr ppidl); + [PreserveSig] int ItemCount(uint uFlags, out int pcItems); + [PreserveSig] int Items(uint uFlags, ref Guid riid, out IntPtr ppv); + [PreserveSig] int GetSelectionMarkedItem(out int piItem); + [PreserveSig] int GetFocusedItem(out int piItem); + [PreserveSig] int GetItemPosition(IntPtr pidl, out POINT ppt); + [PreserveSig] int GetSpacing(out POINT ppt); + } + + [ComImport, Guid("1AF3A467-214F-4298-908E-06B03E0B39F9"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + private interface IFolderView2 + { + // IFolderView + [PreserveSig] int GetCurrentViewMode(out uint pViewMode); + [PreserveSig] int SetCurrentViewMode(uint ViewMode); + [PreserveSig] int GetFolder(ref Guid riid, out IntPtr ppv); + [PreserveSig] int Item(int iItemIndex, out IntPtr ppidl); + [PreserveSig] int ItemCount(uint uFlags, out int pcItems); + [PreserveSig] int Items(uint uFlags, ref Guid riid, out IntPtr ppv); + [PreserveSig] int GetSelectionMarkedItem(out int piItem); + [PreserveSig] int GetFocusedItem(out int piItem); + [PreserveSig] int GetItemPosition(IntPtr pidl, out POINT ppt); + [PreserveSig] int GetSpacing(out POINT ppt); + [PreserveSig] int GetDefaultSpacing(out POINT ppt); + [PreserveSig] int GetAutoArrange(); + [PreserveSig] int SelectItem(int iItem, uint dwFlags); + [PreserveSig] int SelectAndPositionItems(uint cidl, IntPtr apidl, IntPtr apt, uint dwFlags); + // IFolderView2 + [PreserveSig] int SetGroupBy(IntPtr key, bool fAscending); + [PreserveSig] int GetGroupBy(IntPtr pkey, out bool pfAscending); + [PreserveSig] int SetViewProperty(IntPtr pidl, IntPtr propkey, IntPtr propvar); + [PreserveSig] int GetViewProperty(IntPtr pidl, IntPtr propkey, IntPtr ppropvar); + [PreserveSig] int SetTileViewProperties(IntPtr pidl, IntPtr pszPropList); + [PreserveSig] int SetExtendedTileViewProperties(IntPtr pidl, IntPtr pszPropList); + [PreserveSig] int SetText(int iType, IntPtr pwszText); + [PreserveSig] int SetCurrentFolderFlags(uint dwMask, uint dwFlags); + [PreserveSig] int GetCurrentFolderFlags(out uint pdwFlags); + [PreserveSig] int GetSortColumnCount(out int pcColumns); + [PreserveSig] int SetSortColumns(IntPtr rgSortColumns, int cColumns); + [PreserveSig] int GetSortColumns(IntPtr rgSortColumns, int cColumns); + [PreserveSig] int GetItem(int iItem, [MarshalAs(UnmanagedType.LPStruct)] Guid riid, [MarshalAs(UnmanagedType.Interface)] out object ppv); + [PreserveSig] int GetVisibleItem(int iStart, bool fPrevious, out int piItem); + [PreserveSig] int GetSelectedItem(int iStart, out int piItem); + [PreserveSig] int GetSelection(bool fNoneImpliesFolder, out IntPtr ppsia); + [PreserveSig] int GetSelectionState(IntPtr pidl, out uint pdwFlags); + [PreserveSig] int InvokeVerbOnSelection(IntPtr pszVerb); + [PreserveSig] int SetViewModeAndIconSize(int uViewMode, int iImageSize); + [PreserveSig] int GetViewModeAndIconSize(out int puViewMode, out int piImageSize); + } + + [ComImport, Guid("43826D1E-E718-42EE-BC55-A1E261C37BFE"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + private interface IShellItem + { + [PreserveSig] int BindToHandler(IntPtr pbc, ref Guid bhid, ref Guid riid, out IntPtr ppv); + [PreserveSig] int GetParent(out IShellItem ppsi); + [PreserveSig] int GetDisplayName(uint sigdnName, out IntPtr ppszName); + } + + [DllImport("user32.dll")] private static extern bool ClientToScreen(IntPtr hWnd, ref POINT lpPoint); + [DllImport("user32.dll", CharSet = CharSet.Unicode)] private static extern IntPtr FindWindowEx(IntPtr parent, IntPtr after, string? cls, string? title); +} diff --git a/DesktopOverlays.cs b/DesktopOverlays.cs new file mode 100644 index 0000000..350ffc0 --- /dev/null +++ b/DesktopOverlays.cs @@ -0,0 +1,261 @@ +using System.IO; +using System.Runtime.InteropServices; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Interop; +using System.Windows.Media; +using System.Windows.Media.Animation; +using System.Windows.Shapes; +using System.Windows.Threading; + +namespace DeskCore; + +/// Gemeinsame Hilfen für klickdurchlässige Overlay-Fenster. +internal static class Overlay +{ + public static Window Create(bool topmost) + { + var w = new Window + { + WindowStyle = WindowStyle.None, AllowsTransparency = true, Background = Brushes.Transparent, + ShowInTaskbar = false, ShowActivated = false, Topmost = topmost, ResizeMode = ResizeMode.NoResize, + Focusable = false, IsHitTestVisible = false, + }; + w.SourceInitialized += (_, _) => + { + var hwnd = new WindowInteropHelper(w).Handle; + long ex = GetWindowLongPtr(hwnd, GWL_EXSTYLE).ToInt64(); + SetWindowLongPtr(hwnd, GWL_EXSTYLE, new IntPtr(ex | WS_EX_TRANSPARENT | WS_EX_LAYERED | WS_EX_TOOLWINDOW | WS_EX_NOACTIVATE)); + }; + return w; + } + + /// Wie die Boxen: Besitzer = Desktop, immer ganz unten (über den Desktop-Icons, unter allen Fenstern). + public static void PinToDesktop(Window w) + { + var hwnd = new WindowInteropHelper(w).Handle; + var progman = FindWindow("Progman", null); + if (progman != IntPtr.Zero) SetWindowLongPtr(hwnd, GWLP_HWNDPARENT, progman); + HwndSource.FromHwnd(hwnd).AddHook((IntPtr h, int msg, IntPtr wp, IntPtr lp, ref bool handled) => + { + if (msg == WM_WINDOWPOSCHANGING) + { + var pos = Marshal.PtrToStructure(lp); + pos.hwndInsertAfter = new IntPtr(1); // HWND_BOTTOM + pos.flags &= ~SWP_NOZORDER; + Marshal.StructureToPtr(pos, lp, false); + } + return IntPtr.Zero; + }); + } + + private const int GWL_EXSTYLE = -20, GWLP_HWNDPARENT = -8, WM_WINDOWPOSCHANGING = 0x46; + private const long WS_EX_TRANSPARENT = 0x20, WS_EX_LAYERED = 0x80000, WS_EX_TOOLWINDOW = 0x80, WS_EX_NOACTIVATE = 0x8000000; + private const uint SWP_NOZORDER = 0x4; + + [StructLayout(LayoutKind.Sequential)] + private struct WINDOWPOS { public IntPtr hwnd, hwndInsertAfter; public int x, y, cx, cy; public uint flags; } + + [DllImport("user32.dll", CharSet = CharSet.Unicode)] private static extern IntPtr FindWindow(string? cls, string? title); + [DllImport("user32.dll")] private static extern IntPtr GetWindowLongPtr(IntPtr h, int i); + [DllImport("user32.dll")] private static extern IntPtr SetWindowLongPtr(IntPtr h, int i, IntPtr v); +} + +// ========================================================================= +// Seitenanzeige beim Wechseln der Desktop-Seite +// ========================================================================= + +public static class PageIndicator +{ + private static Window? _w; + private static TextBlock? _name; + private static StackPanel? _dots; + private static DispatcherTimer? _hide; + + public static void Show(string name, int index, int count) + { + if (_w is null) + { + _w = Overlay.Create(topmost: true); + _w.SizeToContent = SizeToContent.WidthAndHeight; + _name = new TextBlock { FontSize = 15, FontWeight = FontWeights.SemiBold, HorizontalAlignment = HorizontalAlignment.Center }; + _dots = new StackPanel { Orientation = Orientation.Horizontal, HorizontalAlignment = HorizontalAlignment.Center, Margin = new Thickness(0, 8, 0, 0) }; + _w.Content = new Border + { + Padding = new Thickness(22, 12, 22, 12), CornerRadius = new CornerRadius(10), Margin = new Thickness(12), + Child = new StackPanel { Children = { _name, _dots } }, + }; + _hide = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(1300) }; + _hide.Tick += (_, _) => + { + _hide.Stop(); + var fade = new DoubleAnimation(0, TimeSpan.FromMilliseconds(350)); + fade.Completed += (_, _) => { if (_w.Opacity == 0) _w.Hide(); }; + _w.BeginAnimation(UIElement.OpacityProperty, fade); + }; + } + var res = Application.Current.Resources; + var border = (Border)_w.Content; + border.Background = (Brush)res["FlyoutBrush"]; + border.BorderBrush = (Brush)res["FlyoutStrokeBrush"]; + border.BorderThickness = new Thickness(1); + border.Effect = (System.Windows.Media.Effects.Effect)res["FlyoutShadow"]; + _name!.Text = name; + _name.Foreground = (Brush)res["TextBrush"]; + _dots!.Children.Clear(); + for (int i = 0; i < count; i++) + _dots.Children.Add(new Ellipse + { + Width = 7, Height = 7, Margin = new Thickness(4, 0, 4, 0), + Fill = (Brush)res[i == index ? "AccentBrush" : "ControlStrongStrokeBrush"], + }); + + _w.BeginAnimation(UIElement.OpacityProperty, null); + _w.Opacity = 1; + _w.Show(); + _w.UpdateLayout(); + var wa = Screens.PrimaryWorkArea; + _w.Left = wa.Left + (wa.Width - _w.ActualWidth) / 2; + _w.Top = wa.Bottom - _w.ActualHeight - 40; + _hide!.Stop(); + _hide.Start(); + } +} + +// ========================================================================= +// Rahmen beim Aufziehen einer neuen Box (rechte Maustaste auf dem Desktop) +// ========================================================================= + +public static class DrawBoxOverlay +{ + private static Window? _w; + + public static void Begin() + { + if (_w is null) + { + _w = Overlay.Create(topmost: true); + var res = Application.Current.Resources; + var accent = ((SolidColorBrush)res["AccentBrush"]).Color; + _w.Content = new Grid + { + Children = + { + new Rectangle + { + RadiusX = 8, RadiusY = 8, StrokeThickness = 2, StrokeDashArray = [4, 3], + Stroke = new SolidColorBrush(accent), Fill = new SolidColorBrush(Color.FromArgb(0x38, accent.R, accent.G, accent.B)), + }, + new TextBlock + { + Text = "Neue Box", Foreground = Brushes.White, FontSize = 14, FontWeight = FontWeights.SemiBold, + HorizontalAlignment = HorizontalAlignment.Center, VerticalAlignment = VerticalAlignment.Center, + Effect = new System.Windows.Media.Effects.DropShadowEffect { BlurRadius = 6, ShadowDepth = 0, Opacity = 0.8 }, + }, + }, + }; + } + _w.Width = _w.Height = 1; + _w.Show(); + } + + /// Rechteck in Bildschirm-Pixeln. + public static void Update(Rect px) + { + if (_w is null) return; + double s = Screens.Scale; + _w.Left = px.X / s; + _w.Top = px.Y / s; + _w.Width = Math.Max(1, px.Width / s); + _w.Height = Math.Max(1, px.Height / s); + } + + public static void Finish(Rect px) + { + _w?.Hide(); + double s = Screens.Scale; + var dip = new Rect(px.X / s, px.Y / s, px.Width / s, px.Height / s); + if (dip.Width < 120 || dip.Height < 60) return; // zu klein – war wohl nur ein Versehen + FenceManager.ShowCreateMenu(dip); + } +} + +// ========================================================================= +// Tönung der echten Desktop-Icons +// ========================================================================= + +/// +/// Legt eine klickdurchlässige Farbschicht exakt über die Bilder der Desktop-Icons. Die Positionen stammen aus der +/// offiziellen Shell-Schnittstelle; am Explorer selbst wird nichts verändert. Icons unter Boxen bleiben ausgespart. +/// +public static class DesktopTint +{ + private static Window? _w; + private static Canvas? _canvas; + private static string _signature = ""; + + private static AppSettings S => AppSettings.Current; + + /// Schicht neu aufbauen, wenn sich Icons, Boxen oder Einstellungen geändert haben. + public static void Refresh(bool force = false) + { + bool on = S.DesktopTint is not null && !FenceManager.Hidden && DesktopToggle.IconsVisible(); + if (!on) + { + _w?.Hide(); + _signature = ""; + return; + } + var icons = DesktopIcons.Cached.Where(i => i.Path.Length > 0).ToList(); + var fences = FenceManager.OpenRects(); + var sig = $"{S.DesktopTint}|{S.DesktopTintStrength}|{DesktopIcons.IconTopOffset}|" + + string.Join(";", icons.Select(i => $"{i.X},{i.Y},{i.IconSize}")) + "|" + string.Join(";", fences.Select(r => r.ToString())); + if (!force && sig == _signature && _w?.IsVisible == true) return; + _signature = sig; + + if (_w is null) + { + _w = Overlay.Create(topmost: false); + _canvas = new Canvas(); + _w.Content = _canvas; + _w.SourceInitialized += (_, _) => Overlay.PinToDesktop(_w); + } + + // Fenster über die gesamte Desktop-Fläche (alle Bildschirme) + double s = Screens.Scale; + var vs = new Rect(SystemParameters.VirtualScreenLeft, SystemParameters.VirtualScreenTop, + SystemParameters.VirtualScreenWidth, SystemParameters.VirtualScreenHeight); + _w.Left = vs.Left; + _w.Top = vs.Top; + _w.Width = vs.Width; + _w.Height = vs.Height; + + var tint = CategoryColors.Brush(S.DesktopTint) ?? Brushes.Transparent; + _canvas!.Children.Clear(); + foreach (var icon in icons) + { + var (l, t, size) = DesktopIcons.IconRect(icon); + var r = new Rect(l / s, t / s, size / s, size / s); + if (fences.Any(f => f.IntersectsWith(r))) continue; // liegt unter einer Box + var item = new IconItem { Path = icon.Path }; + var rect = new Rectangle + { + Width = r.Width, Height = r.Height, Fill = tint, Opacity = Math.Clamp(S.DesktopTintStrength, 0.1, 0.95), + Visibility = Visibility.Hidden, // erst zeigen, wenn die Form (das Icon) geladen ist + }; + void Apply() + { + if (item.Icon is null) return; + rect.OpacityMask = new ImageBrush(item.Icon) { Stretch = Stretch.Fill }; + rect.Visibility = Visibility.Visible; + } + item.PropertyChanged += (_, _) => Apply(); + Canvas.SetLeft(rect, r.X - vs.Left); + Canvas.SetTop(rect, r.Y - vs.Top); + _canvas.Children.Add(rect); + IconLoader.Load(item, size); + Apply(); + } + if (!_w.IsVisible) _w.Show(); + } +} diff --git a/DesktopToggle.cs b/DesktopToggle.cs new file mode 100644 index 0000000..eb20e1b --- /dev/null +++ b/DesktopToggle.cs @@ -0,0 +1,272 @@ +using System.Diagnostics; +using System.Runtime.InteropServices; +using System.Text; +using System.Windows; +using System.Windows.Automation; + +namespace DeskCore; + +/// +/// Maus-Funktionen direkt auf dem Windows-Desktop – ohne etwas in den Explorer einzuschleusen: +/// • Doppelklick auf freie Stelle: Desktop-Icons und Boxen aus-/einblenden ("Schnell verstecken") +/// • Rechte Maustaste auf freier Stelle ziehen: neue Box aufziehen (normaler Rechtsklick bleibt unverändert) +/// • Mausrad über dem Desktop: Desktop-Seite wechseln +/// +public static class DesktopToggle +{ + private static IntPtr _hook; + private static LowLevelMouseProc? _proc; // Referenz halten, sonst räumt der GC den Callback weg + private static long _lastDownTick; + private static POINT _lastDownPt; + + // Box aufziehen + private static bool _rPending, _rDrawing, _updatePosted; + private static POINT _rStart, _rCurrent; + private static long _lastWheelTick; + + private static AppSettings S => AppSettings.Current; + + public static void Apply() + { + bool needed = S.QuickHide || S.DrawFences || (S.PageWheel && S.Pages.Count > 1); + if (needed) Start(); + else Stop(); + } + + private static void Start() + { + if (_hook != IntPtr.Zero) return; + _proc = Proc; + using var module = Process.GetCurrentProcess().MainModule!; + _hook = SetWindowsHookEx(WH_MOUSE_LL, _proc, GetModuleHandle(module.ModuleName), 0); + } + + public static void Stop() + { + if (_hook == IntPtr.Zero) return; + UnhookWindowsHookEx(_hook); + _hook = IntPtr.Zero; + } + + /// Muss sehr schnell sein: nur Fensterklasse und zwischengespeicherte Icon-Positionen prüfen. + private static IntPtr Proc(int nCode, IntPtr wParam, IntPtr lParam) + { + if (nCode < 0) return CallNextHookEx(_hook, nCode, wParam, lParam); + var info = Marshal.PtrToStructure(lParam); + bool injected = (info.flags & LLMHF_INJECTED) != 0; + int msg = wParam.ToInt32(); + var dispatcher = Application.Current?.Dispatcher; + + switch (msg) + { + case WM_LBUTTONDOWN when S.QuickHide: + { + long now = Environment.TickCount64; + bool isDouble = now - _lastDownTick <= GetDoubleClickTime() && + Math.Abs(info.pt.x - _lastDownPt.x) <= 4 && Math.Abs(info.pt.y - _lastDownPt.y) <= 4; + if (isDouble) + { + _lastDownTick = 0; + var pt = info.pt; + dispatcher?.BeginInvoke(() => OnDoubleClick(pt)); + } + else + { + _lastDownTick = now; + _lastDownPt = info.pt; + } + break; + } + + case WM_RBUTTONDOWN when !injected && S.DrawFences && IsDesktopAt(info.pt) && !DesktopIcons.HitsIcon(info.pt.x, info.pt.y): + // Rechtsklick auf freie Desktop-Fläche abfangen – bleibt es ein Klick, wird er unten nachgereicht + _rPending = true; + _rDrawing = false; + _rStart = _rCurrent = info.pt; + return new IntPtr(1); + + case WM_MOUSEMOVE when _rPending: + _rCurrent = info.pt; + if (!_rDrawing && (Math.Abs(info.pt.x - _rStart.x) > 8 || Math.Abs(info.pt.y - _rStart.y) > 8)) + { + _rDrawing = true; + dispatcher?.BeginInvoke(() => DrawBoxOverlay.Begin()); + } + if (_rDrawing && !_updatePosted) + { + _updatePosted = true; + dispatcher?.BeginInvoke(() => { _updatePosted = false; DrawBoxOverlay.Update(PixelRect(_rStart, _rCurrent)); }); + } + break; + + case WM_RBUTTONUP when _rPending && !injected: + { + _rPending = false; + var rect = PixelRect(_rStart, info.pt); + if (_rDrawing) dispatcher?.BeginInvoke(() => DrawBoxOverlay.Finish(rect)); + else dispatcher?.BeginInvoke(ReplayRightClick); // normales Desktop-Kontextmenü + return new IntPtr(1); + } + + case WM_MOUSEWHEEL when S.PageWheel && S.Pages.Count > 1 && !injected: + { + if ((GetKeyState(VK_CONTROL) & 0x8000) != 0) break; // Strg+Rad = Icon-Größe (Windows) + long now = Environment.TickCount64; + if (now - _lastWheelTick < 350 || !IsDesktopAt(info.pt)) break; + _lastWheelTick = now; + int delta = (short)((info.mouseData >> 16) & 0xFFFF); + dispatcher?.BeginInvoke(() => FenceManager.NextPage(delta < 0 ? 1 : -1)); + break; + } + } + return CallNextHookEx(_hook, nCode, wParam, lParam); + } + + private static Rect PixelRect(POINT a, POINT b) => + new(Math.Min(a.x, b.x), Math.Min(a.y, b.y), Math.Abs(a.x - b.x), Math.Abs(a.y - b.y)); + + /// Liegt unter dem Punkt direkt der Desktop (kein Fenster darüber)? + private static bool IsDesktopAt(POINT pt) + { + var cls = ClassName(WindowFromPoint(pt)); + return cls is "WorkerW" or "Progman" or "SHELLDLL_DefView" || + (cls == "SysListView32" && ClassName(GetParent(WindowFromPoint(pt))) == "SHELLDLL_DefView"); + } + + /// Abgefangenen Rechtsklick an Windows weitergeben (wird als "eingespeist" markiert und nicht erneut abgefangen). + private static void ReplayRightClick() + { + var inputs = new[] + { + new INPUT { type = INPUT_MOUSE, mi = new MOUSEINPUT { dwFlags = MOUSEEVENTF_RIGHTDOWN } }, + new INPUT { type = INPUT_MOUSE, mi = new MOUSEINPUT { dwFlags = MOUSEEVENTF_RIGHTUP } }, + }; + SendInput((uint)inputs.Length, inputs, Marshal.SizeOf()); + } + + private static void OnDoubleClick(POINT pt) + { + if (!IsEmptyDesktopSpot(pt)) return; + Toggle(); + } + + /// Liegt der Punkt auf dem Desktop – und zwar nicht auf einem Icon? + private static bool IsEmptyDesktopSpot(POINT pt) + { + var hwnd = WindowFromPoint(pt); + var cls = ClassName(hwnd); + if (cls is "WorkerW" or "Progman" or "SHELLDLL_DefView") return true; // Icons gerade ausgeblendet + if (cls != "SysListView32" || ClassName(GetParent(hwnd)) != "SHELLDLL_DefView") return false; + // Auf ein Icon geklickt? Dann ist es ein normaler Programmstart, nichts tun. + try + { + var el = AutomationElement.FromPoint(new Point(pt.x, pt.y)); + for (var e = el; e is not null; e = TreeWalker.RawViewWalker.GetParent(e)) + { + var type = e.Current.ControlType; + if (type == ControlType.ListItem) return false; + if (type == ControlType.List) break; + } + return true; + } + catch { return false; } + } + + /// Desktop-Icons und Boxen umschalten (auch per Befehl --toggle-desktop). + public static void Toggle() + { + var view = DesktopView(); + var list = view == IntPtr.Zero ? IntPtr.Zero : FindWindowEx(view, IntPtr.Zero, "SysListView32", null); + bool iconsVisible = list != IntPtr.Zero && IsWindowVisible(list); + if (view != IntPtr.Zero) SendMessage(view, WM_COMMAND, new IntPtr(0x7402), IntPtr.Zero); // "Desktopsymbole anzeigen" umschalten + FenceManager.SetHidden(iconsVisible); + DesktopTint.Refresh(); + } + + /// Beim Beenden: ausgeblendete Desktop-Icons wieder zeigen, damit niemand vor einem leeren Desktop sitzt. + public static void RestoreOnExit() + { + if (!FenceManager.Hidden) return; + var view = DesktopView(); + var list = view == IntPtr.Zero ? IntPtr.Zero : FindWindowEx(view, IntPtr.Zero, "SysListView32", null); + if (view != IntPtr.Zero && list != IntPtr.Zero && !IsWindowVisible(list)) + SendMessage(view, WM_COMMAND, new IntPtr(0x7402), IntPtr.Zero); + } + + /// Das Fenster mit den Desktop-Icons (liegt je nach Zustand unter Progman oder einem WorkerW). + public static IntPtr DesktopView() + { + var progman = FindWindow("Progman", null); + var view = FindWindowEx(progman, IntPtr.Zero, "SHELLDLL_DefView", null); + if (view != IntPtr.Zero) return view; + IntPtr worker = IntPtr.Zero; + while ((worker = FindWindowEx(IntPtr.Zero, worker, "WorkerW", null)) != IntPtr.Zero) + { + view = FindWindowEx(worker, IntPtr.Zero, "SHELLDLL_DefView", null); + if (view != IntPtr.Zero) return view; + } + return IntPtr.Zero; + } + + /// Sind die Desktop-Icons gerade sichtbar? + public static bool IconsVisible() + { + var view = DesktopView(); + var list = view == IntPtr.Zero ? IntPtr.Zero : FindWindowEx(view, IntPtr.Zero, "SysListView32", null); + return list != IntPtr.Zero && IsWindowVisible(list); + } + + private static string ClassName(IntPtr hwnd) + { + if (hwnd == IntPtr.Zero) return ""; + var sb = new StringBuilder(64); + GetClassName(hwnd, sb, sb.Capacity); + return sb.ToString(); + } + + private const int WH_MOUSE_LL = 14, WM_COMMAND = 0x0111, VK_CONTROL = 0x11; + private const int WM_MOUSEMOVE = 0x0200, WM_LBUTTONDOWN = 0x0201, WM_RBUTTONDOWN = 0x0204, WM_RBUTTONUP = 0x0205, WM_MOUSEWHEEL = 0x020A; + private const uint LLMHF_INJECTED = 0x1, INPUT_MOUSE = 0, MOUSEEVENTF_RIGHTDOWN = 0x0008, MOUSEEVENTF_RIGHTUP = 0x0010; + + private delegate IntPtr LowLevelMouseProc(int nCode, IntPtr wParam, IntPtr lParam); + + [StructLayout(LayoutKind.Sequential)] private struct POINT { public int x, y; } + + [StructLayout(LayoutKind.Sequential)] + private struct MSLLHOOKSTRUCT + { + public POINT pt; + public uint mouseData, flags, time; + public IntPtr dwExtraInfo; + } + + [StructLayout(LayoutKind.Sequential)] + private struct MOUSEINPUT + { + public int dx, dy; + public uint mouseData, dwFlags, time; + public IntPtr dwExtraInfo; + } + + [StructLayout(LayoutKind.Explicit)] + private struct INPUT + { + [FieldOffset(0)] public uint type; + [FieldOffset(8)] public MOUSEINPUT mi; // 64-Bit: Union beginnt bei Offset 8 + } + + [DllImport("user32.dll", SetLastError = true)] private static extern IntPtr SetWindowsHookEx(int idHook, LowLevelMouseProc lpfn, IntPtr hMod, uint dwThreadId); + [DllImport("user32.dll")] private static extern bool UnhookWindowsHookEx(IntPtr hhk); + [DllImport("user32.dll")] private static extern IntPtr CallNextHookEx(IntPtr hhk, int nCode, IntPtr wParam, IntPtr lParam); + [DllImport("kernel32.dll", CharSet = CharSet.Unicode)] private static extern IntPtr GetModuleHandle(string lpModuleName); + [DllImport("user32.dll")] private static extern uint GetDoubleClickTime(); + [DllImport("user32.dll")] private static extern short GetKeyState(int vk); + [DllImport("user32.dll")] private static extern uint SendInput(uint n, INPUT[] inputs, int size); + [DllImport("user32.dll")] private static extern IntPtr WindowFromPoint(POINT p); + [DllImport("user32.dll")] private static extern IntPtr GetParent(IntPtr hWnd); + [DllImport("user32.dll", CharSet = CharSet.Unicode)] private static extern int GetClassName(IntPtr hWnd, StringBuilder name, int max); + [DllImport("user32.dll", CharSet = CharSet.Unicode)] private static extern IntPtr FindWindow(string? cls, string? title); + [DllImport("user32.dll", CharSet = CharSet.Unicode)] private static extern IntPtr FindWindowEx(IntPtr parent, IntPtr after, string? cls, string? title); + [DllImport("user32.dll")] private static extern bool IsWindowVisible(IntPtr hWnd); + [DllImport("user32.dll")] private static extern IntPtr SendMessage(IntPtr hWnd, int msg, IntPtr wParam, IntPtr lParam); +} diff --git a/Directory.Build.props b/Directory.Build.props index e9af55f..35b1e28 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,7 +1,7 @@  - + - 1.0.1 + 1.0.2 false diff --git a/FenceManager.cs b/FenceManager.cs new file mode 100644 index 0000000..4a22b90 --- /dev/null +++ b/FenceManager.cs @@ -0,0 +1,394 @@ +using System.Windows; +using System.Windows.Controls; +using System.Windows.Media; +using System.Windows.Threading; + +namespace DeskCore; + +/// Verwaltet alle Desktop-Boxen: Anzeigen, Peek, Ausblenden, Snapshots und Anordnung je Bildschirm-Setup. +public static class FenceManager +{ + private static readonly List _open = []; + private static AppSettings S => AppSettings.Current; + private static DispatcherTimer? _peekWatch; + private static string? _screenSignature; + + /// Boxen gerade per "Schnell verstecken" ausgeblendet? + public static bool Hidden { get; private set; } + + /// Boxen gerade per Peek vor allen Fenstern? + public static bool Peeking { get; private set; } + + public static void Sync() + { + _screenSignature ??= ScreenSignature(); + // Boxen auf nicht (mehr) vorhandenen Seiten landen auf der letzten Seite + foreach (var f in S.Fences) f.Page = Math.Clamp(f.Page, 0, S.Pages.Count - 1); + var wanted = S.FencesEnabled && !Hidden + ? S.Fences.Where(f => f.Tabs.Count > 0 && f.Page == S.CurrentPage).ToList() + : []; + + foreach (var w in _open.Where(w => !wanted.Contains(w.Def)).ToList()) + { + w.Close(); + _open.Remove(w); + } + + var wa = Screens.PrimaryWorkArea; + foreach (var def in wanted) + { + var w = _open.FirstOrDefault(x => x.Def == def); + if (w is not null) { w.Refresh(); continue; } + + if (def.X is null) + { + // neue Box: oben rechts beginnen – links liegen meist die Desktop-Icons + int perRow = Math.Max(1, (int)((wa.Width - 48) / (def.W + 16))); + int i = _open.Count; + def.X = wa.Right - 24 - def.W - (i % perRow) * (def.W + 16); + def.Y = wa.Top + 24 + (i / perRow) * (def.H + 16); + } + w = new FenceWindow(def); + _open.Add(w); + w.Show(); + } + } + + public static void RefreshAll() + { + foreach (var w in _open) w.Refresh(); + } + + public static void CloseAll() + { + foreach (var w in _open) w.Close(); + _open.Clear(); + } + + public static void Remove(FenceDef def) + { + S.Fences.Remove(def); + S.Save(); + Sync(); + App.Win?.RefreshFromFences(); + } + + /// Kategorie aus allen Boxen (außer except) entfernen – eine Kategorie soll nur in einer Box stehen. + public static void RemoveCategoryTab(string category, FenceDef? except) + { + foreach (var f in S.Fences.Where(f => f != except)) + f.Tabs.RemoveAll(t => !t.IsFolder && string.Equals(t.Value, category, StringComparison.OrdinalIgnoreCase)); + S.Fences.RemoveAll(f => f != except && f.Tabs.Count == 0); + } + + /// Tab als eigene Box daneben lösen. + public static void DetachTab(FenceDef def, int index) + { + if (index < 0 || index >= def.Tabs.Count || def.Tabs.Count < 2) return; + var tab = def.Tabs[index]; + def.Tabs.RemoveAt(index); + def.ActiveTab = Math.Clamp(def.ActiveTab, 0, def.Tabs.Count - 1); + S.Fences.Add(new FenceDef + { + Page = def.Page, Tabs = [tab], X = (def.X ?? 0) + 40, Y = (def.Y ?? 0) + 40, W = def.W, H = def.H, + Background = def.Background, Opacity = def.Opacity, Blur = def.Blur, IconSize = def.IconSize, Sort = def.Sort, Tint = def.Tint, + }); + S.Save(); + Sync(); + } + + public static string? PickFolder() + { + var dlg = new Microsoft.Win32.OpenFolderDialog { Title = "Ordner für das Portal wählen" }; + return dlg.ShowDialog() == true ? dlg.FolderName : null; + } + + /// Neue Box, die live einen beliebigen Ordner zeigt (z. B. Downloads). + public static void AddPortal() + { + if (PickFolder() is not { } folder) return; + S.Fences.Add(new FenceDef { Page = S.CurrentPage, Tabs = [new FenceTab { Kind = FenceTab.FolderKind, Value = folder }] }); + S.FencesEnabled = true; + S.Save(); + Sync(); + } + + /// Flächen der sichtbaren Boxen (WPF-Einheiten), z. B. damit die Icon-Tönung sie ausspart. + public static List OpenRects() => + _open.Where(w => w.IsVisible).Select(w => new Rect(w.Left, w.Top, w.ActualWidth, w.ActualHeight)).ToList(); + + /// Nach dem Aufziehen: Menü "Neue Box hier" an der Mausposition. + public static void ShowCreateMenu(Rect area) + { + var menu = new ContextMenu { Placement = System.Windows.Controls.Primitives.PlacementMode.MousePoint }; + var header = MainWindow.MenuItem("Neue Box hier für …", "\uE8A1", null, bold: true); + header.IsEnabled = false; + menu.Items.Add(header); + menu.Items.Add(new Separator()); + + void Create(FenceTab tab) + { + if (!tab.IsFolder) RemoveCategoryTab(tab.Value, except: null); + S.Fences.Add(new FenceDef + { + Page = S.CurrentPage, Tabs = [tab], + X = area.X, Y = area.Y, W = Math.Max(180, area.Width), H = Math.Max(120, area.Height), + }); + S.FencesEnabled = true; + S.Save(); + Sync(); + App.Win?.RefreshFromFences(); + } + + foreach (var c in Storage.Categories()) + { + var label = S.HasFence(c) ? $"{c} (aus anderer Box hierher holen)" : c; + menu.Items.Add(MainWindow.MenuItem(label, S.Categories.GetValueOrDefault(c)?.Glyph ?? "\uF12B", + () => Create(new FenceTab { Value = c }))); + } + if (Storage.Categories().Count > 0) menu.Items.Add(new Separator()); + menu.Items.Add(MainWindow.MenuItem("Neue Kategorie …", "\uE710", () => + { + if (App.Win?.CreateCategoryInteractive() is { } name) Create(new FenceTab { Value = name }); + })); + menu.Items.Add(MainWindow.MenuItem("Ordner-Portal …", "\uF12B", () => + { + if (PickFolder() is { } folder) Create(new FenceTab { Kind = FenceTab.FolderKind, Value = folder }); + })); + menu.Items.Add(new Separator()); + menu.Items.Add(MainWindow.MenuItem("Abbrechen", "\uE711", () => { })); + // Damit das Menü bei Klick daneben zugeht, muss es im Vordergrund sein + menu.Opened += (_, _) => + { + if (PresentationSource.FromVisual(menu) is System.Windows.Interop.HwndSource src) SetForegroundWindow(src.Handle); + }; + menu.IsOpen = true; + } + + [System.Runtime.InteropServices.DllImport("user32.dll")] private static extern bool SetForegroundWindow(IntPtr hwnd); + + // ===================================================================== + // Desktop-Seiten + // ===================================================================== + + /// Zur nächsten/vorigen Seite wechseln (rundum). + public static void NextPage(int delta) + { + if (S.Pages.Count < 2) return; + SwitchPage((S.CurrentPage + delta + S.Pages.Count) % S.Pages.Count); + } + + public static void SwitchPage(int index) + { + index = Math.Clamp(index, 0, S.Pages.Count - 1); + if (Peeking) EndPeek(); + bool changed = index != S.CurrentPage; + S.CurrentPage = index; + S.Save(); + if (changed) Sync(); + PageIndicator.Show(S.Pages[index], index, S.Pages.Count); + App.Win?.RefreshFromFences(); + } + + public static void MoveToPage(FenceDef def, int page) + { + def.Page = Math.Clamp(page, 0, S.Pages.Count - 1); + S.Save(); + Sync(); + } + + /// Seite löschen: ihre Boxen wandern auf die Seite davor. + public static void DeletePage(int index) + { + if (S.Pages.Count < 2 || index < 0 || index >= S.Pages.Count) return; + S.Pages.RemoveAt(index); + foreach (var f in S.Fences) + if (f.Page == index) f.Page = Math.Max(0, index - 1); + else if (f.Page > index) f.Page--; + if (S.CurrentPage >= index && S.CurrentPage > 0) S.CurrentPage--; + S.Save(); + CloseAll(); + Sync(); + } + + // ===================================================================== + // Aussehen + // ===================================================================== + + public static void EditLook(FenceWindow w) + { + var def = w.Def; + var res = Application.Current.Resources; + var before = AppSettings.CloneFences([def])[0]; + var panel = new StackPanel(); + TextBlock Label(string t, double top = 16) => new() { Text = t, Margin = new Thickness(0, top, 0, 8), FontWeight = FontWeights.SemiBold, FontSize = 13 }; + + // Hintergrund + panel.Children.Add(Label("Hintergrund", 0)); + var bgPanel = new WrapPanel(); + void AddSwatch(WrapPanel p, string group, string? hex, Brush brush, string tip, Func get, Action set) + { + var rb = new RadioButton { Style = (Style)res["SwatchRadio"], GroupName = group, Background = brush, ToolTip = tip, IsChecked = get() == hex }; + rb.Checked += (_, _) => { set(hex); w.ApplyLook(); }; + p.Children.Add(rb); + } + AddSwatch(bgPanel, "bg", null, (Brush)res["FenceBrush"], "Passend zum Design", () => def.Background, v => def.Background = v); + foreach (var (name, hex) in CategoryColors.All.Concat([("Schwarz", "#101010"), ("Weiß", "#F5F5F5")])) + AddSwatch(bgPanel, "bg", hex, CategoryColors.Brush(hex)!, name, () => def.Background, v => def.Background = v); + panel.Children.Add(bgPanel); + + // Deckkraft + panel.Children.Add(Label("Deckkraft")); + var opacity = new Slider { Minimum = 20, Maximum = 100, Value = Math.Round(def.Opacity * 100), TickFrequency = 5, IsSnapToTickEnabled = true }; + opacity.ValueChanged += (_, _) => { def.Opacity = opacity.Value / 100; w.ApplyLook(); }; + panel.Children.Add(opacity); + + var blur = new CheckBox { Content = "Milchglas (Hintergrund verschwommen)", IsChecked = def.Blur, Margin = new Thickness(0, 12, 0, 0) }; + blur.Click += (_, _) => { def.Blur = blur.IsChecked == true; w.ApplyLook(); }; + panel.Children.Add(blur); + + // Icon-Größe und Sortierung als Segmente + StackPanel Segments(string group, (string Label, string Value)[] options, string current, Action set) + { + var sp = new StackPanel { Orientation = Orientation.Horizontal }; + foreach (var (label, value) in options) + { + var rb = new RadioButton { Style = (Style)res["SegmentRadio"], GroupName = group, Content = label, IsChecked = value == current }; + rb.Checked += (_, _) => { set(value); w.Refresh(); }; + sp.Children.Add(rb); + } + return sp; + } + panel.Children.Add(Label("Icon-Größe")); + panel.Children.Add(new Border + { + Style = (Style)res["SegmentGroup"], HorizontalAlignment = HorizontalAlignment.Left, + Child = Segments("size", [("Klein", "Small"), ("Mittel", "Medium"), ("Groß", "Large")], def.IconSize.ToString(), + v => def.IconSize = Enum.Parse(v)), + }); + panel.Children.Add(Label("Sortieren nach")); + panel.Children.Add(new Border + { + Style = (Style)res["SegmentGroup"], HorizontalAlignment = HorizontalAlignment.Left, + Child = Segments("sort", [("Name", "Name"), ("Typ", "Typ"), ("Datum", "Datum")], def.Sort, v => def.Sort = v), + }); + + // Icon-Tönung + panel.Children.Add(Label("Icon-Tönung")); + var tintPanel = new WrapPanel(); + AddSwatch(tintPanel, "tint", null, Brushes.Transparent, "Keine Tönung", () => def.Tint, v => def.Tint = v); + foreach (var (name, hex) in CategoryColors.All) + AddSwatch(tintPanel, "tint", hex, CategoryColors.Brush(hex)!, name, () => def.Tint, v => def.Tint = v); + panel.Children.Add(tintPanel); + + var owner = App.Win!; + if (Dialog.Show(owner, "Aussehen der Box", panel, "Speichern", "Abbrechen", null, width: 470)) + S.Save(); + else + { + // Vorschau zurücknehmen + (def.Background, def.Opacity, def.Blur, def.IconSize, def.Sort, def.Tint) = + (before.Background, before.Opacity, before.Blur, before.IconSize, before.Sort, before.Tint); + w.Refresh(); + } + } + + // ===================================================================== + // Peek & Schnell verstecken + // ===================================================================== + + public static void TogglePeek() + { + if (Peeking) EndPeek(); + else StartPeek(); + } + + private static void StartPeek() + { + if (_open.Count == 0) return; + if (Hidden) SetHidden(false); + Peeking = true; + foreach (var w in _open) w.SetPeek(true); + _open[0].Activate(); + // Peek endet, sobald ein anderes Fenster aktiv wird + _peekWatch ??= new DispatcherTimer(TimeSpan.FromMilliseconds(300), DispatcherPriority.Background, (_, _) => + { + if (!_open.Any(w => w.IsActive) && !_open.Any(w => w.IsMouseOver)) EndPeek(); + }, Application.Current.Dispatcher); + _peekWatch.Start(); + } + + public static void EndPeek() + { + _peekWatch?.Stop(); + if (!Peeking) return; + Peeking = false; + foreach (var w in _open) w.SetPeek(false); + } + + /// Alle Boxen aus- bzw. wieder einblenden (Schnell verstecken). + public static void SetHidden(bool hidden) + { + if (Hidden == hidden) return; + Hidden = hidden; + if (hidden) EndPeek(); + Sync(); + } + + // ===================================================================== + // Bildschirme & Snapshots + // ===================================================================== + + /// Kennung der aktuellen Bildschirm-Anordnung (Anzahl, Größen, Positionen). + private static string ScreenSignature() => + string.Join("|", System.Windows.Forms.Screen.AllScreens + .OrderBy(s => s.Bounds.X).ThenBy(s => s.Bounds.Y) + .Select(s => $"{s.Bounds.X},{s.Bounds.Y},{s.Bounds.Width}x{s.Bounds.Height}")); + + private static Dictionary CurrentPositions() => + S.Fences.Where(f => f.X is not null).ToDictionary(f => f.Id, f => new[] { f.X!.Value, f.Y!.Value, f.W, f.H }); + + /// + /// Bildschirm angesteckt/abgesteckt oder Auflösung geändert: Anordnung der alten Konstellation merken + /// und – falls bekannt – die zur neuen Konstellation passende wiederherstellen. + /// + public static void OnDisplayChanged() + { + var newSig = ScreenSignature(); + if (_screenSignature is { } oldSig && oldSig != newSig) + { + S.ScreenLayouts[oldSig] = CurrentPositions(); + if (S.ScreenLayouts.TryGetValue(newSig, out var layout)) + { + foreach (var f in S.Fences) + if (layout.TryGetValue(f.Id, out var r) && r.Length == 4) + (f.X, f.Y, f.W, f.H) = (r[0], r[1], r[2], r[3]); + foreach (var w in _open) w.Reapply(); + } + S.Save(); + } + _screenSignature = newSig; + foreach (var w in _open) w.EnsureOnScreen(); + } + + public static void SaveSnapshot(string name) + { + S.Snapshots.RemoveAll(s => string.Equals(s.Name, name, StringComparison.OrdinalIgnoreCase)); + S.Snapshots.Add(new FenceSnapshot { Name = name, Fences = AppSettings.CloneFences(S.Fences) }); + S.Save(); + } + + /// Anordnung wiederherstellen. Gibt die Anordnung davor zurück (für Rückgängig). + public static List RestoreSnapshot(FenceSnapshot snap) => ApplyFences(AppSettings.CloneFences(snap.Fences)); + + public static List ApplyFences(List fences) + { + var before = AppSettings.CloneFences(S.Fences); + CloseAll(); + S.Fences = fences; + S.FencesEnabled = true; + S.Save(); + Sync(); + return before; + } +} diff --git a/FenceWindow.xaml b/FenceWindow.xaml index 3c3a4ac..4051720 100644 --- a/FenceWindow.xaml +++ b/FenceWindow.xaml @@ -1,29 +1,37 @@ - + - - - - + + + 78 + 80 + 32 + + + + + + - + - - + + + + + + + + - - + + + + + + + + + + diff --git a/FenceWindow.xaml.cs b/FenceWindow.xaml.cs index 1805332..3de280a 100644 --- a/FenceWindow.xaml.cs +++ b/FenceWindow.xaml.cs @@ -1,346 +1,634 @@ -using System.Diagnostics; -using System.IO; -using System.Runtime.InteropServices; -using System.Windows; -using System.Windows.Controls; -using System.Windows.Input; -using System.Windows.Interop; -using System.Windows.Media; -using System.Windows.Threading; - -namespace DeskCore; - -/// Eine "Box" auf dem Desktop, die den Inhalt einer Kategorie zeigt. -public partial class FenceWindow : Window -{ - private const double CollapsedHeight = 38; - private readonly DispatcherTimer _saveTimer; - private bool _applying; - - public string Category { get; private set; } - - public FenceWindow(string category) - { - InitializeComponent(); - Category = category; - _saveTimer = new DispatcherTimer(TimeSpan.FromMilliseconds(700), DispatcherPriority.Background, (_, _) => - { - _saveTimer!.Stop(); - AppSettings.Current.Save(); - }, Dispatcher); - _saveTimer.Stop(); - - ApplyBounds(); - LocationChanged += (_, _) => RememberBounds(); - SizeChanged += (_, _) => RememberBounds(); - SourceInitialized += (_, _) => PinToDesktop(); - Refresh(); - } - - private CategoryMeta Meta => AppSettings.Current.Meta(Category); - - public void Rename(string newName) - { - Category = newName; - Refresh(); - } - - // ---------- Position/Größe ---------- - - private void ApplyBounds() - { - _applying = true; - var m = Meta; - Width = Math.Max(MinWidth, m.FenceW); - Height = m.FenceCollapsed ? CollapsedHeight : Math.Max(120, m.FenceH); - var wa = Screens.PrimaryWorkArea; - double left = m.FenceX ?? wa.Right - Width - 24, top = m.FenceY ?? wa.Top + 24; - // Bildschirm abgesteckt? → zurück auf den Hauptbildschirm (oben rechts) - if (!Screens.IsReachable(new Rect(left, top, Width, Height))) - { - left = wa.Right - Width - 24; - top = wa.Top + 24; - m.FenceX = left; - m.FenceY = top; - } - Left = left; - Top = top; - Body.Visibility = m.FenceCollapsed ? Visibility.Collapsed : Visibility.Visible; - _applying = false; - } - - /// Nach Bildschirm-Änderungen: Box wieder sichtbar machen. - public void EnsureOnScreen() - { - if (Screens.IsReachable(new Rect(Left, Top, ActualWidth, ActualHeight))) return; - Meta.FenceX = null; - Meta.FenceY = null; - ApplyBounds(); - AppSettings.Current.Save(); - } - - private void RememberBounds() - { - if (_applying || !IsLoaded) return; - var m = Meta; - m.FenceX = Left; - m.FenceY = Top; - m.FenceW = Width; - if (!m.FenceCollapsed) m.FenceH = Height; - _saveTimer.Stop(); - _saveTimer.Start(); - } - - private void ToggleCollapsed() - { - Meta.FenceCollapsed = !Meta.FenceCollapsed; - ApplyBounds(); - AppSettings.Current.Save(); - } - - // ---------- Inhalt ---------- - - public void Refresh() - { - var meta = Meta; - TitleText.Text = Category; - GlyphText.Text = meta.Glyph ?? ""; - GlyphText.Foreground = CategoryColors.Brush(meta.Color) ?? (Brush)FindResource("TextBrush"); - - var items = Storage.Entries(Storage.CategoryPath(Category)) - .Select(p => new IconItem { Path = p }) - .OrderBy(i => !Directory.Exists(i.Path)).ThenBy(i => i.Name, StringComparer.CurrentCultureIgnoreCase) - .ToList(); - int px = (int)Math.Round(32 * VisualTreeHelper.GetDpi(this).DpiScaleX); - foreach (var i in items) IconLoader.Load(i, px); - Items.ItemsSource = items; - CountText.Text = items.Count.ToString(); - EmptyText.Visibility = items.Count == 0 ? Visibility.Visible : Visibility.Collapsed; - } - - private List Selected => Items.SelectedItems.Cast().ToList(); - - private static void Open(string path) - { - try { Process.Start(new ProcessStartInfo(path) { UseShellExecute = true }); } - catch { } - } - - private void Items_MouseDoubleClick(object sender, MouseButtonEventArgs e) - { - if (MainWindow.FindParent(e.OriginalSource as DependencyObject)?.DataContext is IconItem i) Open(i.Path); - } - - private void Items_KeyDown(object sender, KeyEventArgs e) - { - var sel = Selected; - if (sel.Count == 0 || App.Win is not { } win) return; - switch (e.Key) - { - case Key.Enter: sel.ForEach(i => Open(i.Path)); e.Handled = true; break; - case Key.Delete: win.DeleteIcons(sel); e.Handled = true; break; - case Key.F2 when sel.Count == 1: win.RenameIcon(sel[0]); e.Handled = true; break; - } - } - - private void Items_ContextMenuOpening(object sender, ContextMenuEventArgs e) - { - var sel = Selected; - if (sel.Count == 0 || App.Win is not { } win) { e.Handled = true; return; } - ItemMenu.Items.Clear(); - ItemMenu.Items.Add(MainWindow.MenuItem("Öffnen", "\uE8A7", () => sel.ForEach(i => Open(i.Path)), bold: true, gesture: "Enter")); - if (sel.Count == 1) - { - ItemMenu.Items.Add(MainWindow.MenuItem("Dateispeicherort öffnen", "\uED25", () => - Process.Start("explorer.exe", $"/select,\"{sel[0].Path}\""))); - ItemMenu.Items.Add(MainWindow.MenuItem("Umbenennen", "\uE70F", () => win.RenameIcon(sel[0]), gesture: "F2")); - } - ItemMenu.Items.Add(new Separator()); - - var moveTo = MainWindow.MenuItem("Verschieben nach", "\uE8DE", null); - foreach (var c in Storage.Categories().Where(c => !string.Equals(c, Category, StringComparison.OrdinalIgnoreCase))) - moveTo.Items.Add(MainWindow.MenuItem(c, AppSettings.Current.Categories.GetValueOrDefault(c)?.Glyph ?? "\uF12B", - () => win.TransferAll(sel.Select(i => i.Path), Storage.CategoryPath(c)))); - if (moveTo.Items.Count > 0) ItemMenu.Items.Add(moveTo); - ItemMenu.Items.Add(MainWindow.MenuItem("Zurück auf den Desktop legen", "\uE7F4", - () => win.TransferAll(sel.Select(i => i.Path), Storage.UserDesktop))); - ItemMenu.Items.Add(MainWindow.MenuItem("Löschen", "\uE74D", () => win.DeleteIcons(sel), gesture: "Entf")); - ItemMenu.Items.Add(new Separator()); - ItemMenu.Items.Add(MainWindow.MenuItem("In DeskCore zeigen", "\uE8A7", ShowInApp)); - } - - private void MenuButton_Click(object sender, RoutedEventArgs e) - { - FenceMenu.Items.Clear(); - FenceMenu.Items.Add(MainWindow.MenuItem("In DeskCore öffnen", "", ShowInApp)); - FenceMenu.Items.Add(MainWindow.MenuItem(Meta.FenceCollapsed ? "Ausklappen" : "Einklappen", - Meta.FenceCollapsed ? "" : "", ToggleCollapsed)); - FenceMenu.Items.Add(new Separator()); - FenceMenu.Items.Add(MainWindow.MenuItem("Box ausblenden", "", () => - { - Meta.Fence = false; - AppSettings.Current.Save(); - FenceManager.Sync(); - })); - FenceMenu.PlacementTarget = MenuButton; - FenceMenu.IsOpen = true; - } - - private void ShowInApp() => App.Win?.ShowCategory(Category); - - private Point _dragStart; - private bool _dragging; - - private void Items_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) => _dragStart = e.GetPosition(null); - - private void Items_PreviewMouseMove(object sender, MouseEventArgs e) - { - if (e.LeftButton != MouseButtonState.Pressed || _dragging) return; - var d = e.GetPosition(null) - _dragStart; - if (Math.Abs(d.X) < SystemParameters.MinimumHorizontalDragDistance && - Math.Abs(d.Y) < SystemParameters.MinimumVerticalDragDistance) return; - if (MainWindow.FindParent(e.OriginalSource as DependencyObject) is null) return; - var paths = Selected.Select(i => i.Path).ToArray(); - if (paths.Length == 0) return; - _dragging = true; - try - { - // nur "Kopieren" erlauben: der Explorer soll die Datei nicht aus der Kategorie entfernen. - // In eine andere Box/Kategorie gezogen, verschiebt DeskCore selbst. - DragDrop.DoDragDrop(Items, new DataObject(DataFormats.FileDrop, paths), DragDropEffects.Copy); - } - finally { _dragging = false; } - } - - private void Items_DragOver(object sender, DragEventArgs e) - { - e.Effects = e.Data.GetDataPresent(DataFormats.FileDrop) && !_dragging - ? (e.AllowedEffects.HasFlag(DragDropEffects.Copy) ? DragDropEffects.Copy : DragDropEffects.Move) - : DragDropEffects.None; - e.Handled = true; - } - - private void Items_Drop(object sender, DragEventArgs e) - { - if (!_dragging && e.Data.GetData(DataFormats.FileDrop) is string[] files) - App.Win?.TransferAll(files, Storage.CategoryPath(Category)); - } - - // ---------- Auf dem Desktop festhalten ---------- - - private void PinToDesktop() - { - var hwnd = new WindowInteropHelper(this).Handle; - // Besitzer = Desktop (Progman): bleibt bei Win+D sichtbar und liegt über den Desktop-Icons - var progman = FindWindow("Progman", null); - if (progman != IntPtr.Zero) SetWindowLongPtr(hwnd, GWLP_HWNDPARENT, progman); - // Nicht in Alt+Tab - var ex = GetWindowLongPtr(hwnd, GWL_EXSTYLE).ToInt64(); - SetWindowLongPtr(hwnd, GWL_EXSTYLE, new IntPtr((ex | WS_EX_TOOLWINDOW) & ~WS_EX_APPWINDOW)); - HwndSource.FromHwnd(hwnd).AddHook(Hook); - SetWindowPos(hwnd, HWND_BOTTOM, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE); - } - - private IntPtr Hook(IntPtr hwnd, int msg, IntPtr wParam, IntPtr lParam, ref bool handled) - { - switch (msg) - { - case WM_WINDOWPOSCHANGING: - // immer ganz unten bleiben, nie über anderen Fenstern - var wp = Marshal.PtrToStructure(lParam); - wp.hwndInsertAfter = HWND_BOTTOM; - wp.flags &= ~SWP_NOZORDER; - Marshal.StructureToPtr(wp, lParam, false); - break; - case WM_NCLBUTTONDBLCLK: - ToggleCollapsed(); - handled = true; - break; - case WM_SYSCOMMAND when (wParam.ToInt32() & 0xFFF0) == SC_MAXIMIZE: - handled = true; - break; - } - return IntPtr.Zero; - } - - private const int GWL_EXSTYLE = -20, GWLP_HWNDPARENT = -8; - private const long WS_EX_TOOLWINDOW = 0x80, WS_EX_APPWINDOW = 0x40000; - private const int WM_WINDOWPOSCHANGING = 0x46, WM_NCLBUTTONDBLCLK = 0xA3, WM_SYSCOMMAND = 0x112, SC_MAXIMIZE = 0xF030; - private const uint SWP_NOSIZE = 0x1, SWP_NOMOVE = 0x2, SWP_NOZORDER = 0x4, SWP_NOACTIVATE = 0x10; - private static readonly IntPtr HWND_BOTTOM = new(1); - - [StructLayout(LayoutKind.Sequential)] - private struct WINDOWPOS - { - public IntPtr hwnd, hwndInsertAfter; - public int x, y, cx, cy; - public uint flags; - } - - [DllImport("user32.dll", CharSet = CharSet.Unicode)] private static extern IntPtr FindWindow(string? cls, string? title); - [DllImport("user32.dll")] private static extern IntPtr GetWindowLongPtr(IntPtr h, int i); - [DllImport("user32.dll")] private static extern IntPtr SetWindowLongPtr(IntPtr h, int i, IntPtr v); - [DllImport("user32.dll")] private static extern bool SetWindowPos(IntPtr h, IntPtr after, int x, int y, int cx, int cy, uint flags); -} - -/// Hält die Desktop-Boxen passend zu den Einstellungen. -public static class FenceManager -{ - private static readonly List _open = []; - - public static void Sync() - { - var s = AppSettings.Current; - var wanted = s.FencesEnabled - ? Storage.Categories().Where(c => s.Categories.GetValueOrDefault(c)?.Fence == true).ToList() - : []; - - foreach (var f in _open.Where(f => !wanted.Contains(f.Category, StringComparer.OrdinalIgnoreCase)).ToList()) - { - f.Close(); - _open.Remove(f); - } - - var wa = SystemParameters.WorkArea; - foreach (var c in wanted) - { - var f = _open.FirstOrDefault(x => string.Equals(x.Category, c, StringComparison.OrdinalIgnoreCase)); - if (f is not null) { f.Refresh(); continue; } - - // Neue Box: neben die vorhandenen setzen - var meta = s.Meta(c); - if (meta.FenceX is null) - { - // oben rechts beginnen – links liegen meist die Desktop-Icons - int perRow = Math.Max(1, (int)((wa.Width - 48) / (meta.FenceW + 16))); - int i = _open.Count; - meta.FenceX = wa.Right - 24 - meta.FenceW - (i % perRow) * (meta.FenceW + 16); - meta.FenceY = wa.Top + 24 + (i / perRow) * (meta.FenceH + 16); - } - f = new FenceWindow(c); - _open.Add(f); - f.Show(); - } - } - - public static void RefreshAll() - { - foreach (var f in _open) f.Refresh(); - } - - public static void EnsureOnScreen() - { - foreach (var f in _open) f.EnsureOnScreen(); - } - - public static void Renamed(string oldName, string newName) - { - _open.FirstOrDefault(x => string.Equals(x.Category, oldName, StringComparison.OrdinalIgnoreCase))?.Rename(newName); - } - - public static void CloseAll() - { - foreach (var f in _open) f.Close(); - _open.Clear(); - } -} +using System.Diagnostics; +using System.IO; +using System.Runtime.InteropServices; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Input; +using System.Windows.Interop; +using System.Windows.Media; +using System.Windows.Media.Animation; +using System.Windows.Shell; +using System.Windows.Threading; + +namespace DeskCore; + +/// +/// Eine Box auf dem Desktop: zeigt eine oder mehrere Kategorien bzw. Ordner (Tabs), +/// mit eigenem Aussehen, Einklappen, Sperren, Chamäleon und Peek. +/// +public partial class FenceWindow : Window +{ + private const double CollapsedHeight = 38; + private readonly DispatcherTimer _saveTimer; + private readonly DispatcherTimer _collapseTimer; + private FileSystemWatcher? _portalWatcher; + private DispatcherTimer? _portalRefresh; + private bool _applying; + private bool _hoverExpanded; + private bool _mouseInside; + /// Unterordner, in den im Ordner-Portal navigiert wurde (null = Wurzel). + private string? _portalSub; + + public FenceDef Def { get; } + private static AppSettings S => AppSettings.Current; + + public FenceWindow(FenceDef def) + { + InitializeComponent(); + Def = def; + _saveTimer = new DispatcherTimer(TimeSpan.FromMilliseconds(700), DispatcherPriority.Background, (_, _) => + { + _saveTimer!.Stop(); + S.Save(); + }, Dispatcher); + _saveTimer.Stop(); + _collapseTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(500) }; + _collapseTimer.Tick += (_, _) => + { + _collapseTimer.Stop(); + if (!_mouseInside && _hoverExpanded && !ItemMenu.IsOpen && !FenceMenu.IsOpen) CollapseAfterHover(); + }; + + ApplyBounds(); + LocationChanged += (_, _) => RememberBounds(); + SizeChanged += (_, _) => RememberBounds(); + SourceInitialized += (_, _) => PinToDesktop(); + MouseEnter += (_, _) => OnHover(true); + MouseLeave += (_, _) => OnHover(false); + Closed += (_, _) => _portalWatcher?.Dispose(); + Refresh(); + } + + // ===================================================================== + // Position, Größe, Einklappen + // ===================================================================== + + private void ApplyBounds() + { + _applying = true; + Width = Math.Max(MinWidth, Def.W); + Height = Def.Collapsed && !_hoverExpanded ? CollapsedHeight : Math.Max(120, Def.H); + var wa = Screens.PrimaryWorkArea; + double left = Def.X ?? wa.Right - Width - 24, top = Def.Y ?? wa.Top + 24; + // Bildschirm abgesteckt? → zurück auf den Hauptbildschirm (oben rechts) + if (!Screens.IsReachable(new Rect(left, top, Width, Height))) + { + left = wa.Right - Width - 24; + top = wa.Top + 24; + Def.X = left; + Def.Y = top; + } + Left = left; + Top = top; + Body.Visibility = Def.Collapsed && !_hoverExpanded ? Visibility.Collapsed : Visibility.Visible; + _applying = false; + } + + /// Position/Größe aus den Einstellungen neu übernehmen (z. B. nach Snapshot). + public void Reapply() + { + ApplyBounds(); + ApplyLook(); + Refresh(); + } + + public void EnsureOnScreen() + { + if (Screens.IsReachable(new Rect(Left, Top, ActualWidth, ActualHeight))) return; + Def.X = null; + Def.Y = null; + ApplyBounds(); + S.Save(); + } + + private void RememberBounds() + { + if (_applying || !IsLoaded || _hoverExpanded) return; + Def.X = Left; + Def.Y = Top; + Def.W = Width; + if (!Def.Collapsed) Def.H = Height; + _saveTimer.Stop(); + _saveTimer.Start(); + } + + private void ToggleCollapsed() + { + _hoverExpanded = false; + Def.Collapsed = !Def.Collapsed; + ApplyBounds(); + S.Save(); + } + + /// Eingeklappte Box klappt beim Überfahren kurz auf; Chamäleon blendet ein/aus. + private void OnHover(bool inside) + { + _mouseInside = inside; + if (inside) + { + _collapseTimer.Stop(); + if (Def.Collapsed && !_hoverExpanded) + { + _hoverExpanded = true; + ApplyBounds(); + } + } + else if (_hoverExpanded) _collapseTimer.Start(); + ApplyChameleon(); + } + + private void CollapseAfterHover() + { + _hoverExpanded = false; + ApplyBounds(); + } + + // ===================================================================== + // Aussehen + // ===================================================================== + + public void ApplyLook() + { + // Hintergrund: eigene Farbe oder Design-Farbe, mit eingestellter Deckkraft + var baseColor = Def.Background is { } hex && CategoryColors.Brush(hex) is SolidColorBrush custom + ? custom.Color + : ((SolidColorBrush)FindResource("FenceBrush")).Color; + double opacity = Math.Clamp(Def.Opacity, 0.2, 1); + if (Def.Blur) opacity = Math.Min(opacity, 0.75); // sonst sieht man vom Milchglas nichts + var bg = new SolidColorBrush(Color.FromArgb((byte)(opacity * 255), baseColor.R, baseColor.G, baseColor.B)); + bg.Freeze(); + Root.Background = bg; + Root.CornerRadius = new CornerRadius(Def.Blur ? 0 : 8); // Milchglas wirkt nur eckig sauber + + // Icon-Größe + var (w, h, icon) = Def.IconSize switch + { + ViewMode.Small => (64.0, 66.0, 24.0), + ViewMode.Large => (98.0, 104.0, 48.0), + _ => (78.0, 80.0, 32.0), + }; + Resources["FTileW"] = w; + Resources["FTileH"] = h; + Resources["FIcon"] = icon; + Resources["FTint"] = CategoryColors.Brush(Def.Tint) ?? Brushes.Transparent; + + // Sperren: nicht verschieb-/vergrößerbar + Chrome.CaptionHeight = Def.Locked ? 0 : 36; + Chrome.ResizeBorderThickness = Def.Locked ? new Thickness(0) : new Thickness(6); + LockGlyph.Visibility = Def.Locked ? Visibility.Visible : Visibility.Collapsed; + + if (IsLoaded) SetBlur(Def.Blur); + ApplyChameleon(); + } + + /// Chamäleon: Box ist fast unsichtbar, bis die Maus darüberfährt (nicht während Peek). + private void ApplyChameleon() + { + double target = S.FenceChameleon && !_mouseInside && !FenceManager.Peeking ? 0.08 : 1; + Root.BeginAnimation(OpacityProperty, new DoubleAnimation(target, TimeSpan.FromMilliseconds(target < 1 ? 600 : 150))); + } + + // ===================================================================== + // Inhalt & Tabs + // ===================================================================== + + private FenceTab? Tab => Def.Active; + + /// Ordner des aktiven Tabs (bei Ordner-Portalen ggf. ein Unterordner). + private string? CurrentFolder => Tab switch + { + null => null, + { IsFolder: true } t => _portalSub ?? t.Value, + { } t => Storage.CategoryPath(t.Value), + }; + + public void Refresh() + { + ApplyLook(); + BuildTabs(); + + var tab = Tab; + var folder = CurrentFolder; + bool portal = tab?.IsFolder == true; + PortalBar.Visibility = portal && _portalSub is not null ? Visibility.Visible : Visibility.Collapsed; + if (portal && _portalSub is not null) PortalPath.Text = Path.GetRelativePath(tab!.Value, _portalSub); + WatchPortal(portal ? folder : null); + + List items = []; + if (folder is not null && Directory.Exists(folder)) + items = (portal ? PortalEntries(folder) : Storage.Entries(folder)).Select(p => new IconItem { Path = p }).ToList(); + items = Sort(items); + + int px = (int)Math.Round((double)Resources["FIcon"] * VisualTreeHelper.GetDpi(this).DpiScaleX); + foreach (var i in items) IconLoader.Load(i, px); + Items.ItemsSource = items; + CountText.Text = items.Count.ToString(); + EmptyText.Text = folder is not null && !Directory.Exists(folder) ? "Ordner nicht gefunden" : "Leer – Icons hierher ziehen"; + EmptyText.Visibility = items.Count == 0 ? Visibility.Visible : Visibility.Collapsed; + } + + /// Ordner-Portal zeigt alles außer versteckten/System-Dateien. + private static IEnumerable PortalEntries(string dir) => Storage.Entries(dir); + + private List Sort(List items) + { + var folders = items.Where(i => Directory.Exists(i.Path)); + var files = items.Where(i => !Directory.Exists(i.Path)); + IEnumerable Order(IEnumerable x) => Def.Sort switch + { + "Typ" => x.OrderBy(i => Path.GetExtension(i.Path), StringComparer.OrdinalIgnoreCase).ThenBy(i => i.Name, StringComparer.CurrentCultureIgnoreCase), + "Datum" => x.OrderByDescending(i => { try { return File.GetLastWriteTime(i.Path); } catch { return DateTime.MinValue; } }), + _ => x.OrderBy(i => i.Name, StringComparer.CurrentCultureIgnoreCase), + }; + return Order(folders).Concat(Order(files)).ToList(); + } + + private void BuildTabs() + { + TabStrip.Children.Clear(); + for (int i = 0; i < Def.Tabs.Count; i++) + { + var t = Def.Tabs[i]; + bool active = i == Math.Clamp(Def.ActiveTab, 0, Def.Tabs.Count - 1); + var meta = t.IsFolder ? null : S.Categories.GetValueOrDefault(t.Value); + var glyph = new TextBlock + { + Text = t.IsFolder ? "" : meta?.Glyph ?? "", + FontFamily = (FontFamily)FindResource("IconFont"), FontSize = 13, VerticalAlignment = VerticalAlignment.Center, + Margin = new Thickness(0, 0, 7, 0), + Foreground = CategoryColors.Brush(meta?.Color) ?? (Brush)FindResource(t.IsFolder ? "AccentBrush" : "TextBrush"), + }; + var label = new TextBlock + { + Text = t.Title, VerticalAlignment = VerticalAlignment.Center, TextTrimming = TextTrimming.CharacterEllipsis, + FontSize = 13, MaxWidth = Def.Tabs.Count == 1 ? 220 : 120, + FontWeight = active ? FontWeights.SemiBold : FontWeights.Normal, + Foreground = (Brush)FindResource(active ? "TextBrush" : "TextSecondaryBrush"), + }; + var content = new StackPanel { Orientation = Orientation.Horizontal, Children = { glyph, label } }; + var underline = new Border + { + Height = 2, CornerRadius = new CornerRadius(1), Margin = new Thickness(4, 0, 4, 0), VerticalAlignment = VerticalAlignment.Bottom, + Background = active && Def.Tabs.Count > 1 ? (Brush)FindResource("AccentBrush") : Brushes.Transparent, + }; + var btn = new Button + { + Style = (Style)FindResource("SubtleButton"), Padding = new Thickness(6, 4, 6, 4), MinHeight = 0, Tag = i, + Content = new Grid { Children = { content, underline } }, Margin = new Thickness(0, 0, 2, 0), + ToolTip = t.IsFolder ? t.Value : $"Kategorie „{t.Value}“", + }; + WindowChrome.SetIsHitTestVisibleInChrome(btn, Def.Tabs.Count > 1); // bei nur einem Tab: Titel = Ziehfläche + btn.Click += (_, _) => SwitchTab((int)btn.Tag); + btn.MouseRightButtonUp += (_, ev) => { ShowTabMenu((int)btn.Tag); ev.Handled = true; }; + TabStrip.Children.Add(btn); + } + } + + private void SwitchTab(int index) + { + if (index == Def.ActiveTab) return; + Def.ActiveTab = index; + _portalSub = null; + S.Save(); + Refresh(); + } + + // ---------- Ordner-Portal ---------- + + private void WatchPortal(string? folder) + { + if (string.Equals(_portalWatcher?.Path, folder, StringComparison.OrdinalIgnoreCase)) return; + _portalWatcher?.Dispose(); + _portalWatcher = null; + if (folder is null || !Directory.Exists(folder)) return; + _portalRefresh ??= new DispatcherTimer(TimeSpan.FromMilliseconds(400), DispatcherPriority.Background, (_, _) => + { + _portalRefresh!.Stop(); + Refresh(); + }, Dispatcher); + try + { + _portalWatcher = new FileSystemWatcher(folder) + { + NotifyFilter = NotifyFilters.FileName | NotifyFilters.DirectoryName | NotifyFilters.LastWrite, + EnableRaisingEvents = true, + }; + FileSystemEventHandler poke = (_, _) => Dispatcher.BeginInvoke(() => { _portalRefresh.Stop(); _portalRefresh.Start(); }); + _portalWatcher.Created += poke; + _portalWatcher.Deleted += poke; + _portalWatcher.Renamed += (s, e) => poke(s, e); + } + catch { } + } + + private void PortalUp_Click(object sender, RoutedEventArgs e) + { + if (Tab is not { IsFolder: true } t || _portalSub is null) return; + var parent = Path.GetDirectoryName(_portalSub.TrimEnd('\\')); + _portalSub = parent is null || Storage.SamePath(parent, t.Value) || !Storage.IsUnder(parent, t.Value) ? null : parent; + Refresh(); + } + + // ===================================================================== + // Icons + // ===================================================================== + + private List Selected => Items.SelectedItems.Cast().ToList(); + + private void Open(IconItem i) + { + // Im Ordner-Portal öffnen Ordner sich innerhalb der Box + if (Tab?.IsFolder == true && Directory.Exists(i.Path) && !i.Path.EndsWith(".lnk", StringComparison.OrdinalIgnoreCase)) + { + _portalSub = i.Path; + Refresh(); + return; + } + try { Process.Start(new ProcessStartInfo(i.Path) { UseShellExecute = true }); } + catch { } + } + + private void Items_MouseDoubleClick(object sender, MouseButtonEventArgs e) + { + if (MainWindow.FindParent(e.OriginalSource as DependencyObject)?.DataContext is IconItem i) Open(i); + } + + private void Items_KeyDown(object sender, KeyEventArgs e) + { + if (e.Key == Key.Escape && FenceManager.Peeking) { FenceManager.EndPeek(); e.Handled = true; return; } + var sel = Selected; + if (sel.Count == 0 || App.Win is not { } win) return; + switch (e.Key) + { + case Key.Enter: sel.ForEach(Open); e.Handled = true; break; + case Key.Delete: win.DeleteIcons(sel); e.Handled = true; break; + case Key.F2 when sel.Count == 1: win.RenameIcon(sel[0]); e.Handled = true; break; + case Key.Back when _portalSub is not null: PortalUp_Click(this, e); e.Handled = true; break; + } + } + + private void Items_ContextMenuOpening(object sender, ContextMenuEventArgs e) + { + var sel = Selected; + if (sel.Count == 0 || App.Win is not { } win) { e.Handled = true; return; } + ItemMenu.Items.Clear(); + ItemMenu.Items.Add(MainWindow.MenuItem("Öffnen", "", () => sel.ForEach(Open), bold: true, gesture: "Enter")); + if (sel.Count == 1) + { + ItemMenu.Items.Add(MainWindow.MenuItem("Dateispeicherort öffnen", "", () => + Process.Start("explorer.exe", $"/select,\"{sel[0].Path}\""))); + ItemMenu.Items.Add(MainWindow.MenuItem("Umbenennen", "", () => win.RenameIcon(sel[0]), gesture: "F2")); + } + ItemMenu.Items.Add(new Separator()); + + bool inStorage = sel.All(i => Storage.IsInStorage(i.Path)); + var moveTo = MainWindow.MenuItem(inStorage ? "Verschieben nach" : "Sichern in", inStorage ? "" : "", null); + var ownCategory = Tab is { IsFolder: false } t ? t.Value : null; + foreach (var c in Storage.Categories().Where(c => !string.Equals(c, ownCategory, StringComparison.OrdinalIgnoreCase))) + moveTo.Items.Add(MainWindow.MenuItem(c, S.Categories.GetValueOrDefault(c)?.Glyph ?? "", + () => win.TransferAll(sel.Select(i => i.Path), Storage.CategoryPath(c)))); + if (moveTo.Items.Count > 0) ItemMenu.Items.Add(moveTo); + ItemMenu.Items.Add(win.LaunchGroupSubmenu(sel.Select(i => i.Path).ToList())); + if (inStorage) + ItemMenu.Items.Add(MainWindow.MenuItem("Zurück auf den Desktop legen", "", + () => win.TransferAll(sel.Select(i => i.Path), Storage.UserDesktop))); + ItemMenu.Items.Add(MainWindow.MenuItem("Löschen", "", () => win.DeleteIcons(sel), gesture: "Entf")); + } + + // ---------- Ziehen ---------- + + private Point _dragStart; + private bool _dragging; + + private void Items_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) => _dragStart = e.GetPosition(null); + + private void Items_PreviewMouseMove(object sender, MouseEventArgs e) + { + if (e.LeftButton != MouseButtonState.Pressed || _dragging) return; + var d = e.GetPosition(null) - _dragStart; + if (Math.Abs(d.X) < SystemParameters.MinimumHorizontalDragDistance && + Math.Abs(d.Y) < SystemParameters.MinimumVerticalDragDistance) return; + if (MainWindow.FindParent(e.OriginalSource as DependencyObject) is null) return; + var paths = Selected.Select(i => i.Path).ToArray(); + if (paths.Length == 0) return; + _dragging = true; + try + { + // nur "Kopieren" erlauben: der Explorer soll die Datei nicht aus der Kategorie entfernen. + // In eine andere Box/Kategorie gezogen, verschiebt DeskCore selbst. + DragDrop.DoDragDrop(Items, new DataObject(DataFormats.FileDrop, paths), DragDropEffects.Copy | DragDropEffects.Link); + } + finally { _dragging = false; } + } + + private void Items_DragOver(object sender, DragEventArgs e) + { + e.Effects = e.Data.GetDataPresent(DataFormats.FileDrop) && !_dragging && CurrentFolder is not null + ? (e.AllowedEffects.HasFlag(DragDropEffects.Copy) ? DragDropEffects.Copy : DragDropEffects.Move) + : DragDropEffects.None; + e.Handled = true; + } + + private void Items_Drop(object sender, DragEventArgs e) + { + if (!_dragging && CurrentFolder is { } folder && e.Data.GetData(DataFormats.FileDrop) is string[] files) + App.Win?.TransferAll(files, folder); + } + + // ===================================================================== + // Menüs + // ===================================================================== + + private void TitleBar_MouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + // gesperrte Box: Titelleiste ist normale Fläche → Doppelklick selbst auswerten + if (e.ClickCount == 2) { ToggleCollapsed(); e.Handled = true; } + } + + private void MenuButton_Click(object sender, RoutedEventArgs e) + { + FenceMenu.Items.Clear(); + if (Tab is { IsFolder: false } t) + FenceMenu.Items.Add(MainWindow.MenuItem("In DeskCore öffnen", "", () => App.Win?.ShowCategory(t.Value))); + else if (Tab is { IsFolder: true } pt) + FenceMenu.Items.Add(MainWindow.MenuItem("Im Explorer öffnen", "", () => Process.Start("explorer.exe", $"\"{CurrentFolder ?? pt.Value}\""))); + + // Tabs + var add = MainWindow.MenuItem("Tab hinzufügen", "", null); + foreach (var c in Storage.Categories().Where(c => !Def.Tabs.Any(x => !x.IsFolder && string.Equals(x.Value, c, StringComparison.OrdinalIgnoreCase)))) + add.Items.Add(MainWindow.MenuItem(c, S.Categories.GetValueOrDefault(c)?.Glyph ?? "", () => AddTab(new FenceTab { Value = c }))); + if (add.Items.Count > 0) add.Items.Add(new Separator()); + add.Items.Add(MainWindow.MenuItem("Ordner-Portal…", "", () => + { + if (FenceManager.PickFolder() is { } folder) AddTab(new FenceTab { Kind = FenceTab.FolderKind, Value = folder }); + })); + FenceMenu.Items.Add(add); + if (Def.Tabs.Count > 1) + FenceMenu.Items.Add(MainWindow.MenuItem($"Tab „{Tab?.Title}“ entfernen", "", () => RemoveTab(Def.ActiveTab))); + + FenceMenu.Items.Add(new Separator()); + FenceMenu.Items.Add(MainWindow.MenuItem("Aussehen…", "", () => FenceManager.EditLook(this))); + FenceMenu.Items.Add(MainWindow.MenuItem(Def.Collapsed ? "Ausklappen" : "Einklappen", + Def.Collapsed ? "" : "", ToggleCollapsed)); + FenceMenu.Items.Add(MainWindow.MenuItem(Def.Locked ? "Entsperren" : "Sperren (nicht mehr verschiebbar)", + Def.Locked ? "" : "", () => { Def.Locked = !Def.Locked; S.Save(); ApplyLook(); })); + if (S.Pages.Count > 1) + { + var move = MainWindow.MenuItem("Auf Seite verschieben", "\uE8AB", null); + for (int p = 0; p < S.Pages.Count; p++) + { + int page = p; + var mi = MainWindow.MenuItem(S.Pages[p], "\uE8A1", () => FenceManager.MoveToPage(Def, page)); + mi.IsEnabled = page != Def.Page; + move.Items.Add(mi); + } + FenceMenu.Items.Add(move); + } + FenceMenu.Items.Add(new Separator()); + FenceMenu.Items.Add(MainWindow.MenuItem("Box schließen", "", () => FenceManager.Remove(Def))); + FenceMenu.PlacementTarget = MenuButton; + FenceMenu.IsOpen = true; + } + + private void ShowTabMenu(int index) + { + if (index < 0 || index >= Def.Tabs.Count) return; + var t = Def.Tabs[index]; + var menu = new ContextMenu(); + if (!t.IsFolder) menu.Items.Add(MainWindow.MenuItem("In DeskCore öffnen", "", () => App.Win?.ShowCategory(t.Value))); + else menu.Items.Add(MainWindow.MenuItem("Im Explorer öffnen", "", () => Process.Start("explorer.exe", $"\"{t.Value}\""))); + if (Def.Tabs.Count > 1) + { + menu.Items.Add(MainWindow.MenuItem("Als eigene Box lösen", "", () => FenceManager.DetachTab(Def, index))); + menu.Items.Add(MainWindow.MenuItem("Tab entfernen", "", () => RemoveTab(index))); + } + menu.IsOpen = true; + } + + private void AddTab(FenceTab tab) + { + // Kategorie steckt schon in einer anderen Box? Dann dorthin verschieben statt doppelt anzeigen + if (!tab.IsFolder) FenceManager.RemoveCategoryTab(tab.Value, except: Def); + Def.Tabs.Add(tab); + Def.ActiveTab = Def.Tabs.Count - 1; + _portalSub = null; + S.Save(); + FenceManager.Sync(); + Refresh(); + } + + private void RemoveTab(int index) + { + if (index < 0 || index >= Def.Tabs.Count) return; + Def.Tabs.RemoveAt(index); + Def.ActiveTab = Math.Clamp(Def.ActiveTab, 0, Math.Max(0, Def.Tabs.Count - 1)); + _portalSub = null; + S.Save(); + if (Def.Tabs.Count == 0) FenceManager.Remove(Def); + else Refresh(); + } + + // ===================================================================== + // Auf dem Desktop festhalten, Peek, Milchglas + // ===================================================================== + + private IntPtr Hwnd => new WindowInteropHelper(this).Handle; + + private void PinToDesktop() + { + var hwnd = Hwnd; + // Besitzer = Desktop (Progman): bleibt bei Win+D sichtbar und liegt über den Desktop-Icons + var progman = FindWindow("Progman", null); + if (progman != IntPtr.Zero) SetWindowLongPtr(hwnd, GWLP_HWNDPARENT, progman); + // Nicht in Alt+Tab + var ex = GetWindowLongPtr(hwnd, GWL_EXSTYLE).ToInt64(); + SetWindowLongPtr(hwnd, GWL_EXSTYLE, new IntPtr((ex | WS_EX_TOOLWINDOW) & ~WS_EX_APPWINDOW)); + HwndSource.FromHwnd(hwnd).AddHook(Hook); + SetWindowPos(hwnd, HWND_BOTTOM, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE); + SetBlur(Def.Blur); + } + + /// Peek: Box vor alle Fenster holen bzw. wieder ganz nach hinten. + public void SetPeek(bool on) + { + var hwnd = Hwnd; + if (hwnd == IntPtr.Zero) return; + SetWindowPos(hwnd, on ? HWND_TOPMOST : HWND_NOTOPMOST, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE); + if (!on) SetWindowPos(hwnd, HWND_BOTTOM, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE); + ApplyChameleon(); + } + + private IntPtr Hook(IntPtr hwnd, int msg, IntPtr wParam, IntPtr lParam, ref bool handled) + { + switch (msg) + { + case WM_WINDOWPOSCHANGING when !FenceManager.Peeking: + // immer ganz unten bleiben, nie über anderen Fenstern (außer während Peek) + var wp = Marshal.PtrToStructure(lParam); + wp.hwndInsertAfter = HWND_BOTTOM; + wp.flags &= ~SWP_NOZORDER; + Marshal.StructureToPtr(wp, lParam, false); + break; + case WM_NCLBUTTONDBLCLK: + ToggleCollapsed(); + handled = true; + break; + case WM_SYSCOMMAND when (wParam.ToInt32() & 0xFFF0) == SC_MAXIMIZE: + handled = true; + break; + } + return IntPtr.Zero; + } + + /// Milchglas (verschwommener Hintergrund) über die Windows-Composition-Schnittstelle. + private void SetBlur(bool on) + { + var hwnd = Hwnd; + if (hwnd == IntPtr.Zero) return; + var accent = new AccentPolicy { AccentState = on ? 3 : 0 }; // 3 = ACCENT_ENABLE_BLURBEHIND + int size = Marshal.SizeOf(accent); + var ptr = Marshal.AllocHGlobal(size); + try + { + Marshal.StructureToPtr(accent, ptr, false); + var data = new WindowCompositionAttributeData { Attribute = 19, Data = ptr, SizeOfData = size }; // 19 = WCA_ACCENT_POLICY + SetWindowCompositionAttribute(hwnd, ref data); + } + catch { } + finally { Marshal.FreeHGlobal(ptr); } + } + + private const int GWL_EXSTYLE = -20, GWLP_HWNDPARENT = -8; + private const long WS_EX_TOOLWINDOW = 0x80, WS_EX_APPWINDOW = 0x40000; + private const int WM_WINDOWPOSCHANGING = 0x46, WM_NCLBUTTONDBLCLK = 0xA3, WM_SYSCOMMAND = 0x112, SC_MAXIMIZE = 0xF030; + private const uint SWP_NOSIZE = 0x1, SWP_NOMOVE = 0x2, SWP_NOZORDER = 0x4, SWP_NOACTIVATE = 0x10; + private static readonly IntPtr HWND_BOTTOM = new(1), HWND_TOPMOST = new(-1), HWND_NOTOPMOST = new(-2); + + [StructLayout(LayoutKind.Sequential)] + private struct WINDOWPOS + { + public IntPtr hwnd, hwndInsertAfter; + public int x, y, cx, cy; + public uint flags; + } + + [StructLayout(LayoutKind.Sequential)] + private struct AccentPolicy + { + public int AccentState, AccentFlags, GradientColor, AnimationId; + } + + [StructLayout(LayoutKind.Sequential)] + private struct WindowCompositionAttributeData + { + public int Attribute; + public IntPtr Data; + public int SizeOfData; + } + + [DllImport("user32.dll", CharSet = CharSet.Unicode)] private static extern IntPtr FindWindow(string? cls, string? title); + [DllImport("user32.dll")] private static extern IntPtr GetWindowLongPtr(IntPtr h, int i); + [DllImport("user32.dll")] private static extern IntPtr SetWindowLongPtr(IntPtr h, int i, IntPtr v); + [DllImport("user32.dll")] private static extern bool SetWindowPos(IntPtr h, IntPtr after, int x, int y, int cx, int cy, uint flags); + [DllImport("user32.dll")] private static extern int SetWindowCompositionAttribute(IntPtr hwnd, ref WindowCompositionAttributeData data); +} diff --git a/GameLibraries.cs b/GameLibraries.cs new file mode 100644 index 0000000..effa860 --- /dev/null +++ b/GameLibraries.cs @@ -0,0 +1,101 @@ +using System.IO; +using System.Text.Json; +using System.Text.RegularExpressions; +using Microsoft.Win32; + +namespace DeskCore; + +/// +/// Erkennt, ob ein Spiel aus einem Steam- oder Epic-Link (.url) noch installiert ist. +/// Ergebnis null = "weiß ich nicht" → DeskCore lässt den Link dann sicherheitshalber stehen. +/// +public static class GameLibraries +{ + private static readonly Regex SteamRun = new(@"^steam://(?:rungameid|run|launch)/(\d+)", RegexOptions.IgnoreCase); + private const string EpicPrefix = "com.epicgames.launcher://apps/"; + + /// Ist das Spiel hinter diesem Launcher-Link noch installiert? (null = nicht feststellbar) + public static bool? IsInstalled(string url) + { + url = url.Trim(); + var m = SteamRun.Match(url); + if (m.Success) return SteamAppInstalled(m.Groups[1].Value); + if (url.StartsWith(EpicPrefix, StringComparison.OrdinalIgnoreCase)) return EpicAppInstalled(url); + return null; + } + + // ---------- Steam ---------- + + private static (DateTime When, List? Libraries) _steamCache; + + /// Alle Steam-Bibliotheksordner – oder null, wenn Steam fehlt bzw. eine Bibliothek gerade nicht erreichbar ist. + private static List? SteamLibraries() + { + if (DateTime.UtcNow - _steamCache.When < TimeSpan.FromSeconds(30)) return _steamCache.Libraries; + List? result = null; + try + { + using var k = Registry.CurrentUser.OpenSubKey(@"Software\Valve\Steam"); + if (k?.GetValue("SteamPath") is string sp && sp.Length > 0) + { + var steamPath = Path.GetFullPath(sp.Replace('/', '\\')); + var libs = new List { steamPath }; + var vdf = Path.Combine(steamPath, "steamapps", "libraryfolders.vdf"); + if (File.Exists(vdf)) + { + foreach (Match lm in Regex.Matches(File.ReadAllText(vdf), "\"path\"\\s+\"([^\"]+)\"")) + libs.Add(Path.GetFullPath(lm.Groups[1].Value.Replace(@"\\", @"\"))); + } + libs = libs.Distinct(StringComparer.OrdinalIgnoreCase).ToList(); + // Liegt eine Bibliothek auf einem abgesteckten Laufwerk, könnte das Spiel dort sein → nichts entscheiden + result = libs.All(l => Directory.Exists(Path.Combine(l, "steamapps"))) ? libs : null; + } + } + catch { result = null; } + _steamCache = (DateTime.UtcNow, result); + return result; + } + + private static bool? SteamAppInstalled(string appId) + { + var libs = SteamLibraries(); + if (libs is null) return null; + return libs.Any(l => File.Exists(Path.Combine(l, "steamapps", $"appmanifest_{appId}.acf"))); + } + + // ---------- Epic Games ---------- + + private static bool? EpicAppInstalled(string url) + { + // Format: com.epicgames.launcher://apps/%3A%3A?action=launch + // oder com.epicgames.launcher://apps/?action=launch + var rest = url[EpicPrefix.Length..]; + var q = rest.IndexOf('?'); + if (q >= 0) rest = rest[..q]; + var appName = Uri.UnescapeDataString(rest).Split(':').Last().Trim(); + if (appName.Length == 0) return null; + + var manifests = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), + "Epic", "EpicGamesLauncher", "Data", "Manifests"); + if (!Directory.Exists(manifests)) return null; // Epic-Launcher nicht (mehr) vorhanden → nicht entscheiden + try + { + foreach (var f in Directory.EnumerateFiles(manifests, "*.item")) + { + using var doc = JsonDocument.Parse(File.ReadAllText(f)); + var root = doc.RootElement; + if (!root.TryGetProperty("AppName", out var an) || + !string.Equals(an.GetString(), appName, StringComparison.OrdinalIgnoreCase)) continue; + // Installationsort auf abgestecktem Laufwerk? → nicht entscheiden + if (root.TryGetProperty("InstallLocation", out var loc) && loc.GetString() is { Length: > 0 } dir) + { + var drive = new DriveInfo(Path.GetPathRoot(dir)!); + if (!drive.IsReady) return null; + } + return true; + } + return false; + } + catch { return null; } + } +} diff --git a/Hotkey.cs b/Hotkey.cs index 10112fd..e4a0d9d 100644 --- a/Hotkey.cs +++ b/Hotkey.cs @@ -1,138 +1,140 @@ -using System.Runtime.InteropServices; -using System.Windows.Input; -using System.Windows.Interop; -using Microsoft.Win32; - -namespace DeskCore; - -/// Systemweites Tastenkürzel (RegisterHotKey). -public sealed class Hotkey : IDisposable -{ - public const int ModAlt = 0x1, ModCtrl = 0x2, ModShift = 0x4, ModWin = 0x8; - private const int ModNoRepeat = 0x4000, WmHotkey = 0x0312, Id = 0xB001; - - private readonly IntPtr _hwnd; - private readonly HwndSource _source; - private bool _registered; - - public event Action? Pressed; - - public Hotkey(IntPtr hwnd) - { - _hwnd = hwnd; - _source = HwndSource.FromHwnd(hwnd); - _source.AddHook(Hook); - } - - /// Registriert das Kürzel. false = schon von einem anderen Programm belegt. - public bool Register(int modifiers, int vk) - { - Unregister(); - if (vk == 0) return false; - _registered = RegisterHotKey(_hwnd, Id, modifiers | ModNoRepeat, vk); - return _registered; - } - - public void Unregister() - { - if (_registered) UnregisterHotKey(_hwnd, Id); - _registered = false; - } - - private IntPtr Hook(IntPtr hwnd, int msg, IntPtr wParam, IntPtr lParam, ref bool handled) - { - if (msg == WmHotkey && wParam.ToInt32() == Id) - { - Pressed?.Invoke(); - handled = true; - } - return IntPtr.Zero; - } - - public static string Describe(int modifiers, int vk) - { - if (vk == 0) return "–"; - var parts = new List(); - if ((modifiers & ModWin) != 0) parts.Add("Win"); - if ((modifiers & ModCtrl) != 0) parts.Add("Strg"); - if ((modifiers & ModAlt) != 0) parts.Add("Alt"); - if ((modifiers & ModShift) != 0) parts.Add("Umschalt"); - var key = KeyInterop.KeyFromVirtualKey(vk); - parts.Add(key switch - { - >= Key.D0 and <= Key.D9 => ((char)('0' + (key - Key.D0))).ToString(), - Key.Space => "Leertaste", - _ => key.ToString(), - }); - return string.Join(" + ", parts); - } - - public void Dispose() - { - Unregister(); - _source.RemoveHook(Hook); - } - - [DllImport("user32.dll")] private static extern bool RegisterHotKey(IntPtr hWnd, int id, int fsModifiers, int vk); - [DllImport("user32.dll")] private static extern bool UnregisterHotKey(IntPtr hWnd, int id); -} - -/// Start mit Windows über HKCU\...\Run. -public static class Autostart -{ - private const string Key = @"Software\Microsoft\Windows\CurrentVersion\Run"; - private const string Name = "DeskCore"; - // Frühere Namen dieser App. "DeskHub" heißt auch ein anderes Programm → nur übernehmen, wenn der Eintrag eindeutig von uns ist. - private static readonly string[] OldNames = ["DesktopSortierer", "DeskHub"]; - - public static bool Enabled - { - get - { - try - { - using var k = Registry.CurrentUser.OpenSubKey(Key); - return k?.GetValue(Name) is string; - } - catch { return false; } - } - set - { - try - { - using var k = Registry.CurrentUser.OpenSubKey(Key, writable: true) ?? Registry.CurrentUser.CreateSubKey(Key); - if (value) k.SetValue(Name, $"\"{Environment.ProcessPath}\" --minimized"); - else k.DeleteValue(Name, throwOnMissingValue: false); - } - catch { } - } - } - - /// Alten Autostart-Eintrag ("DesktopSortierer") auf den neuen Namen umstellen. - public static void Migrate() - { - try - { - using var k = Registry.CurrentUser.OpenSubKey(Key, writable: true); - if (k is null) return; - bool wasEnabled = false; - foreach (var old in OldNames) - { - if (k.GetValue(old) is not string cmd) continue; - bool ours = old == "DesktopSortierer" || - (cmd.Contains($"{old}.exe", StringComparison.OrdinalIgnoreCase) && cmd.Contains("--minimized")); - if (!ours) continue; - k.DeleteValue(old, throwOnMissingValue: false); - wasEnabled = true; - } - if (wasEnabled) Enabled = true; - } - catch { } - } - - /// Falls die exe verschoben wurde: Pfad im Autostart aktualisieren. - public static void RefreshPath() - { - if (Enabled) Enabled = true; - } -} +using System.Runtime.InteropServices; +using System.Windows.Input; +using System.Windows.Interop; +using Microsoft.Win32; + +namespace DeskCore; + +/// Systemweites Tastenkürzel (RegisterHotKey). +public sealed class Hotkey : IDisposable +{ + public const int ModAlt = 0x1, ModCtrl = 0x2, ModShift = 0x4, ModWin = 0x8; + private const int ModNoRepeat = 0x4000, WmHotkey = 0x0312; + private readonly int Id; + + private readonly IntPtr _hwnd; + private readonly HwndSource _source; + private bool _registered; + + public event Action? Pressed; + + public Hotkey(IntPtr hwnd, int id = 0xB001) + { + Id = id; + _hwnd = hwnd; + _source = HwndSource.FromHwnd(hwnd); + _source.AddHook(Hook); + } + + /// Registriert das Kürzel. false = schon von einem anderen Programm belegt. + public bool Register(int modifiers, int vk) + { + Unregister(); + if (vk == 0) return false; + _registered = RegisterHotKey(_hwnd, Id, modifiers | ModNoRepeat, vk); + return _registered; + } + + public void Unregister() + { + if (_registered) UnregisterHotKey(_hwnd, Id); + _registered = false; + } + + private IntPtr Hook(IntPtr hwnd, int msg, IntPtr wParam, IntPtr lParam, ref bool handled) + { + if (msg == WmHotkey && wParam.ToInt32() == Id) + { + Pressed?.Invoke(); + handled = true; + } + return IntPtr.Zero; + } + + public static string Describe(int modifiers, int vk) + { + if (vk == 0) return "–"; + var parts = new List(); + if ((modifiers & ModWin) != 0) parts.Add("Win"); + if ((modifiers & ModCtrl) != 0) parts.Add("Strg"); + if ((modifiers & ModAlt) != 0) parts.Add("Alt"); + if ((modifiers & ModShift) != 0) parts.Add("Umschalt"); + var key = KeyInterop.KeyFromVirtualKey(vk); + parts.Add(key switch + { + >= Key.D0 and <= Key.D9 => ((char)('0' + (key - Key.D0))).ToString(), + Key.Space => "Leertaste", + _ => key.ToString(), + }); + return string.Join(" + ", parts); + } + + public void Dispose() + { + Unregister(); + _source.RemoveHook(Hook); + } + + [DllImport("user32.dll")] private static extern bool RegisterHotKey(IntPtr hWnd, int id, int fsModifiers, int vk); + [DllImport("user32.dll")] private static extern bool UnregisterHotKey(IntPtr hWnd, int id); +} + +/// Start mit Windows über HKCU\...\Run. +public static class Autostart +{ + private const string Key = @"Software\Microsoft\Windows\CurrentVersion\Run"; + private const string Name = "DeskCore"; + // Frühere Namen dieser App. "DeskHub" heißt auch ein anderes Programm → nur übernehmen, wenn der Eintrag eindeutig von uns ist. + private static readonly string[] OldNames = ["DesktopSortierer", "DeskHub"]; + + public static bool Enabled + { + get + { + try + { + using var k = Registry.CurrentUser.OpenSubKey(Key); + return k?.GetValue(Name) is string; + } + catch { return false; } + } + set + { + try + { + using var k = Registry.CurrentUser.OpenSubKey(Key, writable: true) ?? Registry.CurrentUser.CreateSubKey(Key); + if (value) k.SetValue(Name, $"\"{Environment.ProcessPath}\" --minimized"); + else k.DeleteValue(Name, throwOnMissingValue: false); + } + catch { } + } + } + + /// Alten Autostart-Eintrag ("DesktopSortierer") auf den neuen Namen umstellen. + public static void Migrate() + { + try + { + using var k = Registry.CurrentUser.OpenSubKey(Key, writable: true); + if (k is null) return; + bool wasEnabled = false; + foreach (var old in OldNames) + { + if (k.GetValue(old) is not string cmd) continue; + bool ours = old == "DesktopSortierer" || + (cmd.Contains($"{old}.exe", StringComparison.OrdinalIgnoreCase) && cmd.Contains("--minimized")); + if (!ours) continue; + k.DeleteValue(old, throwOnMissingValue: false); + wasEnabled = true; + } + if (wasEnabled) Enabled = true; + } + catch { } + } + + /// Falls die exe verschoben wurde: Pfad im Autostart aktualisieren. + public static void RefreshPath() + { + if (Enabled) Enabled = true; + } +} diff --git a/MainWindow.Cleanup.cs b/MainWindow.Cleanup.cs new file mode 100644 index 0000000..1bbeb5d --- /dev/null +++ b/MainWindow.Cleanup.cs @@ -0,0 +1,114 @@ +using System.IO; +using System.Windows; +using System.Windows.Threading; + +namespace DeskCore; + +/// Verknüpfungen zu deinstallierten Programmen automatisch aus den Kategorien entfernen. +public partial class MainWindow +{ + private DispatcherTimer? _deadScanTimer; + private DispatcherTimer? _deadScanPeriodic; + private bool _deadScanRunning; + private DispatcherTimer? _deadConfirmTimer; + private readonly Dictionary _deadSince = new(StringComparer.OrdinalIgnoreCase); + private static readonly TimeSpan ConfirmAfter = TimeSpan.FromSeconds(60); + + /// Zweite Prüfung kurz nach Ablauf der Wartezeit. + private void ScheduleConfirmScan() + { + if (_deadConfirmTimer is null) + { + _deadConfirmTimer = new DispatcherTimer { Interval = ConfirmAfter + TimeSpan.FromSeconds(5) }; + _deadConfirmTimer.Tick += (_, _) => { _deadConfirmTimer.Stop(); CleanupDeadShortcuts(); }; + } + if (!_deadConfirmTimer.IsEnabled) _deadConfirmTimer.Start(); + } + + /// Einmal beim Start einrichten: Prüfung kurz nach dem Start und danach alle 10 Minuten. + public void StartDeadShortcutWatch() + { + _deadScanTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(4) }; + _deadScanTimer.Tick += (_, _) => { _deadScanTimer.Stop(); CleanupDeadShortcuts(); }; + // Deinstallationen über die Windows-Einstellungen ändern unsere Ordner nicht → zusätzlich regelmäßig prüfen + _deadScanPeriodic = new DispatcherTimer { Interval = TimeSpan.FromMinutes(10) }; + _deadScanPeriodic.Tick += (_, _) => CleanupDeadShortcuts(); + _deadScanPeriodic.Start(); + ScheduleDeadShortcutScan(); + } + + /// Nach Änderungen (z. B. Deinstallation entfernt die Desktop-Verknüpfung) gebündelt prüfen. + public void ScheduleDeadShortcutScan() + { + if (_deadScanTimer is null) return; + _deadScanTimer.Stop(); + _deadScanTimer.Start(); + } + + private async void CleanupDeadShortcuts() + { + if (!S.RemoveDeadShortcuts || _deadScanRunning || IsBusy) return; + _deadScanRunning = true; + try + { + var keep = S.KeptShortcuts.ToList(); + var found = await Task.Run(() => Storage.FindDeadShortcuts(keep)); + + // Updates ersetzen ihre Programmdatei manchmal kurz → erst entfernen, wenn sie mindestens + // eine Minute lang fehlt (zweite Prüfung), sonst würde ein laufendes Update fälschlich aufgeräumt + var now = DateTime.UtcNow; + foreach (var p in _deadSince.Keys.Where(k => !found.Contains(k, StringComparer.OrdinalIgnoreCase)).ToList()) + _deadSince.Remove(p); + foreach (var p in found) _deadSince.TryAdd(p, now); + var dead = found.Where(p => now - _deadSince[p] >= ConfirmAfter).ToList(); + if (dead.Count < found.Count) ScheduleConfirmScan(); + if (dead.Count == 0) return; + foreach (var p in dead) _deadSince.Remove(p); + + var entry = new UndoEntry(dead.Count == 1 + ? $"„{Storage.DisplayName(dead[0])}“ entfernen (Programm deinstalliert)" + : $"{dead.Count} Verknüpfungen deinstallierter Programme entfernen"); + var removed = new List(); + foreach (var p in dead) + { + try + { + // Reihenfolge beim Rückgängigmachen: erst zurückholen, dann merken, dass sie bleiben soll + var path = p; + entry.Add(() => + { + if (!S.KeptShortcuts.Contains(path, StringComparer.OrdinalIgnoreCase)) S.KeptShortcuts.Add(path); + S.Save(); + return null; + }); + entry.AddDelete(p, Storage.Delete(p)); + removed.Add(p); + } + catch { } + } + if (removed.Count == 0) return; + Undo.Push(entry); + Refresh(); + FenceManager.RefreshAll(); + + var text = removed.Count == 1 + ? $"„{Storage.DisplayName(removed[0])}“ entfernt – das Programm ist nicht mehr installiert" + : $"{removed.Count} Verknüpfungen entfernt – die Programme sind nicht mehr installiert"; + if (IsVisible && IsActive) ShowToast(text, "", undoable: true); + else App.TrayIcon?.Notify("Aufgeräumt", text); + } + finally + { + _deadScanRunning = false; + } + } + + private void DeadShortcutsToggle_Click(object sender, RoutedEventArgs e) + { + S.RemoveDeadShortcuts = DeadShortcutsToggle.IsChecked == true; + // beim Einschalten: früher behaltene Verknüpfungen wieder prüfen lassen + if (S.RemoveDeadShortcuts) S.KeptShortcuts.RemoveAll(p => !File.Exists(p)); + S.Save(); + if (S.RemoveDeadShortcuts) ScheduleDeadShortcutScan(); + } +} diff --git a/MainWindow.Fences.cs b/MainWindow.Fences.cs new file mode 100644 index 0000000..e64f87a --- /dev/null +++ b/MainWindow.Fences.cs @@ -0,0 +1,119 @@ +using System.Windows; + +namespace DeskCore; + +/// Einstellungen der Desktop-Boxen: Portale, Schnell verstecken, Peek, Chamäleon und Snapshots. +public partial class MainWindow +{ + /// Box geschlossen o. Ä. → Kennzeichnung "hat Box" in der Seitenleiste aktualisieren. + public void RefreshFromFences() + { + Refresh(); + if (InSettings) { LoadFenceSettings(); LoadDesktopSettings(); } + } + + private void LoadFenceSettings() + { + QuickHideToggle.IsChecked = S.QuickHide; + PeekToggle.IsChecked = S.PeekEnabled; + PeekHint.Text = $"{Hotkey.Describe(S.PeekModifiers, S.PeekKey)} holt alle Boxen vor die offenen Fenster. " + + "Nochmal drücken, Esc oder woanders hinklicken schickt sie zurück."; + ChameleonToggle.IsChecked = S.FenceChameleon; + SnapshotList.ItemsSource = null; + SnapshotList.ItemsSource = S.Snapshots.OrderByDescending(x => x.Created).ToList(); + } + + private void AddPortal_Click(object sender, RoutedEventArgs e) + { + int before = S.Fences.Count; + FenceManager.AddPortal(); + if (S.Fences.Count > before) ShowToast("Ordner-Portal liegt jetzt auf dem Desktop", ""); + LoadSettingsView(); + } + + private void QuickHideToggle_Click(object sender, RoutedEventArgs e) + { + S.QuickHide = QuickHideToggle.IsChecked == true; + S.Save(); + DesktopToggle.Apply(); + } + + private void PeekToggle_Click(object sender, RoutedEventArgs e) + { + S.PeekEnabled = PeekToggle.IsChecked == true; + S.Save(); + if (!App.ApplyPeekHotkey()) + PeekHint.Text = "⚠ Diese Tastenkombination ist schon von einem anderen Programm belegt."; + } + + private void ChameleonToggle_Click(object sender, RoutedEventArgs e) + { + S.FenceChameleon = ChameleonToggle.IsChecked == true; + S.Save(); + FenceManager.RefreshAll(); + } + + // ---------- Snapshots ---------- + + private void SaveSnapshot_Click(object sender, RoutedEventArgs e) + { + if (S.Fences.Count == 0) + { + ShowToast("Es gibt noch keine Boxen zum Speichern", "", info: true); + return; + } + var name = Dialog.Prompt(this, "Anordnung speichern", "Name für diese Anordnung (z. B. „Arbeit“ oder „Zocken“):", + $"Anordnung {S.Snapshots.Count + 1}", "Speichern", + t => t.Trim().Length == 0 ? "Bitte einen Namen eingeben." : t.Contains('"') ? "Bitte ohne Anführungszeichen." : null); + if (name is null) return; + bool replace = S.Snapshots.Any(x => string.Equals(x.Name, name, StringComparison.OrdinalIgnoreCase)); + FenceManager.SaveSnapshot(name); + LoadFenceSettings(); + ShowToast(replace ? $"Anordnung „{name}“ aktualisiert" : $"Anordnung „{name}“ gespeichert", ""); + } + + private void RestoreSnapshot(FenceSnapshot snap) + { + var before = FenceManager.RestoreSnapshot(snap); + var entry = new UndoEntry($"Anordnung „{snap.Name}“ laden"); + entry.Add(() => { FenceManager.ApplyFences(before); return null; }); + Undo.Push(entry); + Refresh(); + ShowToast($"Anordnung „{snap.Name}“ wiederhergestellt", "", undoable: true); + } + + private void RestoreSnapshot_Click(object sender, RoutedEventArgs e) + { + if ((sender as FrameworkElement)?.Tag is FenceSnapshot snap) RestoreSnapshot(snap); + } + + private void DeleteSnapshot_Click(object sender, RoutedEventArgs e) + { + if ((sender as FrameworkElement)?.Tag is not FenceSnapshot snap) return; + S.Snapshots.Remove(snap); + S.Save(); + var entry = new UndoEntry($"Anordnung „{snap.Name}“ löschen"); + entry.Add(() => { S.Snapshots.Add(snap); S.Save(); return null; }); + Undo.Push(entry); + LoadFenceSettings(); + ShowToast($"Anordnung „{snap.Name}“ gelöscht", "", undoable: true); + } + + /// Für die Befehlszeile: DeskCore.exe --snapshot "Name". + public void RestoreSnapshotByName(string name) + { + var snap = S.Snapshots.FirstOrDefault(x => string.Equals(x.Name, name, StringComparison.OrdinalIgnoreCase)); + if (snap is null) App.TrayIcon?.Notify("Anordnung nicht gefunden", $"Es gibt keine gespeicherte Anordnung „{name}“."); + else RestoreSnapshot(snap); + } + + // ---------- Regeln: Name oder Dateityp ---------- + + private void RuleKind_Click(object sender, RoutedEventArgs e) + { + if ((sender as FrameworkElement)?.Tag is not SortRule r) return; + r.Kind = r.Kind == "Typ" ? "Name" : "Typ"; + S.Save(); + LoadSettingsView(); + } +} diff --git a/MainWindow.LaunchGroups.cs b/MainWindow.LaunchGroups.cs new file mode 100644 index 0000000..3977dee --- /dev/null +++ b/MainWindow.LaunchGroups.cs @@ -0,0 +1,413 @@ +using System.Diagnostics; +using System.IO; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Media; + +namespace DeskCore; + +/// Start-Gruppen: ein Klick startet mehrere Programme nacheinander. +public partial class MainWindow +{ + private LaunchGroupItem? CurrentGroup => LaunchList.SelectedItem as LaunchGroupItem; + + /// Kategorie-Dialog von außen (z. B. "Neue Box hier" auf dem Desktop). + public string? CreateCategoryInteractive() => NewCategory(); + + private static string DelayText(LaunchGroup g) => + g.DelaySeconds <= 0 ? "gleichzeitig" : $"{g.DelaySeconds:0.#} s Abstand"; + + /// Wo liegt ein Element? (für Tooltip/Listenansicht) + private static string LocationOf(string path) + { + var dir = Path.GetDirectoryName(path) ?? ""; + if (Storage.IsDesktopDir(dir)) return DesktopName; + return Storage.IsInStorage(path) ? Path.GetFileName(dir) : dir; + } + + private void LoadGroupIcons(LaunchGroup g) + { + var items = g.Items.Select(p => new IconItem { Path = p, Category = LocationOf(p) }).ToList(); + HeaderText.Text = g.Name; + int missing = items.Count(i => !File.Exists(i.Path) && !Directory.Exists(i.Path)); + SubText.Text = $"{Plural(items.Count, "Programm", "Programme")} · {DelayText(g)}" + + (missing > 0 ? $" · ⚠ {missing} nicht mehr vorhanden" : ""); + SetEmpty("", "Noch leer", + "Ziehe Programme hierher oder auf die Gruppe links – oder Rechtsklick auf ein Icon → „Zur Start-Gruppe hinzufügen“."); + + // Reihenfolge = Startreihenfolge, deshalb hier nicht sortieren + var px = IconPixels; + foreach (var item in items) IconLoader.Load(item, px); + IconList.ItemsSource = items; + + EmptyState.Visibility = items.Count == 0 ? Visibility.Visible : Visibility.Collapsed; + CategoryCommands.Visibility = Visibility.Collapsed; + InfoBar.Visibility = Visibility.Collapsed; + GroupCommands.Visibility = Visibility.Visible; + } + + private void SelectGroup(LaunchGroup g) + { + var item = LaunchList.Items.OfType().FirstOrDefault(x => x.Group == g); + if (item is not null) LaunchList.SelectedItem = item; + } + + // ---------- Starten ---------- + + /// Startet alle Programme der Gruppe nacheinander (mit eingestelltem Abstand). + public async void LaunchGroupNow(LaunchGroup g) + { + if (g.Items.Count == 0) + { + ShowToast($"„{g.Name}“ ist noch leer", "", info: true); + return; + } + int started = 0; + var problems = new List(); + var items = g.Items.ToList(); + for (int i = 0; i < items.Count; i++) + { + if (i > 0 && g.DelaySeconds > 0) await Task.Delay(TimeSpan.FromSeconds(g.DelaySeconds)); + var p = items[i]; + if (!File.Exists(p) && !Directory.Exists(p)) { problems.Add($"„{Storage.DisplayName(p)}“ gibt es nicht mehr"); continue; } + try + { + Process.Start(new ProcessStartInfo(p) { UseShellExecute = true }); + started++; + } + catch (Exception ex) { problems.Add($"„{Storage.DisplayName(p)}“: {ex.Message}"); } + } + + var text = $"„{g.Name}“: {Plural(started, "Programm", "Programme")} gestartet" + + (problems.Count > 0 ? $" · {problems.Count} nicht" : ""); + if (IsVisible && IsActive) ShowToast(text, problems.Count > 0 ? "" : "", info: problems.Count > 0); + else App.TrayIcon?.Notify(g.Name, problems.Count > 0 ? text + "\n" + string.Join("\n", problems.Take(3)) : text); + } + + /// Für die Befehlszeile und Desktop-Verknüpfungen: DeskCore.exe --launch "Name". + public bool LaunchGroupByName(string name) + { + var g = S.LaunchGroups.FirstOrDefault(x => string.Equals(x.Name, name, StringComparison.OrdinalIgnoreCase)); + if (g is null) + { + App.TrayIcon?.Notify("Start-Gruppe nicht gefunden", $"Es gibt keine Start-Gruppe „{name}“."); + return false; + } + LaunchGroupNow(g); + return true; + } + + private void LaunchCurrentGroup_Click(object sender, RoutedEventArgs e) + { + if (CurrentGroup is { } g) LaunchGroupNow(g.Group); + } + + private void LaunchGroupPlay_Click(object sender, RoutedEventArgs e) + { + if ((sender as FrameworkElement)?.Tag is LaunchGroupItem g) LaunchGroupNow(g.Group); + e.Handled = true; + } + + // ---------- Anlegen / Bearbeiten ---------- + + /// Dialog für Name, Startabstand und Farbe. Gibt null bei Abbruch zurück. + private (string Name, double Delay, string? Color)? LaunchGroupDialog(string title, string primary, LaunchGroup? existing) + { + var res = Application.Current.Resources; + var panel = new StackPanel(); + TextBlock Label(string t, double top = 16) => new() + { + Text = t, Margin = new Thickness(0, top, 0, 8), FontWeight = FontWeights.SemiBold, FontSize = 13, + }; + + panel.Children.Add(Label("Name", 0)); + var nameBox = new TextBox { Text = existing?.Name ?? "" }; + panel.Children.Add(nameBox); + + panel.Children.Add(Label("Abstand zwischen den Programmen")); + var delayText = new TextBlock { VerticalAlignment = VerticalAlignment.Center, Width = 110, Foreground = (Brush)res["TextSecondaryBrush"] }; + var slider = new Slider { Minimum = 0, Maximum = 10, TickFrequency = 0.5, IsSnapToTickEnabled = true, Value = existing?.DelaySeconds ?? 1 }; + void UpdateDelay() => delayText.Text = slider.Value <= 0 ? "gleichzeitig" : $"{slider.Value:0.#} Sekunden"; + slider.ValueChanged += (_, _) => UpdateDelay(); + UpdateDelay(); + var row = new DockPanel(); + DockPanel.SetDock(delayText, Dock.Right); + row.Children.Add(delayText); + row.Children.Add(slider); + panel.Children.Add(row); + panel.Children.Add(new TextBlock + { + Text = "Etwas Abstand hilft, wenn Programme beim Start viel Leistung brauchen.", FontSize = 12, Margin = new Thickness(0, 4, 0, 0), + TextWrapping = TextWrapping.Wrap, Foreground = (Brush)res["TextTertiaryBrush"], + }); + + panel.Children.Add(Label("Farbe")); + string? color = existing?.Color; + var swatches = new WrapPanel(); + var none = new RadioButton + { + Style = (Style)res["SwatchRadio"], GroupName = "lgcolor", ToolTip = "Akzentfarbe", Background = (Brush)res["AccentBrush"], + IsChecked = color is null, + }; + none.Checked += (_, _) => color = null; + swatches.Children.Add(none); + foreach (var (cname, hex) in CategoryColors.All) + { + var rb = new RadioButton + { + Style = (Style)res["SwatchRadio"], GroupName = "lgcolor", ToolTip = cname, Background = CategoryColors.Brush(hex), + IsChecked = string.Equals(color, hex, StringComparison.OrdinalIgnoreCase), + }; + rb.Checked += (_, _) => color = hex; + swatches.Children.Add(rb); + } + panel.Children.Add(swatches); + + string? Validate() + { + var n = nameBox.Text.Trim(); + if (n.Length == 0) return "Bitte einen Namen eingeben."; + if (n.Contains('"')) return "Der Name darf keine Anführungszeichen enthalten."; + bool taken = S.LaunchGroups.Any(x => x != existing && string.Equals(x.Name, n, StringComparison.OrdinalIgnoreCase)); + return taken ? "Eine Start-Gruppe mit diesem Namen gibt es schon." : null; + } + + if (!Dialog.Show(this, title, panel, primary, "Abbrechen", Validate, nameBox, width: 470)) return null; + return (nameBox.Text.Trim(), slider.Value, color); + } + + private LaunchGroup? NewLaunchGroup(IEnumerable? initialItems = null) + { + var form = LaunchGroupDialog("Neue Start-Gruppe", "Erstellen", null); + if (form is null) return null; + var g = new LaunchGroup { Name = form.Value.Name, DelaySeconds = form.Value.Delay, Color = form.Value.Color }; + S.LaunchGroups.Add(g); + if (initialItems is not null) AddPathsToGroup(g, initialItems, toast: false); + S.Save(); + + var entry = new UndoEntry($"Start-Gruppe „{g.Name}“ erstellen"); + entry.Add(() => { S.LaunchGroups.Remove(g); S.Save(); return null; }); + Undo.Push(entry); + Refresh(); + return g; + } + + private void NewLaunchGroup_Click(object sender, RoutedEventArgs e) + { + if (NewLaunchGroup() is not { } g) return; + ShowBrowse(); + SelectGroup(g); + ShowToast($"Start-Gruppe „{g.Name}“ erstellt – jetzt Programme hineinziehen", "", undoable: true); + } + + private void EditLaunchGroup(LaunchGroup g) + { + var form = LaunchGroupDialog("Start-Gruppe bearbeiten", "Speichern", g); + if (form is null) return; + var before = (g.Name, g.DelaySeconds, g.Color); + (g.Name, g.DelaySeconds, g.Color) = form.Value; + S.Save(); + var entry = new UndoEntry($"Start-Gruppe „{before.Name}“ bearbeiten"); + entry.Add(() => { (g.Name, g.DelaySeconds, g.Color) = before; S.Save(); return null; }); + Undo.Push(entry); + Refresh(); + if (before.Name != g.Name) + ShowToast("Gespeichert – eine vorhandene Desktop-Verknüpfung bitte neu anlegen (sie nutzt noch den alten Namen)", "", info: true); + else ShowToast("Start-Gruppe gespeichert", undoable: true); + } + + private void EditLaunchGroup_Click(object sender, RoutedEventArgs e) + { + if (CurrentGroup is { } g) EditLaunchGroup(g.Group); + } + + private void DeleteLaunchGroup(LaunchGroup g) + { + if (!Dialog.Confirm(this, "Start-Gruppe löschen?", + $"„{g.Name}“ wird entfernt. Die Programme selbst bleiben unverändert.", "Löschen")) + return; + int index = S.LaunchGroups.IndexOf(g); + S.LaunchGroups.Remove(g); + S.Save(); + var entry = new UndoEntry($"Start-Gruppe „{g.Name}“ löschen"); + entry.Add(() => { S.LaunchGroups.Insert(Math.Min(index, S.LaunchGroups.Count), g); S.Save(); return null; }); + Undo.Push(entry); + Refresh(); + ShowToast($"Start-Gruppe „{g.Name}“ gelöscht", "", undoable: true); + } + + private void DeleteLaunchGroup_Click(object sender, RoutedEventArgs e) + { + if (CurrentGroup is { } g) DeleteLaunchGroup(g.Group); + } + + private void MoveLaunchGroup(LaunchGroup g, int delta) + { + int i = S.LaunchGroups.IndexOf(g), j = i + delta; + if (i < 0 || j < 0 || j >= S.LaunchGroups.Count) return; + (S.LaunchGroups[i], S.LaunchGroups[j]) = (S.LaunchGroups[j], S.LaunchGroups[i]); + S.Save(); + Refresh(); + } + + // ---------- Inhalt ---------- + + /// + /// Desktop-Icons, die schon in einer Kategorie gesichert sind, werden durch die gesicherte Kopie ersetzt – + /// so funktioniert die Gruppe auch noch, wenn das Icon später vom Desktop gelöscht wird. + /// + private static string ResolveForGroup(string path) + { + if (Storage.IsInStorage(path)) return path; + foreach (var c in Storage.Categories()) + if (Storage.Entries(Storage.CategoryPath(c)).FirstOrDefault(e => Storage.SameItem(e, path)) is { } saved) + return saved; + return path; + } + + private void AddPathsToGroup(LaunchGroup g, IEnumerable paths, bool toast = true) + { + int added = 0, dup = 0; + foreach (var raw in paths) + { + var p = ResolveForGroup(raw); + if (g.Items.Contains(p, StringComparer.OrdinalIgnoreCase)) { dup++; continue; } + g.Items.Add(p); + added++; + } + S.Save(); + if (!toast) return; + Refresh(); + if (added == 0) ShowToast(dup == 1 ? "Das ist schon in der Gruppe" : "Alles schon in der Gruppe", "", info: true); + else ShowToast($"{Plural(added, "Programm", "Programme")} zu „{g.Name}“ hinzugefügt", ""); + } + + private void RemoveFromGroup(LaunchGroup g, List sel) + { + var before = g.Items.ToList(); + foreach (var i in sel) g.Items.RemoveAll(p => string.Equals(p, i.Path, StringComparison.OrdinalIgnoreCase)); + S.Save(); + var entry = new UndoEntry($"Aus „{g.Name}“ entfernen"); + entry.Add(() => { g.Items = before; S.Save(); return null; }); + Undo.Push(entry); + Refresh(); + ShowToast($"{Plural(sel.Count, "Programm", "Programme")} aus „{g.Name}“ entfernt – die Dateien bleiben erhalten", "", undoable: true); + } + + private void MoveInGroup(LaunchGroup g, IconItem item, int delta) + { + int i = g.Items.FindIndex(p => string.Equals(p, item.Path, StringComparison.OrdinalIgnoreCase)), j = i + delta; + if (i < 0 || j < 0 || j >= g.Items.Count) return; + (g.Items[i], g.Items[j]) = (g.Items[j], g.Items[i]); + S.Save(); + Refresh(); + } + + /// Kontextmenü der Icons, wenn eine Start-Gruppe angezeigt wird. + private void FillGroupItemMenu(LaunchGroup g, List sel) + { + IconMenu.Items.Add(MenuItem("Öffnen", "", () => sel.ForEach(i => Open(i.Path)), bold: true, gesture: "Enter")); + if (sel.Count == 1) + { + IconMenu.Items.Add(MenuItem("Dateispeicherort öffnen", "", () => + Process.Start("explorer.exe", $"/select,\"{sel[0].Path}\""))); + IconMenu.Items.Add(new Separator()); + int idx = g.Items.FindIndex(p => string.Equals(p, sel[0].Path, StringComparison.OrdinalIgnoreCase)); + var up = MenuItem("Früher starten", "", () => MoveInGroup(g, sel[0], -1)); + up.IsEnabled = idx > 0; + var down = MenuItem("Später starten", "", () => MoveInGroup(g, sel[0], +1)); + down.IsEnabled = idx >= 0 && idx < g.Items.Count - 1; + IconMenu.Items.Add(up); + IconMenu.Items.Add(down); + } + IconMenu.Items.Add(new Separator()); + IconMenu.Items.Add(MenuItem("Aus Start-Gruppe entfernen", "", () => RemoveFromGroup(g, sel), gesture: "Entf")); + } + + /// Untermenü "Zur Start-Gruppe hinzufügen" (Haupt-Kontextmenü und Desktop-Boxen). + public MenuItem LaunchGroupSubmenu(List paths) + { + var menu = MenuItem("Zur Start-Gruppe hinzufügen", "", null); + foreach (var g in S.LaunchGroups) + menu.Items.Add(MenuItem(g.Name, "", () => AddPathsToGroup(g, paths))); + if (menu.Items.Count > 0) menu.Items.Add(new Separator()); + menu.Items.Add(MenuItem("Neue Start-Gruppe…", "", () => + { + if (NewLaunchGroup(paths) is { } g) + ShowToast($"Start-Gruppe „{g.Name}“ mit {Plural(g.Items.Count, "Programm", "Programmen")} erstellt", "", undoable: true); + })); + return menu; + } + + // ---------- Seitenleiste ---------- + + private void LaunchList_ContextMenuOpening(object sender, ContextMenuEventArgs e) + { + if (FindParent(e.OriginalSource as DependencyObject)?.DataContext is not LaunchGroupItem item) + { + e.Handled = true; + return; + } + var g = item.Group; + int idx = S.LaunchGroups.IndexOf(g); + LaunchMenu.Items.Clear(); + LaunchMenu.Items.Add(MenuItem("Alle starten", "", () => LaunchGroupNow(g), bold: true)); + LaunchMenu.Items.Add(MenuItem("Bearbeiten…", "", () => EditLaunchGroup(g))); + LaunchMenu.Items.Add(MenuItem("Verknüpfung auf den Desktop", "", () => CreateGroupShortcut(g))); + LaunchMenu.Items.Add(new Separator()); + var up = MenuItem("Nach oben", "", () => MoveLaunchGroup(g, -1)); + up.IsEnabled = idx > 0; + var down = MenuItem("Nach unten", "", () => MoveLaunchGroup(g, +1)); + down.IsEnabled = idx < S.LaunchGroups.Count - 1; + LaunchMenu.Items.Add(up); + LaunchMenu.Items.Add(down); + LaunchMenu.Items.Add(new Separator()); + LaunchMenu.Items.Add(MenuItem("Löschen", "", () => DeleteLaunchGroup(g))); + } + + private void LaunchList_DragOver(object sender, DragEventArgs e) + { + var lbi = FindParent(e.OriginalSource as DependencyObject); + bool ok = lbi is not null && e.Data.GetDataPresent(DataFormats.FileDrop); + SetDropHighlight(ok ? lbi : null); + e.Effects = ok ? DragDropEffects.Link | (e.AllowedEffects & DragDropEffects.Copy) : DragDropEffects.None; + e.Handled = true; + } + + private void LaunchList_Drop(object sender, DragEventArgs e) + { + SetDropHighlight(null); + if (FindParent(e.OriginalSource as DependencyObject)?.DataContext is LaunchGroupItem item && + e.Data.GetData(DataFormats.FileDrop) is string[] files) + AddPathsToGroup(item.Group, files); + } + + // ---------- Desktop-Verknüpfung ---------- + + private void LaunchGroupShortcut_Click(object sender, RoutedEventArgs e) + { + if (CurrentGroup is { } g) CreateGroupShortcut(g.Group); + } + + /// Legt "<Name> starten.lnk" auf den Desktop, die DeskCore.exe --launch "<Name>" aufruft. + private void CreateGroupShortcut(LaunchGroup g) + { + try + { + var exe = Environment.ProcessPath!; + var safe = string.Concat(g.Name.Split(Path.GetInvalidFileNameChars())); + var link = Storage.UniquePath(Storage.UserDesktop, $"{safe} starten.lnk"); + Storage.MarkCreated(link); // nicht von der Auto-Sortierung einsammeln lassen + dynamic shell = Activator.CreateInstance(Type.GetTypeFromProgID("WScript.Shell")!)!; + dynamic l = shell.CreateShortcut(link); + l.TargetPath = exe; + l.Arguments = $"--launch \"{g.Name}\""; + l.WorkingDirectory = Path.GetDirectoryName(exe); + l.IconLocation = $"{exe},0"; + l.Description = $"Startet die DeskCore-Start-Gruppe „{g.Name}“"; + l.Save(); + ShowToast($"„{Path.GetFileNameWithoutExtension(link)}“ liegt jetzt auf dem Desktop", ""); + } + catch (Exception ex) { Error("Verknüpfung fehlgeschlagen", ex.Message); } + } +} diff --git a/MainWindow.Pages.cs b/MainWindow.Pages.cs new file mode 100644 index 0000000..571e43f --- /dev/null +++ b/MainWindow.Pages.cs @@ -0,0 +1,134 @@ +using System.Windows; +using System.Windows.Controls; +using System.Windows.Media; + +namespace DeskCore; + +/// Einstellungen für Desktop-Seiten, das Aufziehen neuer Boxen und die Tönung der Desktop-Icons. +public partial class MainWindow +{ + public record PageRow(int Index, string Name, string Info, Brush Dot); + + private bool _loadingDesktopSettings; + + private void LoadDesktopSettings() + { + _loadingDesktopSettings = true; + DrawFencesToggle.IsChecked = S.DrawFences; + PageWheelBox.IsChecked = S.PageWheel; + PageWheelBox.IsEnabled = S.Pages.Count > 1; + + var res = Application.Current.Resources; + PageList.ItemsSource = S.Pages.Select((name, i) => + { + int boxes = S.Fences.Count(f => f.Page == i); + var info = (boxes == 1 ? "1 Box" : $"{boxes} Boxen") + (i == S.CurrentPage ? " · wird gerade angezeigt" : ""); + return new PageRow(i, name, info, (Brush)res[i == S.CurrentPage ? "AccentBrush" : "ControlStrongStrokeBrush"]); + }).ToList(); + + DesktopTintSwatches.Children.Clear(); + void AddSwatch(string? hex, Brush brush, string tip) + { + var rb = new RadioButton + { + Style = (Style)res["SwatchRadio"], GroupName = "desktoptint", Background = brush, ToolTip = tip, + IsChecked = S.DesktopTint == hex, + }; + rb.Checked += (_, _) => + { + if (_loadingDesktopSettings) return; + S.DesktopTint = hex; + S.Save(); + DesktopTintStrength.IsEnabled = hex is not null; + DesktopIcons.RefreshCache(); + DesktopTint.Refresh(force: true); + }; + DesktopTintSwatches.Children.Add(rb); + } + AddSwatch(null, Brushes.Transparent, "Keine Tönung"); + foreach (var (name, hex) in CategoryColors.All.Concat([("Grau", "#8A8A8A"), ("Weiß", "#F5F5F5")])) + AddSwatch(hex, CategoryColors.Brush(hex)!, name); + DesktopTintStrength.Value = Math.Round(S.DesktopTintStrength * 100); + DesktopTintStrength.IsEnabled = S.DesktopTint is not null; + _loadingDesktopSettings = false; + } + + private void DrawFencesToggle_Click(object sender, RoutedEventArgs e) + { + S.DrawFences = DrawFencesToggle.IsChecked == true; + S.Save(); + DesktopToggle.Apply(); + } + + private void PageWheel_Click(object sender, RoutedEventArgs e) + { + S.PageWheel = PageWheelBox.IsChecked == true; + S.Save(); + DesktopToggle.Apply(); + } + + private void DesktopTintStrength_ValueChanged(object sender, RoutedPropertyChangedEventArgs e) + { + if (_loadingDesktopSettings || !IsLoaded) return; + S.DesktopTintStrength = DesktopTintStrength.Value / 100; + S.Save(); + DesktopTint.Refresh(force: true); + } + + // ---------- Seiten ---------- + + private static string? ValidatePageName(string t, int except) => + t.Trim().Length == 0 ? "Bitte einen Namen eingeben." + : S.Pages.Where((_, i) => i != except).Any(p => string.Equals(p, t.Trim(), StringComparison.OrdinalIgnoreCase)) + ? "Diesen Namen gibt es schon." : null; + + private void AddPage_Click(object sender, RoutedEventArgs e) + { + var name = Dialog.Prompt(this, "Neue Desktop-Seite", "Name der Seite (z. B. „Arbeit“ oder „Freizeit“):", + $"Seite {S.Pages.Count + 1}", "Hinzufügen", t => ValidatePageName(t, -1)); + if (name is null) return; + S.Pages.Add(name.Trim()); + S.Save(); + DesktopToggle.Apply(); + LoadDesktopSettings(); + ShowToast($"Seite „{name.Trim()}“ hinzugefügt", + "Mit „Anzeigen“ wechselst du hin – neue Boxen landen immer auf der gerade angezeigten Seite."); + } + + private void ShowPage_Click(object sender, RoutedEventArgs e) + { + if ((sender as FrameworkElement)?.Tag is int i) FenceManager.SwitchPage(i); + LoadDesktopSettings(); + } + + private void RenamePage_Click(object sender, RoutedEventArgs e) + { + if ((sender as FrameworkElement)?.Tag is not int i || i >= S.Pages.Count) return; + var name = Dialog.Prompt(this, "Seite umbenennen", "Neuer Name:", S.Pages[i], "Umbenennen", t => ValidatePageName(t, i)); + if (name is null) return; + S.Pages[i] = name.Trim(); + S.Save(); + LoadDesktopSettings(); + } + + private void DeletePage_Click(object sender, RoutedEventArgs e) + { + if ((sender as FrameworkElement)?.Tag is not int i || i >= S.Pages.Count) return; + if (S.Pages.Count < 2) + { + ShowToast("Die letzte Seite kann nicht gelöscht werden", "", info: true); + return; + } + var name = S.Pages[i]; + int boxes = S.Fences.Count(f => f.Page == i); + if (boxes > 0 && !Dialog.Confirm(this, "Seite löschen", + $"Auf „{name}“ liegen {boxes} Box(en). Sie wandern auf die Seite „{S.Pages[i == 0 ? 1 : i - 1]}“.", + "Löschen")) + return; + FenceManager.DeletePage(i); + DesktopToggle.Apply(); + Refresh(); + LoadDesktopSettings(); + ShowToast($"Seite „{name}“ gelöscht", ""); + } +} diff --git a/MainWindow.xaml b/MainWindow.xaml index 99ca071..c2fddb4 100644 --- a/MainWindow.xaml +++ b/MainWindow.xaml @@ -1,676 +1,934 @@ - - - - - - - - - 108 - 116 - 48 - 0,14,0,0 - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +