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
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+ 108
+ 116
+ 48
+ 0,14,0,0
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/MainWindow.xaml.cs b/MainWindow.xaml.cs
index 8dd4fa1..92445ad 100644
--- a/MainWindow.xaml.cs
+++ b/MainWindow.xaml.cs
@@ -1,1503 +1,1570 @@
-using System.ComponentModel;
-using System.Diagnostics;
-using System.IO;
-using System.Runtime.InteropServices;
-using System.Windows;
-using System.Windows.Controls;
-using System.Windows.Controls.Primitives;
-using System.Windows.Input;
-using System.Windows.Interop;
-using System.Windows.Media;
-using System.Windows.Media.Animation;
-using System.Windows.Threading;
-
-namespace DeskCore;
-
-public partial class MainWindow : Window
-{
- private const string DesktopName = "Desktop";
- private const string CategoryDragFormat = "DeskCore.Kategorie";
-
- public UndoManager Undo { get; } = new();
-
- private readonly DispatcherTimer _toastTimer = new() { Interval = TimeSpan.FromSeconds(4) };
- private Point _dragStart;
- private bool _dragging;
- private bool _syncingSelection;
- private bool _loadingSettings;
- private string? _categoryDragName;
-
- private static AppSettings S => AppSettings.Current;
-
- public MainWindow()
- {
- InitializeComponent();
- RestorePlacement();
- StateChanged += (_, _) => UpdateWindowState();
- Closing += OnClosing;
- PreviewKeyDown += OnPreviewKeyDown;
- _toastTimer.Tick += (_, _) => { _toastTimer.Stop(); AnimateToast(false); };
- Theme.Changed += UpdateThemeButton;
- Undo.Changed += UpdateUndoButton;
- UpdateThemeButton();
- ApplyView(S.View, reload: false);
- StoragePathText.Text = $"Kategorien liegen in {Storage.Root}";
- var asm = typeof(MainWindow).Assembly;
- var version = asm.GetName().Version;
- AboutTitle.Text = $"DeskCore {version?.Major}.{version?.Minor}.{version?.Build}";
- AboutCopyright.Text = "© M_Viper · Nur für private, nicht-kommerzielle Nutzung";
- Refresh();
- }
-
- private CategoryItem? Current => (DesktopNav.SelectedItem ?? CategoryList.SelectedItem) as CategoryItem;
- private bool Searching => SearchBox.Text.Trim().Length > 0;
- private bool InSettings => SettingsView.Visibility == Visibility.Visible;
-
- // =====================================================================
- // Fenster, Infobereich, Tastenkürzel
- // =====================================================================
-
- private void Minimize_Click(object sender, RoutedEventArgs e) => WindowState = WindowState.Minimized;
- private void Maximize_Click(object sender, RoutedEventArgs e) =>
- WindowState = WindowState == WindowState.Maximized ? WindowState.Normal : WindowState.Maximized;
- private void Close_Click(object sender, RoutedEventArgs e) => Close();
-
- private void UpdateWindowState()
- {
- bool max = WindowState == WindowState.Maximized;
- // Maximierte Fenster ohne Rahmen ragen sonst über den Bildschirmrand
- RootGrid.Margin = max ? new Thickness(7) : new Thickness(0);
- MaxButton.Content = max ? "" : "";
- MaxButton.ToolTip = max ? "Verkleinern" : "Maximieren";
- }
-
- private void OnClosing(object? sender, CancelEventArgs e)
- {
- if (App.Quitting) return;
- SavePlacement();
- if (S.TrayEnabled && S.CloseToTray)
- {
- e.Cancel = true;
- Hide();
- if (!S.TrayHintShown)
- {
- S.TrayHintShown = true;
- S.Save();
- App.TrayIcon?.Notify("DeskCore läuft weiter",
- "Klick auf das Symbol hier unten öffnet die App wieder. Beenden: Rechtsklick → Beenden.");
- }
- return;
- }
- e.Cancel = true;
- App.Quit();
- }
-
- public void SetDimmed(bool dim) => DimOverlay.Visibility = dim ? Visibility.Visible : Visibility.Collapsed;
-
- public void ShowFromBackground()
- {
- if (!IsVisible) Show();
- if (WindowState == WindowState.Minimized) WindowState = WindowState.Normal;
- Activate();
- Topmost = true; // zuverlässig in den Vordergrund holen
- Topmost = false;
- Focus();
- }
-
- /// Globales Tastenkürzel: öffnen bzw. wieder verstecken.
- public void ToggleFromHotkey()
- {
- if (IsVisible && IsActive && WindowState != WindowState.Minimized)
- {
- if (S.TrayEnabled) Hide();
- else WindowState = WindowState.Minimized;
- return;
- }
- ShowFromBackground();
- ShowBrowse();
- SearchBox.Focus();
- SearchBox.SelectAll();
- }
-
- public void ShowCategory(string name)
- {
- ShowFromBackground();
- ShowBrowse();
- SearchBox.Text = "";
- Select(name);
- }
-
- /// Menü beim Rechtsklick auf das Infobereich-Symbol.
- public void ShowTrayMenu()
- {
- var menu = new ContextMenu { Placement = PlacementMode.MousePoint };
- menu.Items.Add(MenuItem("DeskCore öffnen", "", () => { ShowFromBackground(); ShowBrowse(); }, bold: true));
- var cats = Storage.Categories();
- if (cats.Count > 0)
- {
- menu.Items.Add(new Separator());
- foreach (var c in cats.Take(15))
- menu.Items.Add(MenuItem(c, S.Categories.GetValueOrDefault(c)?.Glyph ?? "", () => ShowCategory(c)));
- }
- menu.Items.Add(new Separator());
- if (Undo.CanUndo)
- menu.Items.Add(MenuItem($"Rückgängig: {Undo.NextDescription}", "", DoUndo));
- menu.Items.Add(MenuItem("Einstellungen", "", () => { ShowFromBackground(); ShowSettings(); }));
- menu.Items.Add(MenuItem("Beenden", "", App.Quit));
- // Damit das Menü bei Klick daneben zugeht, muss es im Vordergrund sein
- menu.Opened += (_, _) =>
- {
- if (PresentationSource.FromVisual(menu) is HwndSource src) SetForegroundWindow(src.Handle);
- };
- menu.IsOpen = true;
- }
-
- [DllImport("user32.dll")] private static extern bool SetForegroundWindow(IntPtr hwnd);
-
- // ---------- Design ----------
-
- private void UpdateThemeButton() => ThemeButton.Content = Theme.IsLight ? "" : ""; // Sonne / Mond
-
- private void ThemeButton_Click(object sender, RoutedEventArgs e)
- {
- ThemeMenu.Items.Clear();
- foreach (var (mode, label, glyph) in new[]
- {
- (DesignMode.Light, "Hell", ""),
- (DesignMode.Dark, "Dunkel", ""),
- (DesignMode.System, "Wie Windows", ""),
- })
- {
- var mi = MenuItem(label, glyph, () => SetDesign(mode), bold: S.Design == mode);
- if (S.Design == mode) mi.InputGestureText = "✓";
- ThemeMenu.Items.Add(mi);
- }
- ThemeMenu.PlacementTarget = ThemeButton;
- ThemeMenu.IsOpen = true;
- }
-
- private void SetDesign(DesignMode mode)
- {
- S.Design = mode;
- S.Save();
- Theme.Apply();
- LoadSettingsView();
- }
-
- // =====================================================================
- // Laden & Anzeigen
- // =====================================================================
-
- /// Vom Watcher: Dateien haben sich geändert.
- public void OnStorageChanged()
- {
- if (!_dragging && !IsBusy) Refresh(); // während großer Kopien nicht ständig neu laden
- }
-
- private void Refresh()
- {
- var selected = Current;
- var desktop = new CategoryItem { Name = DesktopName, IsDesktop = true, Count = Storage.DesktopEntries().Count() };
- var cats = Storage.Categories()
- .Select(n => new CategoryItem { Name = n, Count = Storage.Entries(Storage.CategoryPath(n)).Count() })
- .ToList();
-
- _syncingSelection = true;
- DesktopNav.ItemsSource = new[] { desktop };
- CategoryList.ItemsSource = cats;
- if (!InSettings)
- {
- var match = selected is { IsDesktop: false } ? cats.FirstOrDefault(c => c.Name == selected.Name) : null;
- if (match is not null) { CategoryList.SelectedItem = match; DesktopNav.SelectedItem = null; }
- else { DesktopNav.SelectedItem = desktop; CategoryList.SelectedItem = null; }
- }
- _syncingSelection = false;
-
- NoCategoriesText.Visibility = cats.Count == 0 ? Visibility.Visible : Visibility.Collapsed;
- LoadIcons();
- if (InSettings) LoadSettingsView();
- }
-
- private void Select(string categoryName)
- {
- var c = ((List)CategoryList.ItemsSource).FirstOrDefault(x =>
- string.Equals(x.Name, categoryName, StringComparison.OrdinalIgnoreCase));
- if (c is not null) CategoryList.SelectedItem = c;
- }
-
- private void LoadIcons()
- {
- if (InSettings) return;
- var cat = Current;
- var search = SearchBox.Text.Trim();
- var selectedPaths = IconList.SelectedItems.Cast().Select(i => i.Path).ToHashSet(StringComparer.OrdinalIgnoreCase);
- List items;
-
- if (search.Length > 0)
- {
- items = Storage.DesktopEntries().Select(p => new IconItem { Path = p, Category = DesktopName })
- .Concat(Storage.Categories().SelectMany(c =>
- Storage.Entries(Storage.CategoryPath(c)).Select(p => new IconItem { Path = p, Category = c })))
- .Where(i => i.Name.Contains(search, StringComparison.CurrentCultureIgnoreCase))
- .ToList();
- HeaderText.Text = "Suchergebnisse";
- SubText.Text = $"{Plural(items.Count, "Treffer", "Treffer")} für „{search}“";
- SetEmpty("", "Keine Treffer", $"Für „{search}“ wurde nichts gefunden.");
- }
- else if (cat is null)
- {
- items = [];
- }
- else
- {
- var paths = cat.IsDesktop ? Storage.DesktopEntries() : Storage.Entries(cat.Folder);
- // Welche Desktop-Icons stecken schon (als Kopie) in einer Kategorie? (Name oder gleiches Verknüpfungsziel)
- var catEntries = cat.IsDesktop
- ? Storage.Categories().ToDictionary(c => c, c => Storage.Entries(Storage.CategoryPath(c)).ToList())
- : [];
- items = paths.Select(p => new IconItem
- {
- Path = p,
- SortedInto = cat.IsDesktop ? Storage.FindCategoryOf(p, catEntries) : null,
- }).ToList();
-
- HeaderText.Text = cat.Name;
- var done = items.Count(i => i.IsSorted);
- SubText.Text = Plural(items.Count, "Element", "Elemente") + (done > 0 ? $" · {done} davon gesichert" : "");
- if (cat.IsDesktop)
- SetEmpty("", "Alles aufgeräumt", "Auf deinem Desktop liegt nichts mehr.");
- else
- SetEmpty(cat.Glyph, "Noch leer", $"Ziehe Icons vom Desktop hierher oder auf „{cat.Name}“ in der Seitenleiste.");
- }
-
- items = items.OrderBy(i => !Directory.Exists(i.Path))
- .ThenBy(i => i.Name, StringComparer.CurrentCultureIgnoreCase).ToList();
- var px = IconPixels;
- foreach (var item in items) IconLoader.Load(item, px);
- IconList.ItemsSource = items;
- foreach (var i in items.Where(i => selectedPaths.Contains(i.Path))) IconList.SelectedItems.Add(i);
-
- EmptyState.Visibility = items.Count == 0 && (cat is not null || search.Length > 0) ? Visibility.Visible : Visibility.Collapsed;
- CategoryCommands.Visibility = !Searching && cat is { IsDesktop: false } ? Visibility.Visible : Visibility.Collapsed;
- InfoBar.Visibility = !Searching && cat is { IsDesktop: true } && items.Any(i => !i.IsSorted)
- ? Visibility.Visible : Visibility.Collapsed;
- if (cat is { IsDesktop: false })
- FenceToggleText.Text = cat.HasFence ? "Box vom Desktop entfernen" : "Als Box auf den Desktop";
- }
-
- private void SetEmpty(string glyph, string title, string text)
- {
- EmptyGlyph.Text = glyph;
- EmptyTitle.Text = title;
- EmptyText.Text = text;
- }
-
- private static string Plural(int n, string one, string many) => $"{n} {(n == 1 ? one : many)}";
-
- private void Nav_SelectionChanged(object sender, SelectionChangedEventArgs e)
- {
- if (_syncingSelection) return;
- var list = (ListBox)sender;
- if (list.SelectedItem is null) return;
-
- // Nur ein Eintrag in allen Listen gleichzeitig ausgewählt
- _syncingSelection = true;
- if (list != DesktopNav) DesktopNav.SelectedItem = null;
- if (list != CategoryList) CategoryList.SelectedItem = null;
- SettingsNav.SelectedItem = null;
- _syncingSelection = false;
-
- SettingsView.Visibility = Visibility.Collapsed;
- BrowseView.Visibility = Visibility.Visible;
- IconList.SelectedItems.Clear();
- if (Searching) SearchBox.Text = ""; // löst LoadIcons aus
- else LoadIcons();
- }
-
- private void SettingsNav_SelectionChanged(object sender, SelectionChangedEventArgs e)
- {
- if (_syncingSelection || SettingsNav.SelectedItem is null) return;
- ShowSettings();
- }
-
- private void ShowSettings()
- {
- _syncingSelection = true;
- DesktopNav.SelectedItem = null;
- CategoryList.SelectedItem = null;
- SettingsNav.SelectedIndex = 0;
- _syncingSelection = false;
- BrowseView.Visibility = Visibility.Collapsed;
- SettingsView.Visibility = Visibility.Visible;
- LoadSettingsView();
- // erst nach dem Layout nach oben scrollen, sonst springt die Seite wieder zurück
- Dispatcher.BeginInvoke(SettingsView.ScrollToTop, DispatcherPriority.ContextIdle);
- }
-
- private void ShowBrowse()
- {
- if (!InSettings) return;
- _syncingSelection = true;
- SettingsNav.SelectedItem = null;
- DesktopNav.SelectedIndex = 0;
- _syncingSelection = false;
- SettingsView.Visibility = Visibility.Collapsed;
- BrowseView.Visibility = Visibility.Visible;
- LoadIcons();
- }
-
- private void SearchBox_TextChanged(object sender, TextChangedEventArgs e)
- {
- SearchPlaceholder.Visibility = SearchBox.Text.Length == 0 ? Visibility.Visible : Visibility.Collapsed;
- ClearSearchBtn.Visibility = SearchBox.Text.Length > 0 ? Visibility.Visible : Visibility.Collapsed;
- LoadIcons();
- }
-
- private void ClearSearch_Click(object sender, RoutedEventArgs e) { SearchBox.Text = ""; SearchBox.Focus(); }
- private void Refresh_Click(object sender, RoutedEventArgs e) => Refresh();
-
- private void OpenFolder_Click(object sender, RoutedEventArgs e)
- {
- if (Current is { } cat) Process.Start("explorer.exe", $"\"{cat.Folder}\"");
- }
-
- // ---------- Ansicht ----------
-
- private int IconPixels => (int)Math.Round(S.View switch
- {
- ViewMode.Small => 32,
- ViewMode.Large => 72,
- ViewMode.List => 24,
- _ => 48,
- } * VisualTreeHelper.GetDpi(this).DpiScaleX);
-
- private void ApplyView(ViewMode mode, bool reload = true)
- {
- S.View = mode;
- var (w, h, icon, top) = mode switch
- {
- ViewMode.Small => (88.0, 92.0, 32.0, 10.0),
- ViewMode.Large => (136.0, 150.0, 72.0, 14.0),
- _ => (108.0, 116.0, 48.0, 14.0),
- };
- Resources["TileWidth"] = w;
- Resources["TileHeight"] = h;
- Resources["TileIcon"] = icon;
- Resources["TileIconMargin"] = new Thickness(0, top, 0, 0);
-
- bool list = mode == ViewMode.List;
- IconList.ItemTemplate = (DataTemplate)FindResource(list ? "ListTemplate" : "TileTemplate");
- IconList.ItemsPanel = (ItemsPanelTemplate)FindResource(list ? "StackPanelTemplate" : "WrapPanelTemplate");
- IconList.ItemContainerStyle = (Style)FindResource(list ? "RowItem" : "TileItem");
- ListHeader.Visibility = list ? Visibility.Visible : Visibility.Collapsed;
- ViewGlyph.Text = mode switch { ViewMode.Small => "", ViewMode.List => "", _ => "" };
- if (reload) LoadIcons();
- }
-
- private void ViewButton_Click(object sender, RoutedEventArgs e)
- {
- ViewMenu.Items.Clear();
- foreach (var (mode, label, glyph) in new[]
- {
- (ViewMode.Small, "Kleine Symbole", ""),
- (ViewMode.Medium, "Mittelgroße Symbole", ""),
- (ViewMode.Large, "Große Symbole", ""),
- (ViewMode.List, "Liste", ""),
- })
- {
- var mi = MenuItem(label, glyph, () => { ApplyView(mode); S.Save(); }, bold: S.View == mode);
- if (S.View == mode) mi.InputGestureText = "✓";
- ViewMenu.Items.Add(mi);
- }
- ViewMenu.PlacementTarget = ViewButton;
- ViewMenu.IsOpen = true;
- }
-
- // =====================================================================
- // Kategorien
- // =====================================================================
-
- private record CategoryForm(string Name, string? Glyph, string? Color, bool Fence);
-
- /// Dialog zum Anlegen/Bearbeiten einer Kategorie.
- private CategoryForm? EditCategoryDialog(string title, string primary, string? current)
- {
- var meta = current is null ? null : S.Categories.GetValueOrDefault(current);
- var res = Application.Current.Resources;
- var panel = new StackPanel();
-
- TextBlock Label(string t) => new()
- {
- Text = t, Margin = new Thickness(0, 16, 0, 8), FontWeight = FontWeights.SemiBold, FontSize = 13,
- };
-
- var nameBox = new TextBox { Text = current ?? "" };
- panel.Children.Add(new TextBlock { Text = "Name", Margin = new Thickness(0, 0, 0, 8), FontWeight = FontWeights.SemiBold, FontSize = 13 });
- panel.Children.Add(nameBox);
-
- string? color = meta?.Color;
- var glyphButtons = new List();
- void PaintGlyphs()
- {
- var b = CategoryColors.Brush(color) ?? (Brush)res["TextBrush"];
- foreach (var g in glyphButtons) g.Foreground = b;
- }
-
- // Symbol
- string? glyph = meta?.Glyph ?? "";
- panel.Children.Add(Label("Symbol"));
- var glyphs = new WrapPanel();
- foreach (var (g, name) in CategoryGlyphs.All)
- {
- var rb = new RadioButton
- {
- Content = g, ToolTip = name, GroupName = "glyph", Style = (Style)res["GlyphRadio"],
- IsChecked = g == glyph,
- };
- rb.Checked += (_, _) => glyph = g;
- glyphButtons.Add(rb);
- glyphs.Children.Add(rb);
- }
- panel.Children.Add(glyphs);
-
- // Farbe
- panel.Children.Add(Label("Farbe"));
- var swatches = new WrapPanel();
- var none = new RadioButton
- {
- Style = (Style)res["SwatchRadio"], GroupName = "color", ToolTip = "Keine Farbe",
- Background = Brushes.Transparent, IsChecked = color is null,
- Content = new TextBlock { Text = "", FontFamily = (FontFamily)res["IconFont"], FontSize = 10, Foreground = (Brush)res["TextSecondaryBrush"] },
- };
- none.Checked += (_, _) => { color = null; PaintGlyphs(); };
- swatches.Children.Add(none);
- foreach (var (cname, hex) in CategoryColors.All)
- {
- var rb = new RadioButton
- {
- Style = (Style)res["SwatchRadio"], GroupName = "color", ToolTip = cname,
- Background = CategoryColors.Brush(hex), IsChecked = string.Equals(color, hex, StringComparison.OrdinalIgnoreCase),
- };
- rb.Checked += (_, _) => { color = hex; PaintGlyphs(); };
- swatches.Children.Add(rb);
- }
- panel.Children.Add(swatches);
- PaintGlyphs();
-
- var fence = new CheckBox
- {
- Content = "Als Box auf dem Desktop anzeigen", IsChecked = meta?.Fence == true, Margin = new Thickness(0, 14, 0, 0),
- };
- panel.Children.Add(fence);
-
- string? Validate()
- {
- if (Storage.ValidateName(nameBox.Text) is { } err) return err;
- var name = nameBox.Text.Trim();
- bool same = string.Equals(name, current, StringComparison.OrdinalIgnoreCase);
- return Directory.Exists(Storage.CategoryPath(name)) && !same ? "Eine Kategorie mit diesem Namen gibt es schon." : null;
- }
-
- if (!Dialog.Show(this, title, panel, primary, "Abbrechen", Validate, nameBox, width: 470)) return null;
- return new CategoryForm(nameBox.Text.Trim(), glyph == "" ? null : glyph, color, fence.IsChecked == true);
- }
-
- private string? NewCategory()
- {
- var form = EditCategoryDialog("Neue Kategorie", "Erstellen", null);
- if (form is null) return null;
- var path = Storage.CategoryPath(form.Name);
- Directory.CreateDirectory(path);
- var m = S.Meta(form.Name);
- m.Glyph = form.Glyph;
- m.Color = form.Color;
- m.Fence = form.Fence;
- if (form.Fence) S.FencesEnabled = true;
- if (!S.CategoryOrder.Contains(form.Name, StringComparer.OrdinalIgnoreCase)) S.CategoryOrder.Add(form.Name);
- S.Save();
-
- var entry = new UndoEntry($"Kategorie „{form.Name}“ erstellen");
- entry.Add(() =>
- {
- if (Directory.Exists(path) && !Directory.EnumerateFileSystemEntries(path).Any()) Directory.Delete(path);
- S.RemoveCategory(form.Name);
- S.Save();
- return null;
- });
- Undo.Push(entry);
-
- Refresh();
- FenceManager.Sync();
- return form.Name;
- }
-
- private void NewCategory_Click(object sender, RoutedEventArgs e)
- {
- if (NewCategory() is { } name)
- {
- ShowBrowse();
- Select(name);
- ShowToast($"Kategorie „{name}“ erstellt", undoable: true);
- }
- }
-
- private void EditCategory_Click(object sender, RoutedEventArgs e)
- {
- if (Current is { IsDesktop: false } cat) EditCategory(cat.Name);
- }
-
- private void EditCategory(string oldName)
- {
- var form = EditCategoryDialog("Kategorie bearbeiten", "Speichern", oldName);
- if (form is null) return;
-
- var old = S.Meta(oldName);
- var before = (old.Glyph, old.Color, old.Fence);
- var entry = new UndoEntry($"Kategorie „{oldName}“ bearbeiten");
-
- if (form.Name != oldName)
- {
- try
- {
- Storage.Rename(Storage.CategoryPath(oldName), form.Name);
- }
- catch (Exception ex) { Error("Umbenennen fehlgeschlagen", ex.Message); return; }
- S.RenameCategory(oldName, form.Name);
- FenceManager.Renamed(oldName, form.Name);
- var newName = form.Name;
- entry.Add(() =>
- {
- Storage.Rename(Storage.CategoryPath(newName), oldName);
- S.RenameCategory(newName, oldName);
- FenceManager.Renamed(newName, oldName);
- return null;
- });
- }
-
- var m = S.Meta(form.Name);
- m.Glyph = form.Glyph;
- m.Color = form.Color;
- m.Fence = form.Fence;
- if (form.Fence) S.FencesEnabled = true;
- var nameNow = form.Name;
- entry.Add(() =>
- {
- var mm = S.Meta(nameNow);
- (mm.Glyph, mm.Color, mm.Fence) = before;
- S.Save();
- return null;
- });
- S.Save();
- Undo.Push(entry);
-
- Refresh();
- Select(form.Name);
- FenceManager.Sync();
- ShowToast("Kategorie gespeichert", undoable: true);
- }
-
- private void FenceToggle_Click(object sender, RoutedEventArgs e)
- {
- if (Current is { IsDesktop: false } cat) ToggleFence(cat.Name);
- }
-
- private void ToggleFence(string name)
- {
- var m = S.Meta(name);
- m.Fence = !m.Fence;
- if (m.Fence) S.FencesEnabled = true;
- S.Save();
- FenceManager.Sync();
- Refresh();
- ShowToast(m.Fence ? $"„{name}“ wird jetzt als Box auf dem Desktop angezeigt" : $"Box „{name}“ ausgeblendet", "");
- }
-
- private void DeleteCategory_Click(object sender, RoutedEventArgs e)
- {
- if (Current is { IsDesktop: false } cat) DeleteCategory(cat.Name);
- }
-
- private void DeleteCategory(string name)
- {
- var folder = Storage.CategoryPath(name);
- var entries = Storage.Entries(folder).ToList();
- var msg = entries.Count == 0
- ? $"Die leere Kategorie „{name}“ wird entfernt."
- : $"Die {Plural(entries.Count, "Datei", "Dateien")} aus „{name}“ {(entries.Count == 1 ? "wird" : "werden")} zurück auf den Desktop gelegt – " +
- "außer sie liegen dort schon. Mit Strg+Z kannst du das rückgängig machen.";
- if (!Dialog.Confirm(this, "Kategorie löschen?", msg, "Löschen")) return;
-
- var entry = new UndoEntry($"Kategorie „{name}“ löschen");
- var errors = new List();
- foreach (var p in entries)
- {
- var r = Storage.Transfer(p, Storage.UserDesktop);
- if (r.Status == TransferStatus.Ok) entry.AddMove(p, r.Dest!);
- else if (r.Status == TransferStatus.Skipped)
- {
- // liegt schon auf dem Desktop → Kopie in der Kategorie wird nicht mehr gebraucht
- try { entry.AddDelete(p, Storage.Delete(p)); }
- catch (Exception ex) { errors.Add(ex.Message); }
- }
- else errors.Add(r.Message!);
- }
- if (errors.Count > 0)
- {
- Undo.Push(entry);
- Refresh();
- ShowErrors(errors);
- return;
- }
-
- var meta = S.Categories.GetValueOrDefault(name);
- var orderIndex = S.CategoryOrder.FindIndex(o => string.Equals(o, name, StringComparison.OrdinalIgnoreCase));
- try { Directory.Delete(folder, recursive: true); } // nur noch versteckte Reste wie desktop.ini
- catch (Exception ex) { Error("Löschen fehlgeschlagen", ex.Message); }
- S.RemoveCategory(name);
- S.Save();
- entry.Add(() =>
- {
- Directory.CreateDirectory(folder);
- if (meta is not null) S.Categories[name] = meta;
- if (orderIndex >= 0) S.CategoryOrder.Insert(Math.Min(orderIndex, S.CategoryOrder.Count), name);
- S.Save();
- return null;
- });
- Undo.Push(entry);
-
- CategoryList.SelectedItem = null;
- DesktopNav.SelectedIndex = 0;
- Refresh();
- FenceManager.Sync();
- ShowToast($"Kategorie „{name}“ gelöscht", "", undoable: true);
- }
-
- private void CategoryList_ContextMenuOpening(object sender, ContextMenuEventArgs e)
- {
- if (FindParent(e.OriginalSource as DependencyObject)?.DataContext is not CategoryItem cat)
- {
- e.Handled = true;
- return;
- }
- CategoryList.SelectedItem = cat;
- var names = Storage.Categories();
- int idx = names.FindIndex(n => n == cat.Name);
-
- CategoryMenu.Items.Clear();
- CategoryMenu.Items.Add(MenuItem("Bearbeiten…", "", () => EditCategory(cat.Name)));
- CategoryMenu.Items.Add(MenuItem(cat.HasFence ? "Box vom Desktop entfernen" : "Als Box auf den Desktop", "", () => ToggleFence(cat.Name)));
- CategoryMenu.Items.Add(MenuItem("Im Explorer öffnen", "", () => Process.Start("explorer.exe", $"\"{cat.Folder}\"")));
- CategoryMenu.Items.Add(new Separator());
- var up = MenuItem("Nach oben", "", () => MoveCategory(cat.Name, idx - 1));
- up.IsEnabled = idx > 0;
- var down = MenuItem("Nach unten", "", () => MoveCategory(cat.Name, idx + 1));
- down.IsEnabled = idx < names.Count - 1;
- CategoryMenu.Items.Add(up);
- CategoryMenu.Items.Add(down);
- CategoryMenu.Items.Add(new Separator());
- CategoryMenu.Items.Add(MenuItem("Löschen", "", () => DeleteCategory(cat.Name)));
- }
-
- /// Kategorie an eine neue Position der Reihenfolge setzen.
- private void MoveCategory(string name, int newIndex)
- {
- var names = Storage.Categories();
- names.RemoveAll(n => n == name);
- newIndex = Math.Clamp(newIndex, 0, names.Count);
- names.Insert(newIndex, name);
- S.CategoryOrder = names;
- S.Save();
- Refresh();
- Select(name);
- }
-
- private void CategoryList_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e)
- {
- _dragStart = e.GetPosition(null);
- _categoryDragName = (FindParent(e.OriginalSource as DependencyObject)?.DataContext as CategoryItem)?.Name;
- }
-
- private void CategoryList_PreviewMouseMove(object sender, MouseEventArgs e)
- {
- if (e.LeftButton != MouseButtonState.Pressed || _categoryDragName is null || _dragging) return;
- var d = e.GetPosition(null) - _dragStart;
- if (Math.Abs(d.Y) < SystemParameters.MinimumVerticalDragDistance * 2) return;
- var name = _categoryDragName;
- _categoryDragName = null;
- _dragging = true;
- try { DragDrop.DoDragDrop(CategoryList, new DataObject(CategoryDragFormat, name), DragDropEffects.Move); }
- finally { _dragging = false; SetDropHighlight(null); }
- }
-
- // =====================================================================
- // Icons
- // =====================================================================
-
- private List SelectedIcons => IconList.SelectedItems.Cast().ToList();
-
- private void IconList_MouseDoubleClick(object sender, MouseButtonEventArgs e)
- {
- if (ItemAt(e.OriginalSource) is IconItem item) Open(item.Path);
- }
-
- private void IconList_KeyDown(object sender, KeyEventArgs e)
- {
- var sel = SelectedIcons;
- if (sel.Count == 0) return;
- switch (e.Key)
- {
- case Key.Enter: sel.ForEach(i => Open(i.Path)); e.Handled = true; break;
- case Key.Delete: DeleteIcons(sel); e.Handled = true; break;
- case Key.F2 when sel.Count == 1: RenameIcon(sel[0]); e.Handled = true; break;
- }
- }
-
- private void Open(string path)
- {
- try { Process.Start(new ProcessStartInfo(path) { UseShellExecute = true }); }
- catch (Exception ex) { Error("Öffnen fehlgeschlagen", $"„{Path.GetFileName(path)}“ konnte nicht geöffnet werden.\n\n{ex.Message}"); }
- }
-
- private void IconList_ContextMenuOpening(object sender, ContextMenuEventArgs e)
- {
- var sel = SelectedIcons;
- if (sel.Count == 0) { e.Handled = true; return; }
- IconMenu.Items.Clear();
-
- 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(MenuItem("Umbenennen", "", () => RenameIcon(sel[0]), gesture: "F2"));
- }
- IconMenu.Items.Add(new Separator());
-
- bool fromApp = sel.All(i => Storage.IsInStorage(i.Path));
- var moveTo = MenuItem(fromApp ? "Verschieben nach" : "Sichern in", fromApp ? "" : "", null);
- foreach (var c in Storage.Categories())
- moveTo.Items.Add(MenuItem(c, S.Categories.GetValueOrDefault(c)?.Glyph ?? "",
- () => TransferAll(sel.Select(i => i.Path), Storage.CategoryPath(c))));
- if (moveTo.Items.Count > 0) moveTo.Items.Add(new Separator());
- moveTo.Items.Add(MenuItem("Neue Kategorie…", "", () =>
- {
- if (NewCategory() is { } name) TransferAll(sel.Select(i => i.Path), Storage.CategoryPath(name));
- }));
- IconMenu.Items.Add(moveTo);
-
- if (!fromApp && sel.Count == 1)
- IconMenu.Items.Add(MenuItem("Regel für ähnliche Icons…", "", () => CreateRuleFor(sel[0])));
-
- IconMenu.Items.Add(new Separator());
- if (fromApp)
- IconMenu.Items.Add(MenuItem("Zurück auf den Desktop legen", "", () => TransferAll(sel.Select(i => i.Path), Storage.UserDesktop)));
- IconMenu.Items.Add(MenuItem(fromApp ? "Aus der App löschen" : "Vom Desktop löschen", "", () => DeleteIcons(sel), gesture: "Entf"));
- }
-
- public void RenameIcon(IconItem item)
- {
- var ext = Storage.HiddenExtension(item.Path);
- var newName = Dialog.Prompt(this, "Umbenennen", "Neuer Name:", item.Name, "Umbenennen", text =>
- {
- if (Storage.ValidateName(text) is { } err) return err;
- var target = Path.Combine(Path.GetDirectoryName(item.Path)!, text.Trim() + ext);
- return !string.Equals(target, item.Path, StringComparison.OrdinalIgnoreCase) && (File.Exists(target) || Directory.Exists(target))
- ? "Ein Element mit diesem Namen gibt es dort schon." : null;
- });
- if (newName is null || newName == item.Name) return;
- try
- {
- var dest = Storage.Rename(item.Path, newName + ext);
- var entry = new UndoEntry($"„{item.Name}“ umbenennen");
- entry.Add(() => { Storage.Rename(dest, Path.GetFileName(item.Path)); return null; });
- Undo.Push(entry);
- Refresh();
- ShowToast($"Umbenannt in „{newName}“", "", undoable: true);
- }
- catch (UnauthorizedAccessException) { Error("Umbenennen fehlgeschlagen", "Keine Berechtigung (öffentlicher Desktop → App als Administrator starten)."); }
- catch (Exception ex) { Error("Umbenennen fehlgeschlagen", ex.Message); }
- }
-
- public void DeleteIcons(List sel)
- {
- // Ungesicherte Desktop-Icons: nachfragen. Alles andere geht direkt (Strg+Z holt es zurück).
- var unsorted = sel.Count(i => !Storage.IsInStorage(i.Path) && !i.IsSorted);
- if (unsorted > 0)
- {
- var what = unsorted == 1 && sel.Count == 1 ? $"„{sel[0].Name}“ ist" : $"{unsorted} der ausgewählten Icons {(unsorted == 1 ? "ist" : "sind")}";
- if (!Dialog.Confirm(this, "Wirklich löschen?",
- $"{what} noch in keiner Kategorie gesichert. Trotzdem löschen?\n\nSie landen im Papierkorb.", "Löschen"))
- return;
- }
-
- var entry = new UndoEntry(sel.Count == 1 ? $"„{sel[0].Name}“ löschen" : $"{sel.Count} Elemente löschen");
- var errors = new List();
- foreach (var i in sel)
- {
- try { entry.AddDelete(i.Path, Storage.Delete(i.Path)); }
- catch (UnauthorizedAccessException) { errors.Add($"„{i.Name}“: Keine Berechtigung (öffentlicher Desktop → App als Administrator starten)."); }
- catch (Exception ex) { errors.Add($"„{i.Name}“: {ex.Message}"); }
- }
- Undo.Push(entry);
- Refresh();
- if (errors.Count > 0) ShowErrors(errors);
- else ShowToast($"{Plural(sel.Count, "Element", "Elemente")} gelöscht", "", undoable: true);
- }
-
- ///
- /// Kopiert/verschiebt Dateien in einen Ordner – mit Rückgängig und Rückmeldung.
- /// Läuft im Hintergrund; bei großen Datenmengen erscheint ein Fortschritt mit Abbrechen.
- ///
- public async void TransferAll(IEnumerable paths, string targetDir)
- {
- if (IsBusy) { ShowToast("Bitte warten, bis der laufende Vorgang fertig ist", "\uE946", info: true); return; }
- var list = paths.ToList();
- if (list.Count == 0) return;
- var target = Storage.IsDesktopDir(targetDir) ? DesktopName : Path.GetFileName(targetDir.TrimEnd('\\'));
-
- // Große Ordner würden beim Kopieren doppelt Platz belegen → lieber verschieben?
- var bigFolders = new HashSet(StringComparer.OrdinalIgnoreCase);
- if (Storage.IsInStorage(targetDir))
- {
- const long Limit = 200L * 1024 * 1024;
- Mouse.OverrideCursor = Cursors.Wait;
- List<(string Path, long Size)> big;
- try
- {
- big = await Task.Run(() => list.Where(p => Directory.Exists(p) && !Storage.IsInStorage(p))
- .Select(p => (Path: p, Size: Storage.DirectorySize(p, Limit * 50)))
- .Where(x => x.Size > Limit).ToList());
- }
- finally { Mouse.OverrideCursor = null; }
- if (big.Count > 0)
- {
- var what = big.Count == 1
- ? $"Der Ordner „{Path.GetFileName(big[0].Path)}“ ist {(big[0].Size >= Limit * 50 ? "über " : "")}{Backup.FormatSize(big[0].Size)} groß."
- : $"{big.Count} Ordner sind zusammen {Backup.FormatSize(big.Sum(b => b.Size))} groß.";
- if (Dialog.Confirm(this, "Großer Ordner",
- $"{what}\n\nBeim Kopieren würde er doppelt Speicherplatz belegen. Verschieben legt ihn nur in die Kategorie " +
- "(er verschwindet dann vom Desktop).", "Verschieben", "Trotzdem kopieren"))
- foreach (var b in big) bigFolders.Add(b.Path);
- }
- }
-
- bool anyCopy = list.Any(p => !bigFolders.Contains(p) && !Storage.IsInStorage(p) && Storage.IsInStorage(targetDir));
- var detail = list.Count == 1 ? $"„{Storage.DisplayName(list[0])}“ → {target}" : $"{list.Count} Elemente → {target}";
- List results;
- try
- {
- results = await RunWithProgress(anyCopy ? "Wird kopiert …" : "Wird verschoben …", detail, (sink, ct) =>
- {
- sink.SetTotal(list.Sum(p => Storage.BytesToCopy(p, targetDir, bigFolders.Contains(p))));
- var r = new List();
- foreach (var p in list)
- {
- if (list.Count > 1) sink.Detail = $"„{Storage.DisplayName(p)}“ → {target}";
- r.Add(Storage.Transfer(p, targetDir, bigFolders.Contains(p), sink.Add, ct));
- }
- return Task.FromResult(r);
- });
- }
- catch (Exception ex)
- {
- Refresh();
- Error("Übertragen fehlgeschlagen", ex.Message);
- return;
- }
-
- var entry = new UndoEntry($"{Plural(list.Count, "Element", "Elemente")} nach „{target}“");
- foreach (var r in results.Where(r => r.Status == TransferStatus.Ok))
- {
- if (r.Copied) entry.AddCopy(r.Source, r.Dest!);
- else entry.AddMove(r.Source, r.Dest!);
- }
- Undo.Push(entry);
- Refresh();
- FenceManager.RefreshAll();
-
- var ok = results.Where(r => r.Status == TransferStatus.Ok).ToList();
- var skipped = results.Where(r => r.Status == TransferStatus.Skipped).ToList();
- var cancelled = results.Count(r => r.Status == TransferStatus.Cancelled);
- var errors = results.Where(r => r.Status == TransferStatus.Error).Select(r => r.Message!).ToList();
- if (errors.Count > 0) { ShowErrors(errors); return; }
-
- if (cancelled > 0)
- {
- ShowToast(ok.Count > 0 ? $"Abgebrochen – {Plural(ok.Count, "Element", "Elemente")} wurden noch übertragen" : "Abgebrochen – es wurde nichts verändert",
- "\uE711", undoable: ok.Count > 0, info: true);
- return;
- }
- if (ok.Count == 0 && skipped.Count > 0)
- {
- ShowToast(skipped.Count == 1 ? skipped[0].Message! : $"Alle {skipped.Count} Elemente sind schon in „{target}“", "\uE946", info: true);
- return;
- }
- var n = Plural(ok.Count, "Element", "Elemente");
- var extra = skipped.Count > 0 ? $" · {skipped.Count} waren schon da" : "";
- if (Storage.IsDesktopDir(targetDir))
- ShowToast($"{n} zurück auf den Desktop gelegt{extra}", "\uE7F4", undoable: true);
- else if (ok.All(r => r.Copied))
- ShowToast($"{n} in „{target}“ gesichert{extra}", undoable: true);
- else
- ShowToast($"{n} nach „{target}“ verschoben{extra}", "\uE8DE", undoable: true);
- }
-
- // ---------- Rückgängig ----------
-
- private void Undo_Click(object sender, RoutedEventArgs e) => DoUndo();
-
- private void DoUndo()
- {
- if (Undo.Undo() is not { } r) return;
- Refresh();
- FenceManager.Sync();
- if (r.Errors.Count > 0) ShowErrors(r.Errors);
- else ShowToast($"Rückgängig: {r.Description}", "");
- }
-
- private void UpdateUndoButton()
- {
- UndoButton.IsEnabled = Undo.CanUndo;
- UndoButton.ToolTip = Undo.CanUndo ? $"Rückgängig: {Undo.NextDescription} (Strg+Z)" : "Rückgängig (Strg+Z)";
- }
-
- // =====================================================================
- // Drag & Drop
- // =====================================================================
-
- private void IconList_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) => _dragStart = e.GetPosition(null);
-
- private void IconList_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 (ItemAt(e.OriginalSource) is null) return;
-
- var paths = SelectedIcons.Select(i => i.Path).ToArray();
- if (paths.Length == 0) return;
- _dragging = true;
- try
- {
- // FileDrop: funktioniert intern und auch zum Rausziehen auf Desktop/Explorer.
- // Nur "Copy" erlauben, damit der Explorer die Dateien nicht aus der App entfernt.
- DragDrop.DoDragDrop(IconList, new DataObject(DataFormats.FileDrop, paths), DragDropEffects.Copy);
- }
- finally { _dragging = false; }
- Refresh();
- }
-
- private static string[]? DroppedFiles(DragEventArgs e) =>
- e.Data.GetDataPresent(DataFormats.FileDrop) ? e.Data.GetData(DataFormats.FileDrop) as string[] : null;
-
- private static DragDropEffects DropEffect(DragEventArgs e) =>
- e.AllowedEffects.HasFlag(DragDropEffects.Copy) ? DragDropEffects.Copy : DragDropEffects.Move;
-
- private void IconList_DragOver(object sender, DragEventArgs e)
- {
- e.Effects = DroppedFiles(e) is not null && Current is not null && !Searching ? DropEffect(e) : DragDropEffects.None;
- e.Handled = true;
- }
-
- private void IconList_Drop(object sender, DragEventArgs e)
- {
- if (DroppedFiles(e) is { } files && Current is { } cat && !Searching)
- TransferAll(files, cat.Folder);
- }
-
- private ListBoxItem? _dropHighlight;
-
- private void Nav_DragOver(object sender, DragEventArgs e)
- {
- var lbi = FindParent(e.OriginalSource as DependencyObject);
- if (e.Data.GetDataPresent(CategoryDragFormat))
- {
- // Kategorie umsortieren: nur innerhalb der Kategorienliste
- bool ok = sender == CategoryList && lbi is not null;
- // Linie über oder unter dem Eintrag zeigen, je nachdem wo die Maus ist
- SetDropHighlight(ok ? lbi : null, ok && e.GetPosition(lbi).Y > lbi!.ActualHeight / 2 ? "after" : "before");
- e.Effects = ok ? DragDropEffects.Move : DragDropEffects.None;
- }
- else
- {
- SetDropHighlight(lbi);
- e.Effects = DroppedFiles(e) is not null && lbi is not null ? DropEffect(e) : DragDropEffects.None;
- }
- e.Handled = true;
- }
-
- private void Nav_DragLeave(object sender, DragEventArgs e) => SetDropHighlight(null);
-
- private void Nav_Drop(object sender, DragEventArgs e)
- {
- SetDropHighlight(null);
- var lbi = FindParent(e.OriginalSource as DependencyObject);
- if (lbi?.DataContext is not CategoryItem target) return;
-
- if (e.Data.GetData(CategoryDragFormat) is string dragged)
- {
- if (target.IsDesktop || dragged == target.Name) return;
- var names = Storage.Categories();
- int targetIdx = names.IndexOf(target.Name);
- int fromIdx = names.IndexOf(dragged);
- // In der unteren Hälfte losgelassen → dahinter einsortieren
- bool after = e.GetPosition(lbi).Y > lbi.ActualHeight / 2;
- int idx = targetIdx + (after ? 1 : 0) - (fromIdx < targetIdx ? 1 : 0);
- MoveCategory(dragged, idx);
- return;
- }
- if (DroppedFiles(e) is { } files) TransferAll(files, target.Folder);
- }
-
- private void SetDropHighlight(ListBoxItem? lbi, string tag = "drop")
- {
- if (_dropHighlight is not null && _dropHighlight != lbi) _dropHighlight.Tag = null;
- _dropHighlight = lbi;
- if (lbi is not null) lbi.Tag = tag;
- }
-
- // =====================================================================
- // Automatisch sortieren
- // =====================================================================
-
- /// Vom Watcher: neue Elemente auf dem Desktop.
- public void AutoSortNew(List paths)
- {
- if (!S.AutoSort || S.Rules.Count == 0) return;
- var done = ApplyRules(paths, "Automatisch einsortiert");
- if (done.Count == 0) return;
-
- var text = done.Count == 1 ? $"„{Storage.DisplayName(done[0].Path)}“ → {done[0].Category}" : $"{done.Count} neue Icons einsortiert";
- if (IsVisible && IsActive) ShowToast(text, "", undoable: true);
- else App.TrayIcon?.Notify("Automatisch einsortiert", text);
- }
-
- private List<(string Path, string Category)> ApplyRules(IEnumerable paths, string description)
- {
- var entry = new UndoEntry(description);
- var done = new List<(string, string)>();
- foreach (var p in paths)
- {
- if (AutoSorter.Match(p) is not { } cat) continue;
- var r = Storage.Transfer(p, Storage.CategoryPath(cat));
- if (r.Status == TransferStatus.Ok)
- {
- entry.AddCopy(r.Source, r.Dest!);
- done.Add((p, cat));
- }
- if (S.AutoSortRemoveFromDesktop && r.Status is TransferStatus.Ok or TransferStatus.Skipped && !Storage.IsInStorage(p))
- {
- try { entry.AddDelete(p, Storage.Delete(p)); } catch { }
- }
- }
- Undo.Push(entry);
- Refresh();
- return done;
- }
-
- private void ApplyRulesNow_Click(object sender, RoutedEventArgs e)
- {
- if (S.Rules.Count == 0) { ShowToast("Lege zuerst eine Regel an", "", info: true); return; }
- var done = ApplyRules(Storage.DesktopEntries().ToList(), "Regeln anwenden");
- ShowToast(done.Count == 0 ? "Keine neuen passenden Icons gefunden" : $"{Plural(done.Count, "Icon", "Icons")} einsortiert",
- done.Count == 0 ? "" : "", undoable: done.Count > 0, info: done.Count == 0);
- }
-
- private void CreateRuleFor(IconItem item)
- {
- var cats = Storage.Categories();
- if (cats.Count == 0) { ShowToast("Lege zuerst eine Kategorie an", "", info: true); return; }
- // Vorschlag: erstes sinnvolles Wort des Namens
- var suggestion = item.Name.Split(' ', '-', '_').FirstOrDefault(w => w.Length > 2) ?? item.Name;
- S.Rules.Add(new SortRule { Contains = suggestion, Category = cats[0] });
- S.Save();
- ShowSettings();
- ShowToast("Regel angelegt – jetzt noch die Kategorie auswählen", "", info: true);
- }
-
- // =====================================================================
- // Einstellungen
- // =====================================================================
-
- private void LoadSettingsView()
- {
- _loadingSettings = true;
- DesignLight.IsChecked = S.Design == DesignMode.Light;
- DesignDark.IsChecked = S.Design == DesignMode.Dark;
- DesignSystem.IsChecked = S.Design == DesignMode.System;
- ViewSmall.IsChecked = S.View == ViewMode.Small;
- ViewMedium.IsChecked = S.View == ViewMode.Medium;
- ViewLarge.IsChecked = S.View == ViewMode.Large;
- ViewList.IsChecked = S.View == ViewMode.List;
-
- AutostartToggle.IsChecked = Autostart.Enabled;
- TrayToggle.IsChecked = S.TrayEnabled;
- CloseToTrayToggle.IsChecked = S.CloseToTray;
- CloseToTrayToggle.IsEnabled = S.TrayEnabled;
- HotkeyToggle.IsChecked = S.HotkeyEnabled;
- HotkeyBox.Text = Hotkey.Describe(S.HotkeyModifiers, S.HotkeyKey);
- HotkeyBox.IsEnabled = S.HotkeyEnabled;
-
- AutoSortToggle.IsChecked = S.AutoSort;
- AutoUpdateToggle.IsChecked = S.AutoUpdateCheck;
- AutoRemoveToggle.IsChecked = S.AutoSortRemoveFromDesktop;
- RulesList.ItemsSource = null;
- RulesList.ItemsSource = S.Rules;
- NoRulesText.Visibility = S.Rules.Count == 0 ? Visibility.Visible : Visibility.Collapsed;
-
- FencesToggle.IsChecked = S.FencesEnabled;
-
- _loadingSettings = false;
- UpdateBackupHint();
- }
-
- private long _storageSize = -1;
-
- private async void UpdateBackupHint()
- {
- var cats = Storage.Categories().Count;
- string Text() => $"{Plural(cats, "Kategorie", "Kategorien")}" +
- (_storageSize >= 0 ? $", zusammen {Backup.FormatSize(_storageSize)}" : "") +
- " – als ZIP-Datei, z. B. für einen neuen PC";
- BackupHint.Text = Text();
- _storageSize = await Task.Run(Backup.StorageSize);
- BackupHint.Text = Text();
- }
-
- private void Design_Checked(object sender, RoutedEventArgs e)
- {
- if (_loadingSettings) return;
- SetDesign(sender == DesignLight ? DesignMode.Light : sender == DesignDark ? DesignMode.Dark : DesignMode.System);
- }
-
- private void View_Checked(object sender, RoutedEventArgs e)
- {
- if (_loadingSettings) return;
- ApplyView(sender == ViewSmall ? ViewMode.Small : sender == ViewLarge ? ViewMode.Large : sender == ViewList ? ViewMode.List : ViewMode.Medium);
- S.Save();
- }
-
- private void AutostartToggle_Click(object sender, RoutedEventArgs e)
- {
- Autostart.Enabled = AutostartToggle.IsChecked == true;
- // Autostart startet im Infobereich – dafür muss das Symbol aktiv sein
- if (Autostart.Enabled && !S.TrayEnabled) { S.TrayEnabled = true; S.Save(); App.ApplyTray(); }
- LoadSettingsView();
- }
-
- private void TrayToggle_Click(object sender, RoutedEventArgs e)
- {
- S.TrayEnabled = TrayToggle.IsChecked == true;
- S.Save();
- App.ApplyTray();
- LoadSettingsView();
- }
-
- private void CloseToTrayToggle_Click(object sender, RoutedEventArgs e)
- {
- S.CloseToTray = CloseToTrayToggle.IsChecked == true;
- S.Save();
- }
-
- private void HotkeyToggle_Click(object sender, RoutedEventArgs e)
- {
- S.HotkeyEnabled = HotkeyToggle.IsChecked == true;
- S.Save();
- var ok = App.ApplyHotkey();
- LoadSettingsView();
- UpdateHotkeyHint(ok);
- }
-
- private void HotkeyBox_PreviewKeyDown(object sender, KeyEventArgs e)
- {
- var key = e.Key == Key.System ? e.SystemKey : e.Key;
- if (key is Key.Tab) return;
- e.Handled = true;
- if (key is Key.LeftCtrl or Key.RightCtrl or Key.LeftAlt or Key.RightAlt or Key.LeftShift or Key.RightShift
- or Key.LWin or Key.RWin or Key.Escape) return;
-
- int mods = (int)Keyboard.Modifiers; // Alt=1, Strg=2, Umschalt=4, Win=8 – gleiche Bits wie RegisterHotKey
- if ((mods & (Hotkey.ModCtrl | Hotkey.ModAlt | Hotkey.ModWin)) == 0)
- {
- HotkeyHint.Text = "Bitte mit Strg, Alt oder Win kombinieren.";
- return;
- }
- S.HotkeyModifiers = mods;
- S.HotkeyKey = KeyInterop.VirtualKeyFromKey(key);
- S.Save();
- HotkeyBox.Text = Hotkey.Describe(S.HotkeyModifiers, S.HotkeyKey);
- UpdateHotkeyHint(App.ApplyHotkey());
- }
-
- private void UpdateHotkeyHint(bool ok) =>
- HotkeyHint.Text = !S.HotkeyEnabled ? "Ausgeschaltet"
- : ok ? $"{Hotkey.Describe(S.HotkeyModifiers, S.HotkeyKey)} öffnet die App von überall – ins Feld klicken und neue Kombination drücken"
- : "⚠ Diese Kombination ist schon von einem anderen Programm belegt – bitte eine andere wählen.";
-
- private void AutoSortToggle_Click(object sender, RoutedEventArgs e)
- {
- S.AutoSort = AutoSortToggle.IsChecked == true;
- S.Save();
- if (S.AutoSort && S.Rules.Count == 0) ShowToast("Lege unten noch Regeln an", "", info: true);
- }
-
- private void AutoRemoveToggle_Click(object sender, RoutedEventArgs e)
- {
- S.AutoSortRemoveFromDesktop = AutoRemoveToggle.IsChecked == true;
- S.Save();
- }
-
- private void AddRule_Click(object sender, RoutedEventArgs e)
- {
- var cats = Storage.Categories();
- if (cats.Count == 0) { ShowToast("Lege zuerst eine Kategorie an", "", info: true); return; }
- S.Rules.Add(new SortRule { Contains = "", Category = cats[0] });
- S.Save();
- LoadSettingsView();
- }
-
- private void RuleText_LostFocus(object sender, RoutedEventArgs e) => S.Save();
-
- private void RuleDelete_Click(object sender, RoutedEventArgs e)
- {
- if ((sender as FrameworkElement)?.Tag is SortRule r)
- {
- S.Rules.Remove(r);
- S.Save();
- LoadSettingsView();
- }
- }
-
- private void RuleCategory_Click(object sender, RoutedEventArgs e)
- {
- if (sender is not Button b || b.Tag is not SortRule r) return;
- var menu = new ContextMenu { PlacementTarget = b, Placement = PlacementMode.Bottom };
- foreach (var c in Storage.Categories())
- {
- menu.Items.Add(MenuItem(c, S.Categories.GetValueOrDefault(c)?.Glyph ?? "", () =>
- {
- r.Category = c;
- S.Save();
- LoadSettingsView();
- }, bold: string.Equals(c, r.Category, StringComparison.OrdinalIgnoreCase)));
- }
- menu.IsOpen = true;
- }
-
- private void FencesToggle_Click(object sender, RoutedEventArgs e)
- {
- S.FencesEnabled = FencesToggle.IsChecked == true;
- S.Save();
- FenceManager.Sync();
- if (S.FencesEnabled && !S.Categories.Values.Any(m => m.Fence))
- ShowToast("Wähle bei einer Kategorie „Als Box auf den Desktop“", "", info: true);
- }
-
- private async void Export_Click(object sender, RoutedEventArgs e)
- {
- var dlg = new Microsoft.Win32.SaveFileDialog
- {
- Title = "Kategorien sichern",
- FileName = $"DeskCore-Sicherung-{DateTime.Now:yyyy-MM-dd}.zip",
- Filter = "ZIP-Archiv (*.zip)|*.zip",
- InitialDirectory = Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments),
- };
- if (dlg.ShowDialog(this) != true) return;
- if (Storage.IsUnder(dlg.FileName, Storage.Root))
- {
- Error("Anderen Ort wählen", "Die Sicherung kann nicht im DeskCore-Ordner selbst gespeichert werden – sie würde sich sonst mitsichern. " +
- "Am besten z. B. unter Dokumente oder auf einem USB-Stick speichern.");
- return;
- }
- var size = await Task.Run(Backup.StorageSize);
- if (size > 2L * 1024 * 1024 * 1024 &&
- !Dialog.Confirm(this, "Große Sicherung",
- $"Deine Kategorien sind zusammen {Backup.FormatSize(size)} groß (vor allem durch Ordner). Das Sichern kann eine Weile dauern " +
- "und braucht entsprechend Platz.", "Trotzdem sichern"))
- return;
- if (IsBusy) return;
- try
- {
- var file = dlg.FileName;
- var count = await RunWithProgress("Sicherung wird erstellt …", Path.GetFileName(file), (sink, ct) =>
- {
- sink.SetTotal(size);
- return Task.FromResult(Backup.Export(file, sink.Add, ct));
- });
- ShowToast($"{Plural(count, "Datei", "Dateien")} gesichert in {Path.GetFileName(dlg.FileName)}", "");
- }
- catch (OperationCanceledException) { ShowToast("Sicherung abgebrochen – es wurde keine Datei angelegt", "\uE711", info: true); }
- catch (Exception ex) { Error("Sichern fehlgeschlagen", ex.Message); }
- }
-
- private async void Import_Click(object sender, RoutedEventArgs e)
- {
- var dlg = new Microsoft.Win32.OpenFileDialog
- {
- Title = "Sicherung wiederherstellen",
- Filter = "ZIP-Archiv (*.zip)|*.zip",
- InitialDirectory = Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments),
- };
- if (dlg.ShowDialog(this) != true) return;
- if (!Dialog.Confirm(this, "Sicherung wiederherstellen?",
- "Die Kategorien aus der Sicherung werden zu deinen vorhandenen hinzugefügt. Vorhandene Dateien werden nicht überschrieben.",
- "Wiederherstellen"))
- return;
- if (IsBusy) return;
- try
- {
- var file = dlg.FileName;
- var (added, skipped) = await RunWithProgress("Sicherung wird wiederhergestellt …", Path.GetFileName(file), (sink, ct) =>
- {
- sink.SetTotal(Backup.ArchiveSize(file));
- return Task.FromResult(Backup.Import(file, sink.Add, ct));
- });
- Refresh();
- FenceManager.Sync();
- LoadSettingsView();
- ShowToast($"{Plural(added, "Datei", "Dateien")} wiederhergestellt" + (skipped > 0 ? $" · {skipped} waren schon da" : ""), "");
- }
- catch (OperationCanceledException)
- {
- Refresh();
- ShowToast("Wiederherstellen abgebrochen – bereits übernommene Dateien bleiben erhalten", "\uE711", info: true);
- }
- catch (Exception ex) { Error("Wiederherstellen fehlgeschlagen", $"Die Datei ist keine gültige Sicherung.\n\n{ex.Message}"); }
- }
-
- private void License_Click(object sender, RoutedEventArgs e)
- {
- string text;
- try
- {
- using var r = new StreamReader(Application.GetResourceStream(new Uri("pack://application:,,,/LICENSE.txt"))!.Stream);
- text = r.ReadToEnd().Trim();
- }
- catch { text = "Lizenztext nicht gefunden."; }
-
- var box = new Border
- {
- Background = (Brush)FindResource("ControlFillBrush"),
- BorderBrush = (Brush)FindResource("ControlStrokeBrush"),
- BorderThickness = new Thickness(1), CornerRadius = new CornerRadius(6),
- Child = new ScrollViewer
- {
- MaxHeight = 380, VerticalScrollBarVisibility = ScrollBarVisibility.Auto,
- Content = new TextBlock
- {
- Text = text, TextWrapping = TextWrapping.Wrap, FontSize = 13, LineHeight = 19,
- Margin = new Thickness(16, 12, 16, 12), Foreground = (Brush)FindResource("TextBrush"),
- },
- },
- };
- Dialog.Show(this, "Lizenz- und Nutzungsbedingungen", box, "Schließen", null, null, width: 640);
- }
-
- private void OpenStorage_Click(object sender, RoutedEventArgs e) => Process.Start("explorer.exe", $"\"{Storage.Root}\"");
- private void Quit_Click(object sender, RoutedEventArgs e) => App.Quit();
-
- // =====================================================================
- // Tastatur
- // =====================================================================
-
- private void OnPreviewKeyDown(object sender, KeyEventArgs e)
- {
- bool ctrl = Keyboard.Modifiers == ModifierKeys.Control;
- bool inText = e.OriginalSource is TextBox;
- if (e.OriginalSource == HotkeyBox) return;
-
- if (Keyboard.Modifiers == ModifierKeys.Alt && e.Key == Key.System && e.SystemKey is Key.Up or Key.Down
- && !inText && CategoryList.SelectedItem is CategoryItem moving)
- {
- // Alt+↑/↓: ausgewählte Kategorie verschieben
- int idx = CategoryList.SelectedIndex + (e.SystemKey == Key.Up ? -1 : 1);
- if (idx >= 0 && idx < CategoryList.Items.Count)
- {
- MoveCategory(moving.Name, idx);
- (CategoryList.ItemContainerGenerator.ContainerFromIndex(idx) as ListBoxItem)?.Focus();
- }
- e.Handled = true;
- }
- else if (ctrl && e.Key == Key.Z && !inText) { DoUndo(); e.Handled = true; }
- else if (ctrl && e.Key == Key.F) { ShowBrowse(); SearchBox.Focus(); SearchBox.SelectAll(); e.Handled = true; }
- else if (ctrl && e.Key == Key.N) { NewCategory_Click(this, e); e.Handled = true; }
- else if (ctrl && e.Key == Key.A && !inText && !InSettings) { IconList.SelectAll(); IconList.Focus(); e.Handled = true; }
- else if (e.Key == Key.F5) { Refresh(); e.Handled = true; }
- else if (e.Key == Key.Escape && e.OriginalSource == SearchBox && SearchBox.Text.Length > 0) { SearchBox.Text = ""; e.Handled = true; }
- else if (e.Key == Key.Down && e.OriginalSource == SearchBox && IconList.Items.Count > 0)
- {
- IconList.SelectedIndex = 0;
- (IconList.ItemContainerGenerator.ContainerFromIndex(0) as ListBoxItem)?.Focus();
- e.Handled = true;
- }
- else if (ctrl && e.Key is >= Key.D1 and <= Key.D9)
- {
- int n = e.Key - Key.D1;
- ShowBrowse();
- SearchBox.Text = "";
- if (n == 0) DesktopNav.SelectedIndex = 0;
- else if (n - 1 < CategoryList.Items.Count) CategoryList.SelectedIndex = n - 1;
- e.Handled = true;
- }
- }
-
- // =====================================================================
- // Rückmeldungen
- // =====================================================================
-
- private void ShowToast(string text, string glyph = "", bool undoable = false, bool info = false)
- {
- ToastText.Text = text;
- ToastGlyph.Text = glyph;
- ToastBadge.Background = (Brush)FindResource(info ? "AccentBrush" : "SuccessBrush");
- ToastGlyph.Foreground = info ? (Brush)FindResource("OnAccentBrush") : Brushes.White;
- ToastUndo.Visibility = undoable && Undo.CanUndo ? Visibility.Visible : Visibility.Collapsed;
- Toast.IsHitTestVisible = true;
- AnimateToast(true);
- _toastTimer.Stop();
- _toastTimer.Interval = TimeSpan.FromSeconds(undoable ? 6 : 3.5);
- _toastTimer.Start();
- }
-
- private void AnimateToast(bool show)
- {
- if (!show) Toast.IsHitTestVisible = false;
- var ease = new CubicEase { EasingMode = EasingMode.EaseOut };
- var dur = TimeSpan.FromMilliseconds(show ? 220 : 300);
- Toast.BeginAnimation(OpacityProperty, new DoubleAnimation(show ? 1 : 0, dur) { EasingFunction = ease });
- ToastShift.BeginAnimation(TranslateTransform.YProperty, new DoubleAnimation(show ? 0 : 12, dur) { EasingFunction = ease });
- }
-
- private void Error(string title, string msg) => Dialog.Info(this, title, msg);
-
- private void ShowErrors(List errors) =>
- Error("Nicht alles hat geklappt",
- string.Join("\n\n", errors.Take(8)) + (errors.Count > 8 ? $"\n\n… und {errors.Count - 8} weitere" : ""));
-
- // =====================================================================
- // Helfer
- // =====================================================================
-
- public static MenuItem MenuItem(string header, string glyph, Action? action, bool bold = false, string? gesture = null)
- {
- var mi = new MenuItem
- {
- Header = header,
- Icon = new TextBlock { Text = glyph },
- FontWeight = bold ? FontWeights.SemiBold : FontWeights.Normal,
- };
- if (gesture is not null) mi.InputGestureText = gesture;
- if (action is not null) mi.Click += (_, _) => action();
- return mi;
- }
-
- private object? ItemAt(object source) => FindParent(source as DependencyObject)?.DataContext;
-
- public static T? FindParent(DependencyObject? d) where T : DependencyObject
- {
- while (d is not null and not T)
- d = d is Visual or System.Windows.Media.Media3D.Visual3D ? VisualTreeHelper.GetParent(d) : LogicalTreeHelper.GetParent(d);
- return d as T;
- }
-}
+using System.ComponentModel;
+using System.Diagnostics;
+using System.IO;
+using System.Runtime.InteropServices;
+using System.Windows;
+using System.Windows.Controls;
+using System.Windows.Controls.Primitives;
+using System.Windows.Input;
+using System.Windows.Interop;
+using System.Windows.Media;
+using System.Windows.Media.Animation;
+using System.Windows.Threading;
+
+namespace DeskCore;
+
+public partial class MainWindow : Window
+{
+ private const string DesktopName = "Desktop";
+ private const string CategoryDragFormat = "DeskCore.Kategorie";
+
+ public UndoManager Undo { get; } = new();
+
+ private readonly DispatcherTimer _toastTimer = new() { Interval = TimeSpan.FromSeconds(4) };
+ private Point _dragStart;
+ private bool _dragging;
+ private bool _syncingSelection;
+ private bool _loadingSettings;
+ private string? _categoryDragName;
+
+ private static AppSettings S => AppSettings.Current;
+
+ public MainWindow()
+ {
+ InitializeComponent();
+ RestorePlacement();
+ StateChanged += (_, _) => UpdateWindowState();
+ Closing += OnClosing;
+ PreviewKeyDown += OnPreviewKeyDown;
+ _toastTimer.Tick += (_, _) => { _toastTimer.Stop(); AnimateToast(false); };
+ Theme.Changed += UpdateThemeButton;
+ Undo.Changed += UpdateUndoButton;
+ UpdateThemeButton();
+ ApplyView(S.View, reload: false);
+ StoragePathText.Text = $"Kategorien liegen in {Storage.Root}";
+ var asm = typeof(MainWindow).Assembly;
+ var version = asm.GetName().Version;
+ AboutTitle.Text = $"DeskCore {version?.Major}.{version?.Minor}.{version?.Build}";
+ AboutCopyright.Text = "© M_Viper · Nur für private, nicht-kommerzielle Nutzung";
+ Refresh();
+ }
+
+ private CategoryItem? Current => (DesktopNav.SelectedItem ?? CategoryList.SelectedItem) as CategoryItem;
+ private bool Searching => SearchBox.Text.Trim().Length > 0;
+ private bool InSettings => SettingsView.Visibility == Visibility.Visible;
+
+ // =====================================================================
+ // Fenster, Infobereich, Tastenkürzel
+ // =====================================================================
+
+ private void Minimize_Click(object sender, RoutedEventArgs e) => WindowState = WindowState.Minimized;
+ private void Maximize_Click(object sender, RoutedEventArgs e) =>
+ WindowState = WindowState == WindowState.Maximized ? WindowState.Normal : WindowState.Maximized;
+ private void Close_Click(object sender, RoutedEventArgs e) => Close();
+
+ private void UpdateWindowState()
+ {
+ bool max = WindowState == WindowState.Maximized;
+ // Maximierte Fenster ohne Rahmen ragen sonst über den Bildschirmrand
+ RootGrid.Margin = max ? new Thickness(7) : new Thickness(0);
+ MaxButton.Content = max ? "" : "";
+ MaxButton.ToolTip = max ? "Verkleinern" : "Maximieren";
+ }
+
+ private void OnClosing(object? sender, CancelEventArgs e)
+ {
+ if (App.Quitting) return;
+ SavePlacement();
+ if (S.TrayEnabled && S.CloseToTray)
+ {
+ e.Cancel = true;
+ Hide();
+ if (!S.TrayHintShown)
+ {
+ S.TrayHintShown = true;
+ S.Save();
+ App.TrayIcon?.Notify("DeskCore läuft weiter",
+ "Klick auf das Symbol hier unten öffnet die App wieder. Beenden: Rechtsklick → Beenden.");
+ }
+ return;
+ }
+ e.Cancel = true;
+ App.Quit();
+ }
+
+ public void SetDimmed(bool dim) => DimOverlay.Visibility = dim ? Visibility.Visible : Visibility.Collapsed;
+
+ public void ShowFromBackground()
+ {
+ if (!IsVisible) Show();
+ if (WindowState == WindowState.Minimized) WindowState = WindowState.Normal;
+ Activate();
+ Topmost = true; // zuverlässig in den Vordergrund holen
+ Topmost = false;
+ Focus();
+ }
+
+ /// Globales Tastenkürzel: öffnen bzw. wieder verstecken.
+ public void ToggleFromHotkey()
+ {
+ if (IsVisible && IsActive && WindowState != WindowState.Minimized)
+ {
+ if (S.TrayEnabled) Hide();
+ else WindowState = WindowState.Minimized;
+ return;
+ }
+ ShowFromBackground();
+ ShowBrowse();
+ SearchBox.Focus();
+ SearchBox.SelectAll();
+ }
+
+ public void ShowCategory(string name)
+ {
+ ShowFromBackground();
+ ShowBrowse();
+ SearchBox.Text = "";
+ Select(name);
+ }
+
+ /// Menü beim Rechtsklick auf das Infobereich-Symbol.
+ public void ShowTrayMenu()
+ {
+ var menu = new ContextMenu { Placement = PlacementMode.MousePoint };
+ menu.Items.Add(MenuItem("DeskCore öffnen", "", () => { ShowFromBackground(); ShowBrowse(); }, bold: true));
+ var cats = Storage.Categories();
+ if (cats.Count > 0)
+ {
+ menu.Items.Add(new Separator());
+ foreach (var c in cats.Take(15))
+ menu.Items.Add(MenuItem(c, S.Categories.GetValueOrDefault(c)?.Glyph ?? "", () => ShowCategory(c)));
+ }
+ if (S.LaunchGroups.Count > 0)
+ {
+ menu.Items.Add(new Separator());
+ foreach (var g in S.LaunchGroups.Take(10))
+ menu.Items.Add(MenuItem($"{g.Name} starten", "\uE768", () => LaunchGroupNow(g)));
+ }
+ if (S.Snapshots.Count > 0)
+ {
+ var snaps = MenuItem("Box-Anordnung laden", "\uE81E", null);
+ foreach (var snap in S.Snapshots.OrderByDescending(x => x.Created))
+ snaps.Items.Add(MenuItem(snap.Name, "\uE81E", () => RestoreSnapshot(snap)));
+ menu.Items.Add(new Separator());
+ menu.Items.Add(snaps);
+ }
+ if (S.Pages.Count > 1)
+ {
+ var pages = MenuItem("Desktop-Seite", "\uE8A9", null);
+ for (int p = 0; p < S.Pages.Count; p++)
+ {
+ int page = p;
+ var mi = MenuItem(S.Pages[p], p == S.CurrentPage ? "\uE73E" : "", () => FenceManager.SwitchPage(page));
+ if (p == S.CurrentPage) mi.FontWeight = FontWeights.SemiBold;
+ pages.Items.Add(mi);
+ }
+ menu.Items.Add(pages);
+ }
+ menu.Items.Add(MenuItem(FenceManager.Hidden ? "Desktop wieder einblenden" : "Desktop ausblenden", "\uE7B3", DesktopToggle.Toggle));
+ menu.Items.Add(new Separator());
+ if (Undo.CanUndo)
+ menu.Items.Add(MenuItem($"Rückgängig: {Undo.NextDescription}", "", DoUndo));
+ menu.Items.Add(MenuItem("Einstellungen", "", () => { ShowFromBackground(); ShowSettings(); }));
+ menu.Items.Add(MenuItem("Beenden", "", App.Quit));
+ // Damit das Menü bei Klick daneben zugeht, muss es im Vordergrund sein
+ menu.Opened += (_, _) =>
+ {
+ if (PresentationSource.FromVisual(menu) is HwndSource src) SetForegroundWindow(src.Handle);
+ };
+ menu.IsOpen = true;
+ }
+
+ [DllImport("user32.dll")] private static extern bool SetForegroundWindow(IntPtr hwnd);
+
+ // ---------- Design ----------
+
+ private void UpdateThemeButton() => ThemeButton.Content = Theme.IsLight ? "" : ""; // Sonne / Mond
+
+ private void ThemeButton_Click(object sender, RoutedEventArgs e)
+ {
+ ThemeMenu.Items.Clear();
+ foreach (var (mode, label, glyph) in new[]
+ {
+ (DesignMode.Light, "Hell", ""),
+ (DesignMode.Dark, "Dunkel", ""),
+ (DesignMode.System, "Wie Windows", ""),
+ })
+ {
+ var mi = MenuItem(label, glyph, () => SetDesign(mode), bold: S.Design == mode);
+ if (S.Design == mode) mi.InputGestureText = "✓";
+ ThemeMenu.Items.Add(mi);
+ }
+ ThemeMenu.PlacementTarget = ThemeButton;
+ ThemeMenu.IsOpen = true;
+ }
+
+ private void SetDesign(DesignMode mode)
+ {
+ S.Design = mode;
+ S.Save();
+ Theme.Apply();
+ LoadSettingsView();
+ }
+
+ // =====================================================================
+ // Laden & Anzeigen
+ // =====================================================================
+
+ /// Vom Watcher: Dateien haben sich geändert.
+ public void OnStorageChanged()
+ {
+ if (!_dragging && !IsBusy) Refresh(); // während großer Kopien nicht ständig neu laden
+ }
+
+ private void Refresh()
+ {
+ var selected = Current;
+ var selectedGroup = CurrentGroup?.Name;
+ var desktop = new CategoryItem { Name = DesktopName, IsDesktop = true, Count = Storage.DesktopEntries().Count() };
+ var cats = Storage.Categories()
+ .Select(n => new CategoryItem { Name = n, Count = Storage.Entries(Storage.CategoryPath(n)).Count() })
+ .ToList();
+
+ _syncingSelection = true;
+ DesktopNav.ItemsSource = new[] { desktop };
+ CategoryList.ItemsSource = cats;
+ var groups = S.LaunchGroups.Select(g => new LaunchGroupItem { Group = g }).ToList();
+ LaunchList.ItemsSource = groups;
+ if (!InSettings)
+ {
+ var groupMatch = selectedGroup is null ? null : groups.FirstOrDefault(g => g.Name == selectedGroup);
+ var match = selected is { IsDesktop: false } ? cats.FirstOrDefault(c => c.Name == selected.Name) : null;
+ if (groupMatch is not null)
+ {
+ LaunchList.SelectedItem = groupMatch;
+ DesktopNav.SelectedItem = null;
+ CategoryList.SelectedItem = null;
+ }
+ else if (match is not null) { CategoryList.SelectedItem = match; DesktopNav.SelectedItem = null; LaunchList.SelectedItem = null; }
+ else { DesktopNav.SelectedItem = desktop; CategoryList.SelectedItem = null; LaunchList.SelectedItem = null; }
+ }
+ _syncingSelection = false;
+
+ NoCategoriesText.Visibility = cats.Count == 0 ? Visibility.Visible : Visibility.Collapsed;
+ NoLaunchGroupsText.Visibility = groups.Count == 0 ? Visibility.Visible : Visibility.Collapsed;
+ LoadIcons();
+ if (InSettings) LoadSettingsView();
+ }
+
+ private void Select(string categoryName)
+ {
+ var c = ((List)CategoryList.ItemsSource).FirstOrDefault(x =>
+ string.Equals(x.Name, categoryName, StringComparison.OrdinalIgnoreCase));
+ if (c is not null) CategoryList.SelectedItem = c;
+ }
+
+ private void LoadIcons()
+ {
+ if (InSettings) return;
+ if (CurrentGroup is { } group && !Searching)
+ {
+ LoadGroupIcons(group.Group);
+ return;
+ }
+ GroupCommands.Visibility = Visibility.Collapsed;
+ var cat = Current;
+ var search = SearchBox.Text.Trim();
+ var selectedPaths = IconList.SelectedItems.Cast().Select(i => i.Path).ToHashSet(StringComparer.OrdinalIgnoreCase);
+ List items;
+
+ if (search.Length > 0)
+ {
+ items = Storage.DesktopEntries().Select(p => new IconItem { Path = p, Category = DesktopName })
+ .Concat(Storage.Categories().SelectMany(c =>
+ Storage.Entries(Storage.CategoryPath(c)).Select(p => new IconItem { Path = p, Category = c })))
+ .Where(i => i.Name.Contains(search, StringComparison.CurrentCultureIgnoreCase))
+ .ToList();
+ HeaderText.Text = "Suchergebnisse";
+ SubText.Text = $"{Plural(items.Count, "Treffer", "Treffer")} für „{search}“";
+ SetEmpty("", "Keine Treffer", $"Für „{search}“ wurde nichts gefunden.");
+ }
+ else if (cat is null)
+ {
+ items = [];
+ }
+ else
+ {
+ var paths = cat.IsDesktop ? Storage.DesktopEntries() : Storage.Entries(cat.Folder);
+ // Welche Desktop-Icons stecken schon (als Kopie) in einer Kategorie? (Name oder gleiches Verknüpfungsziel)
+ var catEntries = cat.IsDesktop
+ ? Storage.Categories().ToDictionary(c => c, c => Storage.Entries(Storage.CategoryPath(c)).ToList())
+ : [];
+ items = paths.Select(p => new IconItem
+ {
+ Path = p,
+ SortedInto = cat.IsDesktop ? Storage.FindCategoryOf(p, catEntries) : null,
+ }).ToList();
+
+ HeaderText.Text = cat.Name;
+ var done = items.Count(i => i.IsSorted);
+ SubText.Text = Plural(items.Count, "Element", "Elemente") + (done > 0 ? $" · {done} davon gesichert" : "");
+ if (cat.IsDesktop)
+ SetEmpty("", "Alles aufgeräumt", "Auf deinem Desktop liegt nichts mehr.");
+ else
+ SetEmpty(cat.Glyph, "Noch leer", $"Ziehe Icons vom Desktop hierher oder auf „{cat.Name}“ in der Seitenleiste.");
+ }
+
+ items = items.OrderBy(i => !Directory.Exists(i.Path))
+ .ThenBy(i => i.Name, StringComparer.CurrentCultureIgnoreCase).ToList();
+ var px = IconPixels;
+ foreach (var item in items) IconLoader.Load(item, px);
+ IconList.ItemsSource = items;
+ foreach (var i in items.Where(i => selectedPaths.Contains(i.Path))) IconList.SelectedItems.Add(i);
+
+ EmptyState.Visibility = items.Count == 0 && (cat is not null || search.Length > 0) ? Visibility.Visible : Visibility.Collapsed;
+ CategoryCommands.Visibility = !Searching && cat is { IsDesktop: false } ? Visibility.Visible : Visibility.Collapsed;
+ InfoBar.Visibility = !Searching && cat is { IsDesktop: true } && items.Any(i => !i.IsSorted)
+ ? Visibility.Visible : Visibility.Collapsed;
+ if (cat is { IsDesktop: false })
+ FenceToggleText.Text = cat.HasFence ? "Box vom Desktop entfernen" : "Als Box auf den Desktop";
+ }
+
+ private void SetEmpty(string glyph, string title, string text)
+ {
+ EmptyGlyph.Text = glyph;
+ EmptyTitle.Text = title;
+ EmptyText.Text = text;
+ }
+
+ private static string Plural(int n, string one, string many) => $"{n} {(n == 1 ? one : many)}";
+
+ private void Nav_SelectionChanged(object sender, SelectionChangedEventArgs e)
+ {
+ if (_syncingSelection) return;
+ var list = (ListBox)sender;
+ if (list.SelectedItem is null) return;
+
+ // Nur ein Eintrag in allen Listen gleichzeitig ausgewählt
+ _syncingSelection = true;
+ if (list != DesktopNav) DesktopNav.SelectedItem = null;
+ if (list != CategoryList) CategoryList.SelectedItem = null;
+ if (list != LaunchList) LaunchList.SelectedItem = null;
+ SettingsNav.SelectedItem = null;
+ _syncingSelection = false;
+
+ SettingsView.Visibility = Visibility.Collapsed;
+ BrowseView.Visibility = Visibility.Visible;
+ IconList.SelectedItems.Clear();
+ if (Searching) SearchBox.Text = ""; // löst LoadIcons aus
+ else LoadIcons();
+ }
+
+ private void SettingsNav_SelectionChanged(object sender, SelectionChangedEventArgs e)
+ {
+ if (_syncingSelection || SettingsNav.SelectedItem is null) return;
+ ShowSettings();
+ }
+
+ private void ShowSettings()
+ {
+ _syncingSelection = true;
+ DesktopNav.SelectedItem = null;
+ CategoryList.SelectedItem = null;
+ LaunchList.SelectedItem = null;
+ SettingsNav.SelectedIndex = 0;
+ _syncingSelection = false;
+ BrowseView.Visibility = Visibility.Collapsed;
+ SettingsView.Visibility = Visibility.Visible;
+ LoadSettingsView();
+ // erst nach dem Layout nach oben scrollen, sonst springt die Seite wieder zurück
+ Dispatcher.BeginInvoke(SettingsView.ScrollToTop, DispatcherPriority.ContextIdle);
+ }
+
+ private void ShowBrowse()
+ {
+ if (!InSettings) return;
+ _syncingSelection = true;
+ SettingsNav.SelectedItem = null;
+ LaunchList.SelectedItem = null;
+ DesktopNav.SelectedIndex = 0;
+ _syncingSelection = false;
+ SettingsView.Visibility = Visibility.Collapsed;
+ BrowseView.Visibility = Visibility.Visible;
+ LoadIcons();
+ }
+
+ private void SearchBox_TextChanged(object sender, TextChangedEventArgs e)
+ {
+ SearchPlaceholder.Visibility = SearchBox.Text.Length == 0 ? Visibility.Visible : Visibility.Collapsed;
+ ClearSearchBtn.Visibility = SearchBox.Text.Length > 0 ? Visibility.Visible : Visibility.Collapsed;
+ LoadIcons();
+ }
+
+ private void ClearSearch_Click(object sender, RoutedEventArgs e) { SearchBox.Text = ""; SearchBox.Focus(); }
+ private void Refresh_Click(object sender, RoutedEventArgs e) => Refresh();
+
+ private void OpenFolder_Click(object sender, RoutedEventArgs e)
+ {
+ if (Current is { } cat) Process.Start("explorer.exe", $"\"{cat.Folder}\"");
+ }
+
+ // ---------- Ansicht ----------
+
+ private int IconPixels => (int)Math.Round(S.View switch
+ {
+ ViewMode.Small => 32,
+ ViewMode.Large => 72,
+ ViewMode.List => 24,
+ _ => 48,
+ } * VisualTreeHelper.GetDpi(this).DpiScaleX);
+
+ private void ApplyView(ViewMode mode, bool reload = true)
+ {
+ S.View = mode;
+ var (w, h, icon, top) = mode switch
+ {
+ ViewMode.Small => (88.0, 92.0, 32.0, 10.0),
+ ViewMode.Large => (136.0, 150.0, 72.0, 14.0),
+ _ => (108.0, 116.0, 48.0, 14.0),
+ };
+ Resources["TileWidth"] = w;
+ Resources["TileHeight"] = h;
+ Resources["TileIcon"] = icon;
+ Resources["TileIconMargin"] = new Thickness(0, top, 0, 0);
+
+ bool list = mode == ViewMode.List;
+ IconList.ItemTemplate = (DataTemplate)FindResource(list ? "ListTemplate" : "TileTemplate");
+ IconList.ItemsPanel = (ItemsPanelTemplate)FindResource(list ? "StackPanelTemplate" : "WrapPanelTemplate");
+ IconList.ItemContainerStyle = (Style)FindResource(list ? "RowItem" : "TileItem");
+ ListHeader.Visibility = list ? Visibility.Visible : Visibility.Collapsed;
+ ViewGlyph.Text = mode switch { ViewMode.Small => "", ViewMode.List => "", _ => "" };
+ if (reload) LoadIcons();
+ }
+
+ private void ViewButton_Click(object sender, RoutedEventArgs e)
+ {
+ ViewMenu.Items.Clear();
+ foreach (var (mode, label, glyph) in new[]
+ {
+ (ViewMode.Small, "Kleine Symbole", ""),
+ (ViewMode.Medium, "Mittelgroße Symbole", ""),
+ (ViewMode.Large, "Große Symbole", ""),
+ (ViewMode.List, "Liste", ""),
+ })
+ {
+ var mi = MenuItem(label, glyph, () => { ApplyView(mode); S.Save(); }, bold: S.View == mode);
+ if (S.View == mode) mi.InputGestureText = "✓";
+ ViewMenu.Items.Add(mi);
+ }
+ ViewMenu.PlacementTarget = ViewButton;
+ ViewMenu.IsOpen = true;
+ }
+
+ // =====================================================================
+ // Kategorien
+ // =====================================================================
+
+ private record CategoryForm(string Name, string? Glyph, string? Color, bool Fence);
+
+ /// Dialog zum Anlegen/Bearbeiten einer Kategorie.
+ private CategoryForm? EditCategoryDialog(string title, string primary, string? current)
+ {
+ var meta = current is null ? null : S.Categories.GetValueOrDefault(current);
+ var res = Application.Current.Resources;
+ var panel = new StackPanel();
+
+ TextBlock Label(string t) => new()
+ {
+ Text = t, Margin = new Thickness(0, 16, 0, 8), FontWeight = FontWeights.SemiBold, FontSize = 13,
+ };
+
+ var nameBox = new TextBox { Text = current ?? "" };
+ panel.Children.Add(new TextBlock { Text = "Name", Margin = new Thickness(0, 0, 0, 8), FontWeight = FontWeights.SemiBold, FontSize = 13 });
+ panel.Children.Add(nameBox);
+
+ string? color = meta?.Color;
+ var glyphButtons = new List();
+ void PaintGlyphs()
+ {
+ var b = CategoryColors.Brush(color) ?? (Brush)res["TextBrush"];
+ foreach (var g in glyphButtons) g.Foreground = b;
+ }
+
+ // Symbol
+ string? glyph = meta?.Glyph ?? "";
+ panel.Children.Add(Label("Symbol"));
+ var glyphs = new WrapPanel();
+ foreach (var (g, name) in CategoryGlyphs.All)
+ {
+ var rb = new RadioButton
+ {
+ Content = g, ToolTip = name, GroupName = "glyph", Style = (Style)res["GlyphRadio"],
+ IsChecked = g == glyph,
+ };
+ rb.Checked += (_, _) => glyph = g;
+ glyphButtons.Add(rb);
+ glyphs.Children.Add(rb);
+ }
+ panel.Children.Add(glyphs);
+
+ // Farbe
+ panel.Children.Add(Label("Farbe"));
+ var swatches = new WrapPanel();
+ var none = new RadioButton
+ {
+ Style = (Style)res["SwatchRadio"], GroupName = "color", ToolTip = "Keine Farbe",
+ Background = Brushes.Transparent, IsChecked = color is null,
+ Content = new TextBlock { Text = "", FontFamily = (FontFamily)res["IconFont"], FontSize = 10, Foreground = (Brush)res["TextSecondaryBrush"] },
+ };
+ none.Checked += (_, _) => { color = null; PaintGlyphs(); };
+ swatches.Children.Add(none);
+ foreach (var (cname, hex) in CategoryColors.All)
+ {
+ var rb = new RadioButton
+ {
+ Style = (Style)res["SwatchRadio"], GroupName = "color", ToolTip = cname,
+ Background = CategoryColors.Brush(hex), IsChecked = string.Equals(color, hex, StringComparison.OrdinalIgnoreCase),
+ };
+ rb.Checked += (_, _) => { color = hex; PaintGlyphs(); };
+ swatches.Children.Add(rb);
+ }
+ panel.Children.Add(swatches);
+ PaintGlyphs();
+
+ var fence = new CheckBox
+ {
+ Content = "Als Box auf dem Desktop anzeigen", IsChecked = current is not null && S.HasFence(current), Margin = new Thickness(0, 14, 0, 0),
+ };
+ panel.Children.Add(fence);
+
+ string? Validate()
+ {
+ if (Storage.ValidateName(nameBox.Text) is { } err) return err;
+ var name = nameBox.Text.Trim();
+ bool same = string.Equals(name, current, StringComparison.OrdinalIgnoreCase);
+ return Directory.Exists(Storage.CategoryPath(name)) && !same ? "Eine Kategorie mit diesem Namen gibt es schon." : null;
+ }
+
+ if (!Dialog.Show(this, title, panel, primary, "Abbrechen", Validate, nameBox, width: 470)) return null;
+ return new CategoryForm(nameBox.Text.Trim(), glyph == "" ? null : glyph, color, fence.IsChecked == true);
+ }
+
+ private string? NewCategory()
+ {
+ var form = EditCategoryDialog("Neue Kategorie", "Erstellen", null);
+ if (form is null) return null;
+ var path = Storage.CategoryPath(form.Name);
+ Directory.CreateDirectory(path);
+ var m = S.Meta(form.Name);
+ m.Glyph = form.Glyph;
+ m.Color = form.Color;
+ S.SetFence(form.Name, form.Fence);
+ if (!S.CategoryOrder.Contains(form.Name, StringComparer.OrdinalIgnoreCase)) S.CategoryOrder.Add(form.Name);
+ S.Save();
+
+ var entry = new UndoEntry($"Kategorie „{form.Name}“ erstellen");
+ entry.Add(() =>
+ {
+ if (Directory.Exists(path) && !Directory.EnumerateFileSystemEntries(path).Any()) Directory.Delete(path);
+ S.RemoveCategory(form.Name);
+ S.Save();
+ return null;
+ });
+ Undo.Push(entry);
+
+ Refresh();
+ FenceManager.Sync();
+ return form.Name;
+ }
+
+ private void NewCategory_Click(object sender, RoutedEventArgs e)
+ {
+ if (NewCategory() is { } name)
+ {
+ ShowBrowse();
+ Select(name);
+ ShowToast($"Kategorie „{name}“ erstellt", undoable: true);
+ }
+ }
+
+ private void EditCategory_Click(object sender, RoutedEventArgs e)
+ {
+ if (Current is { IsDesktop: false } cat) EditCategory(cat.Name);
+ }
+
+ private void EditCategory(string oldName)
+ {
+ var form = EditCategoryDialog("Kategorie bearbeiten", "Speichern", oldName);
+ if (form is null) return;
+
+ var old = S.Meta(oldName);
+ var before = (old.Glyph, old.Color, Fence: S.HasFence(oldName));
+ var entry = new UndoEntry($"Kategorie „{oldName}“ bearbeiten");
+
+ if (form.Name != oldName)
+ {
+ try
+ {
+ Storage.Rename(Storage.CategoryPath(oldName), form.Name);
+ }
+ catch (Exception ex) { Error("Umbenennen fehlgeschlagen", ex.Message); return; }
+ S.RenameCategory(oldName, form.Name);
+ var newName = form.Name;
+ entry.Add(() =>
+ {
+ Storage.Rename(Storage.CategoryPath(newName), oldName);
+ S.RenameCategory(newName, oldName);
+ return null;
+ });
+ }
+
+ var m = S.Meta(form.Name);
+ m.Glyph = form.Glyph;
+ m.Color = form.Color;
+ S.SetFence(form.Name, form.Fence);
+ var nameNow = form.Name;
+ entry.Add(() =>
+ {
+ var mm = S.Meta(nameNow);
+ (mm.Glyph, mm.Color) = (before.Glyph, before.Color);
+ S.SetFence(nameNow, before.Fence);
+ S.Save();
+ return null;
+ });
+ S.Save();
+ Undo.Push(entry);
+
+ Refresh();
+ Select(form.Name);
+ FenceManager.Sync();
+ ShowToast("Kategorie gespeichert", undoable: true);
+ }
+
+ private void FenceToggle_Click(object sender, RoutedEventArgs e)
+ {
+ if (Current is { IsDesktop: false } cat) ToggleFence(cat.Name);
+ }
+
+ private void ToggleFence(string name)
+ {
+ bool on = !S.HasFence(name);
+ S.SetFence(name, on);
+ S.Save();
+ FenceManager.Sync();
+ Refresh();
+ ShowToast(on ? $"„{name}“ wird jetzt als Box auf dem Desktop angezeigt" : $"Box „{name}“ ausgeblendet", "\uE8A1");
+ }
+
+ private void DeleteCategory_Click(object sender, RoutedEventArgs e)
+ {
+ if (Current is { IsDesktop: false } cat) DeleteCategory(cat.Name);
+ }
+
+ private void DeleteCategory(string name)
+ {
+ var folder = Storage.CategoryPath(name);
+ var entries = Storage.Entries(folder).ToList();
+ var msg = entries.Count == 0
+ ? $"Die leere Kategorie „{name}“ wird entfernt."
+ : $"Die {Plural(entries.Count, "Datei", "Dateien")} aus „{name}“ {(entries.Count == 1 ? "wird" : "werden")} zurück auf den Desktop gelegt – " +
+ "außer sie liegen dort schon. Mit Strg+Z kannst du das rückgängig machen.";
+ if (!Dialog.Confirm(this, "Kategorie löschen?", msg, "Löschen")) return;
+
+ var entry = new UndoEntry($"Kategorie „{name}“ löschen");
+ var errors = new List();
+ foreach (var p in entries)
+ {
+ var r = Storage.Transfer(p, Storage.UserDesktop);
+ if (r.Status == TransferStatus.Ok) entry.AddMove(p, r.Dest!);
+ else if (r.Status == TransferStatus.Skipped)
+ {
+ // liegt schon auf dem Desktop → Kopie in der Kategorie wird nicht mehr gebraucht
+ try { entry.AddDelete(p, Storage.Delete(p)); }
+ catch (Exception ex) { errors.Add(ex.Message); }
+ }
+ else errors.Add(r.Message!);
+ }
+ if (errors.Count > 0)
+ {
+ Undo.Push(entry);
+ Refresh();
+ ShowErrors(errors);
+ return;
+ }
+
+ var meta = S.Categories.GetValueOrDefault(name);
+ var orderIndex = S.CategoryOrder.FindIndex(o => string.Equals(o, name, StringComparison.OrdinalIgnoreCase));
+ try { Directory.Delete(folder, recursive: true); } // nur noch versteckte Reste wie desktop.ini
+ catch (Exception ex) { Error("Löschen fehlgeschlagen", ex.Message); }
+ S.RemoveCategory(name);
+ S.Save();
+ entry.Add(() =>
+ {
+ Directory.CreateDirectory(folder);
+ if (meta is not null) S.Categories[name] = meta;
+ if (orderIndex >= 0) S.CategoryOrder.Insert(Math.Min(orderIndex, S.CategoryOrder.Count), name);
+ S.Save();
+ return null;
+ });
+ Undo.Push(entry);
+
+ CategoryList.SelectedItem = null;
+ DesktopNav.SelectedIndex = 0;
+ Refresh();
+ FenceManager.Sync();
+ ShowToast($"Kategorie „{name}“ gelöscht", "", undoable: true);
+ }
+
+ private void CategoryList_ContextMenuOpening(object sender, ContextMenuEventArgs e)
+ {
+ if (FindParent(e.OriginalSource as DependencyObject)?.DataContext is not CategoryItem cat)
+ {
+ e.Handled = true;
+ return;
+ }
+ CategoryList.SelectedItem = cat;
+ var names = Storage.Categories();
+ int idx = names.FindIndex(n => n == cat.Name);
+
+ CategoryMenu.Items.Clear();
+ CategoryMenu.Items.Add(MenuItem("Bearbeiten…", "", () => EditCategory(cat.Name)));
+ CategoryMenu.Items.Add(MenuItem(cat.HasFence ? "Box vom Desktop entfernen" : "Als Box auf den Desktop", "", () => ToggleFence(cat.Name)));
+ CategoryMenu.Items.Add(MenuItem("Im Explorer öffnen", "", () => Process.Start("explorer.exe", $"\"{cat.Folder}\"")));
+ CategoryMenu.Items.Add(new Separator());
+ var up = MenuItem("Nach oben", "", () => MoveCategory(cat.Name, idx - 1));
+ up.IsEnabled = idx > 0;
+ var down = MenuItem("Nach unten", "", () => MoveCategory(cat.Name, idx + 1));
+ down.IsEnabled = idx < names.Count - 1;
+ CategoryMenu.Items.Add(up);
+ CategoryMenu.Items.Add(down);
+ CategoryMenu.Items.Add(new Separator());
+ CategoryMenu.Items.Add(MenuItem("Löschen", "", () => DeleteCategory(cat.Name)));
+ }
+
+ /// Kategorie an eine neue Position der Reihenfolge setzen.
+ private void MoveCategory(string name, int newIndex)
+ {
+ var names = Storage.Categories();
+ names.RemoveAll(n => n == name);
+ newIndex = Math.Clamp(newIndex, 0, names.Count);
+ names.Insert(newIndex, name);
+ S.CategoryOrder = names;
+ S.Save();
+ Refresh();
+ Select(name);
+ }
+
+ private void CategoryList_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e)
+ {
+ _dragStart = e.GetPosition(null);
+ _categoryDragName = (FindParent(e.OriginalSource as DependencyObject)?.DataContext as CategoryItem)?.Name;
+ }
+
+ private void CategoryList_PreviewMouseMove(object sender, MouseEventArgs e)
+ {
+ if (e.LeftButton != MouseButtonState.Pressed || _categoryDragName is null || _dragging) return;
+ var d = e.GetPosition(null) - _dragStart;
+ if (Math.Abs(d.Y) < SystemParameters.MinimumVerticalDragDistance * 2) return;
+ var name = _categoryDragName;
+ _categoryDragName = null;
+ _dragging = true;
+ try { DragDrop.DoDragDrop(CategoryList, new DataObject(CategoryDragFormat, name), DragDropEffects.Move); }
+ finally { _dragging = false; SetDropHighlight(null); }
+ }
+
+ // =====================================================================
+ // Icons
+ // =====================================================================
+
+ private List SelectedIcons => IconList.SelectedItems.Cast().ToList();
+
+ private void IconList_MouseDoubleClick(object sender, MouseButtonEventArgs e)
+ {
+ if (ItemAt(e.OriginalSource) is IconItem item) Open(item.Path);
+ }
+
+ private void IconList_KeyDown(object sender, KeyEventArgs e)
+ {
+ var sel = SelectedIcons;
+ if (sel.Count == 0) return;
+ if (CurrentGroup is { } group && !Searching)
+ {
+ if (e.Key == Key.Enter) { sel.ForEach(i => Open(i.Path)); e.Handled = true; }
+ else if (e.Key == Key.Delete) { RemoveFromGroup(group.Group, sel); e.Handled = true; }
+ return;
+ }
+ switch (e.Key)
+ {
+ case Key.Enter: sel.ForEach(i => Open(i.Path)); e.Handled = true; break;
+ case Key.Delete: DeleteIcons(sel); e.Handled = true; break;
+ case Key.F2 when sel.Count == 1: RenameIcon(sel[0]); e.Handled = true; break;
+ }
+ }
+
+ private void Open(string path)
+ {
+ try { Process.Start(new ProcessStartInfo(path) { UseShellExecute = true }); }
+ catch (Exception ex) { Error("Öffnen fehlgeschlagen", $"„{Path.GetFileName(path)}“ konnte nicht geöffnet werden.\n\n{ex.Message}"); }
+ }
+
+ private void IconList_ContextMenuOpening(object sender, ContextMenuEventArgs e)
+ {
+ var sel = SelectedIcons;
+ if (sel.Count == 0) { e.Handled = true; return; }
+ IconMenu.Items.Clear();
+ if (CurrentGroup is { } group && !Searching)
+ {
+ FillGroupItemMenu(group.Group, sel);
+ return;
+ }
+
+ 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(MenuItem("Umbenennen", "", () => RenameIcon(sel[0]), gesture: "F2"));
+ }
+ IconMenu.Items.Add(new Separator());
+
+ bool fromApp = sel.All(i => Storage.IsInStorage(i.Path));
+ var moveTo = MenuItem(fromApp ? "Verschieben nach" : "Sichern in", fromApp ? "" : "", null);
+ foreach (var c in Storage.Categories())
+ moveTo.Items.Add(MenuItem(c, S.Categories.GetValueOrDefault(c)?.Glyph ?? "",
+ () => TransferAll(sel.Select(i => i.Path), Storage.CategoryPath(c))));
+ if (moveTo.Items.Count > 0) moveTo.Items.Add(new Separator());
+ moveTo.Items.Add(MenuItem("Neue Kategorie…", "", () =>
+ {
+ if (NewCategory() is { } name) TransferAll(sel.Select(i => i.Path), Storage.CategoryPath(name));
+ }));
+ IconMenu.Items.Add(moveTo);
+
+ IconMenu.Items.Add(LaunchGroupSubmenu(sel.Select(i => i.Path).ToList()));
+ if (!fromApp && sel.Count == 1)
+ IconMenu.Items.Add(MenuItem("Regel für ähnliche Icons…", "", () => CreateRuleFor(sel[0])));
+
+ IconMenu.Items.Add(new Separator());
+ if (fromApp)
+ IconMenu.Items.Add(MenuItem("Zurück auf den Desktop legen", "", () => TransferAll(sel.Select(i => i.Path), Storage.UserDesktop)));
+ IconMenu.Items.Add(MenuItem(fromApp ? "Aus der App löschen" : "Vom Desktop löschen", "", () => DeleteIcons(sel), gesture: "Entf"));
+ }
+
+ public void RenameIcon(IconItem item)
+ {
+ var ext = Storage.HiddenExtension(item.Path);
+ var newName = Dialog.Prompt(this, "Umbenennen", "Neuer Name:", item.Name, "Umbenennen", text =>
+ {
+ if (Storage.ValidateName(text) is { } err) return err;
+ var target = Path.Combine(Path.GetDirectoryName(item.Path)!, text.Trim() + ext);
+ return !string.Equals(target, item.Path, StringComparison.OrdinalIgnoreCase) && (File.Exists(target) || Directory.Exists(target))
+ ? "Ein Element mit diesem Namen gibt es dort schon." : null;
+ });
+ if (newName is null || newName == item.Name) return;
+ try
+ {
+ var dest = Storage.Rename(item.Path, newName + ext);
+ S.ReplaceLaunchPath(item.Path, dest);
+ var entry = new UndoEntry($"„{item.Name}“ umbenennen");
+ entry.Add(() => { Storage.Rename(dest, Path.GetFileName(item.Path)); return null; });
+ Undo.Push(entry);
+ Refresh();
+ ShowToast($"Umbenannt in „{newName}“", "", undoable: true);
+ }
+ catch (UnauthorizedAccessException) { Error("Umbenennen fehlgeschlagen", "Keine Berechtigung (öffentlicher Desktop → App als Administrator starten)."); }
+ catch (Exception ex) { Error("Umbenennen fehlgeschlagen", ex.Message); }
+ }
+
+ public void DeleteIcons(List sel)
+ {
+ // Ungesicherte Desktop-Icons: nachfragen. Alles andere geht direkt (Strg+Z holt es zurück).
+ var unsorted = sel.Count(i => !Storage.IsInStorage(i.Path) && !i.IsSorted);
+ if (unsorted > 0)
+ {
+ var what = unsorted == 1 && sel.Count == 1 ? $"„{sel[0].Name}“ ist" : $"{unsorted} der ausgewählten Icons {(unsorted == 1 ? "ist" : "sind")}";
+ if (!Dialog.Confirm(this, "Wirklich löschen?",
+ $"{what} noch in keiner Kategorie gesichert. Trotzdem löschen?\n\nSie landen im Papierkorb.", "Löschen"))
+ return;
+ }
+
+ var entry = new UndoEntry(sel.Count == 1 ? $"„{sel[0].Name}“ löschen" : $"{sel.Count} Elemente löschen");
+ var errors = new List();
+ foreach (var i in sel)
+ {
+ try { entry.AddDelete(i.Path, Storage.Delete(i.Path)); }
+ catch (UnauthorizedAccessException) { errors.Add($"„{i.Name}“: Keine Berechtigung (öffentlicher Desktop → App als Administrator starten)."); }
+ catch (Exception ex) { errors.Add($"„{i.Name}“: {ex.Message}"); }
+ }
+ Undo.Push(entry);
+ Refresh();
+ if (errors.Count > 0) ShowErrors(errors);
+ else ShowToast($"{Plural(sel.Count, "Element", "Elemente")} gelöscht", "", undoable: true);
+ }
+
+ ///
+ /// Kopiert/verschiebt Dateien in einen Ordner – mit Rückgängig und Rückmeldung.
+ /// Läuft im Hintergrund; bei großen Datenmengen erscheint ein Fortschritt mit Abbrechen.
+ ///
+ public async void TransferAll(IEnumerable paths, string targetDir)
+ {
+ if (IsBusy) { ShowToast("Bitte warten, bis der laufende Vorgang fertig ist", "\uE946", info: true); return; }
+ var list = paths.ToList();
+ if (list.Count == 0) return;
+ var target = Storage.IsDesktopDir(targetDir) ? DesktopName : Path.GetFileName(targetDir.TrimEnd('\\'));
+
+ // Große Ordner würden beim Kopieren doppelt Platz belegen → lieber verschieben?
+ var bigFolders = new HashSet(StringComparer.OrdinalIgnoreCase);
+ if (Storage.IsInStorage(targetDir))
+ {
+ const long Limit = 200L * 1024 * 1024;
+ Mouse.OverrideCursor = Cursors.Wait;
+ List<(string Path, long Size)> big;
+ try
+ {
+ big = await Task.Run(() => list.Where(p => Directory.Exists(p) && !Storage.IsInStorage(p))
+ .Select(p => (Path: p, Size: Storage.DirectorySize(p, Limit * 50)))
+ .Where(x => x.Size > Limit).ToList());
+ }
+ finally { Mouse.OverrideCursor = null; }
+ if (big.Count > 0)
+ {
+ var what = big.Count == 1
+ ? $"Der Ordner „{Path.GetFileName(big[0].Path)}“ ist {(big[0].Size >= Limit * 50 ? "über " : "")}{Backup.FormatSize(big[0].Size)} groß."
+ : $"{big.Count} Ordner sind zusammen {Backup.FormatSize(big.Sum(b => b.Size))} groß.";
+ if (Dialog.Confirm(this, "Großer Ordner",
+ $"{what}\n\nBeim Kopieren würde er doppelt Speicherplatz belegen. Verschieben legt ihn nur in die Kategorie " +
+ "(er verschwindet dann vom Desktop).", "Verschieben", "Trotzdem kopieren"))
+ foreach (var b in big) bigFolders.Add(b.Path);
+ }
+ }
+
+ bool anyCopy = list.Any(p => !bigFolders.Contains(p) && !Storage.IsInStorage(p) && Storage.IsInStorage(targetDir));
+ var detail = list.Count == 1 ? $"„{Storage.DisplayName(list[0])}“ → {target}" : $"{list.Count} Elemente → {target}";
+ List results;
+ try
+ {
+ results = await RunWithProgress(anyCopy ? "Wird kopiert …" : "Wird verschoben …", detail, (sink, ct) =>
+ {
+ sink.SetTotal(list.Sum(p => Storage.BytesToCopy(p, targetDir, bigFolders.Contains(p))));
+ var r = new List();
+ foreach (var p in list)
+ {
+ if (list.Count > 1) sink.Detail = $"„{Storage.DisplayName(p)}“ → {target}";
+ r.Add(Storage.Transfer(p, targetDir, bigFolders.Contains(p), sink.Add, ct));
+ }
+ return Task.FromResult(r);
+ });
+ }
+ catch (Exception ex)
+ {
+ Refresh();
+ Error("Übertragen fehlgeschlagen", ex.Message);
+ return;
+ }
+
+ var entry = new UndoEntry($"{Plural(list.Count, "Element", "Elemente")} nach „{target}“");
+ foreach (var r in results.Where(r => r.Status == TransferStatus.Ok))
+ {
+ if (r.Copied) entry.AddCopy(r.Source, r.Dest!);
+ else
+ {
+ entry.AddMove(r.Source, r.Dest!);
+ S.ReplaceLaunchPath(r.Source, r.Dest!); // Start-Gruppen folgen dem verschobenen Element
+ }
+ }
+ Undo.Push(entry);
+ Refresh();
+ FenceManager.RefreshAll();
+
+ var ok = results.Where(r => r.Status == TransferStatus.Ok).ToList();
+ var skipped = results.Where(r => r.Status == TransferStatus.Skipped).ToList();
+ var cancelled = results.Count(r => r.Status == TransferStatus.Cancelled);
+ var errors = results.Where(r => r.Status == TransferStatus.Error).Select(r => r.Message!).ToList();
+ if (errors.Count > 0) { ShowErrors(errors); return; }
+
+ if (cancelled > 0)
+ {
+ ShowToast(ok.Count > 0 ? $"Abgebrochen – {Plural(ok.Count, "Element", "Elemente")} wurden noch übertragen" : "Abgebrochen – es wurde nichts verändert",
+ "\uE711", undoable: ok.Count > 0, info: true);
+ return;
+ }
+ if (ok.Count == 0 && skipped.Count > 0)
+ {
+ ShowToast(skipped.Count == 1 ? skipped[0].Message! : $"Alle {skipped.Count} Elemente sind schon in „{target}“", "\uE946", info: true);
+ return;
+ }
+ var n = Plural(ok.Count, "Element", "Elemente");
+ var extra = skipped.Count > 0 ? $" · {skipped.Count} waren schon da" : "";
+ if (Storage.IsDesktopDir(targetDir))
+ ShowToast($"{n} zurück auf den Desktop gelegt{extra}", "\uE7F4", undoable: true);
+ else if (ok.All(r => r.Copied))
+ ShowToast($"{n} in „{target}“ gesichert{extra}", undoable: true);
+ else
+ ShowToast($"{n} nach „{target}“ verschoben{extra}", "\uE8DE", undoable: true);
+ }
+
+ // ---------- Rückgängig ----------
+
+ private void Undo_Click(object sender, RoutedEventArgs e) => DoUndo();
+
+ private void DoUndo()
+ {
+ if (Undo.Undo() is not { } r) return;
+ Refresh();
+ FenceManager.Sync();
+ if (r.Errors.Count > 0) ShowErrors(r.Errors);
+ else ShowToast($"Rückgängig: {r.Description}", "");
+ }
+
+ private void UpdateUndoButton()
+ {
+ UndoButton.IsEnabled = Undo.CanUndo;
+ UndoButton.ToolTip = Undo.CanUndo ? $"Rückgängig: {Undo.NextDescription} (Strg+Z)" : "Rückgängig (Strg+Z)";
+ }
+
+ // =====================================================================
+ // Drag & Drop
+ // =====================================================================
+
+ private void IconList_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) => _dragStart = e.GetPosition(null);
+
+ private void IconList_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 (ItemAt(e.OriginalSource) is null) return;
+
+ var paths = SelectedIcons.Select(i => i.Path).ToArray();
+ if (paths.Length == 0) return;
+ _dragging = true;
+ try
+ {
+ // FileDrop: funktioniert intern und auch zum Rausziehen auf Desktop/Explorer.
+ // Nur "Copy" erlauben, damit der Explorer die Dateien nicht aus der App entfernt.
+ DragDrop.DoDragDrop(IconList, new DataObject(DataFormats.FileDrop, paths), DragDropEffects.Copy);
+ }
+ finally { _dragging = false; }
+ Refresh();
+ }
+
+ private static string[]? DroppedFiles(DragEventArgs e) =>
+ e.Data.GetDataPresent(DataFormats.FileDrop) ? e.Data.GetData(DataFormats.FileDrop) as string[] : null;
+
+ private static DragDropEffects DropEffect(DragEventArgs e) =>
+ e.AllowedEffects.HasFlag(DragDropEffects.Copy) ? DragDropEffects.Copy : DragDropEffects.Move;
+
+ private void IconList_DragOver(object sender, DragEventArgs e)
+ {
+ bool group = CurrentGroup is not null;
+ e.Effects = DroppedFiles(e) is not null && (Current is not null || group) && !Searching
+ ? (group ? DragDropEffects.Link | (e.AllowedEffects & DragDropEffects.Copy) : DropEffect(e))
+ : DragDropEffects.None;
+ e.Handled = true;
+ }
+
+ private void IconList_Drop(object sender, DragEventArgs e)
+ {
+ if (DroppedFiles(e) is not { } files || Searching) return;
+ if (CurrentGroup is { } group) AddPathsToGroup(group.Group, files);
+ else if (Current is { } cat) TransferAll(files, cat.Folder);
+ }
+
+ private ListBoxItem? _dropHighlight;
+
+ private void Nav_DragOver(object sender, DragEventArgs e)
+ {
+ var lbi = FindParent(e.OriginalSource as DependencyObject);
+ if (e.Data.GetDataPresent(CategoryDragFormat))
+ {
+ // Kategorie umsortieren: nur innerhalb der Kategorienliste
+ bool ok = sender == CategoryList && lbi is not null;
+ // Linie über oder unter dem Eintrag zeigen, je nachdem wo die Maus ist
+ SetDropHighlight(ok ? lbi : null, ok && e.GetPosition(lbi).Y > lbi!.ActualHeight / 2 ? "after" : "before");
+ e.Effects = ok ? DragDropEffects.Move : DragDropEffects.None;
+ }
+ else
+ {
+ SetDropHighlight(lbi);
+ e.Effects = DroppedFiles(e) is not null && lbi is not null ? DropEffect(e) : DragDropEffects.None;
+ }
+ e.Handled = true;
+ }
+
+ private void Nav_DragLeave(object sender, DragEventArgs e) => SetDropHighlight(null);
+
+ private void Nav_Drop(object sender, DragEventArgs e)
+ {
+ SetDropHighlight(null);
+ var lbi = FindParent(e.OriginalSource as DependencyObject);
+ if (lbi?.DataContext is not CategoryItem target) return;
+
+ if (e.Data.GetData(CategoryDragFormat) is string dragged)
+ {
+ if (target.IsDesktop || dragged == target.Name) return;
+ var names = Storage.Categories();
+ int targetIdx = names.IndexOf(target.Name);
+ int fromIdx = names.IndexOf(dragged);
+ // In der unteren Hälfte losgelassen → dahinter einsortieren
+ bool after = e.GetPosition(lbi).Y > lbi.ActualHeight / 2;
+ int idx = targetIdx + (after ? 1 : 0) - (fromIdx < targetIdx ? 1 : 0);
+ MoveCategory(dragged, idx);
+ return;
+ }
+ if (DroppedFiles(e) is { } files) TransferAll(files, target.Folder);
+ }
+
+ private void SetDropHighlight(ListBoxItem? lbi, string tag = "drop")
+ {
+ if (_dropHighlight is not null && _dropHighlight != lbi) _dropHighlight.Tag = null;
+ _dropHighlight = lbi;
+ if (lbi is not null) lbi.Tag = tag;
+ }
+
+ // =====================================================================
+ // Automatisch sortieren
+ // =====================================================================
+
+ /// Vom Watcher: neue Elemente auf dem Desktop.
+ public void AutoSortNew(List paths)
+ {
+ if (!S.AutoSort || S.Rules.Count == 0) return;
+ var done = ApplyRules(paths, "Automatisch einsortiert");
+ if (done.Count == 0) return;
+
+ var text = done.Count == 1 ? $"„{Storage.DisplayName(done[0].Path)}“ → {done[0].Category}" : $"{done.Count} neue Icons einsortiert";
+ if (IsVisible && IsActive) ShowToast(text, "", undoable: true);
+ else App.TrayIcon?.Notify("Automatisch einsortiert", text);
+ }
+
+ private List<(string Path, string Category)> ApplyRules(IEnumerable paths, string description)
+ {
+ var entry = new UndoEntry(description);
+ var done = new List<(string, string)>();
+ foreach (var p in paths)
+ {
+ if (AutoSorter.Match(p) is not { } cat) continue;
+ var r = Storage.Transfer(p, Storage.CategoryPath(cat));
+ if (r.Status == TransferStatus.Ok)
+ {
+ entry.AddCopy(r.Source, r.Dest!);
+ done.Add((p, cat));
+ }
+ if (S.AutoSortRemoveFromDesktop && r.Status is TransferStatus.Ok or TransferStatus.Skipped && !Storage.IsInStorage(p))
+ {
+ try { entry.AddDelete(p, Storage.Delete(p)); } catch { }
+ }
+ }
+ Undo.Push(entry);
+ Refresh();
+ return done;
+ }
+
+ private void ApplyRulesNow_Click(object sender, RoutedEventArgs e)
+ {
+ if (S.Rules.Count == 0) { ShowToast("Lege zuerst eine Regel an", "", info: true); return; }
+ var done = ApplyRules(Storage.DesktopEntries().ToList(), "Regeln anwenden");
+ ShowToast(done.Count == 0 ? "Keine neuen passenden Icons gefunden" : $"{Plural(done.Count, "Icon", "Icons")} einsortiert",
+ done.Count == 0 ? "" : "", undoable: done.Count > 0, info: done.Count == 0);
+ }
+
+ private void CreateRuleFor(IconItem item)
+ {
+ var cats = Storage.Categories();
+ if (cats.Count == 0) { ShowToast("Lege zuerst eine Kategorie an", "", info: true); return; }
+ // Vorschlag: erstes sinnvolles Wort des Namens
+ var suggestion = item.Name.Split(' ', '-', '_').FirstOrDefault(w => w.Length > 2) ?? item.Name;
+ S.Rules.Add(new SortRule { Contains = suggestion, Category = cats[0] });
+ S.Save();
+ ShowSettings();
+ ShowToast("Regel angelegt – jetzt noch die Kategorie auswählen", "", info: true);
+ }
+
+ // =====================================================================
+ // Einstellungen
+ // =====================================================================
+
+ private void LoadSettingsView()
+ {
+ _loadingSettings = true;
+ DesignLight.IsChecked = S.Design == DesignMode.Light;
+ DesignDark.IsChecked = S.Design == DesignMode.Dark;
+ DesignSystem.IsChecked = S.Design == DesignMode.System;
+ ViewSmall.IsChecked = S.View == ViewMode.Small;
+ ViewMedium.IsChecked = S.View == ViewMode.Medium;
+ ViewLarge.IsChecked = S.View == ViewMode.Large;
+ ViewList.IsChecked = S.View == ViewMode.List;
+
+ AutostartToggle.IsChecked = Autostart.Enabled;
+ TrayToggle.IsChecked = S.TrayEnabled;
+ CloseToTrayToggle.IsChecked = S.CloseToTray;
+ CloseToTrayToggle.IsEnabled = S.TrayEnabled;
+ HotkeyToggle.IsChecked = S.HotkeyEnabled;
+ HotkeyBox.Text = Hotkey.Describe(S.HotkeyModifiers, S.HotkeyKey);
+ HotkeyBox.IsEnabled = S.HotkeyEnabled;
+
+ AutoSortToggle.IsChecked = S.AutoSort;
+ AutoUpdateToggle.IsChecked = S.AutoUpdateCheck;
+ DeadShortcutsToggle.IsChecked = S.RemoveDeadShortcuts;
+ LoadFenceSettings();
+ LoadDesktopSettings();
+ AutoRemoveToggle.IsChecked = S.AutoSortRemoveFromDesktop;
+ RulesList.ItemsSource = null;
+ RulesList.ItemsSource = S.Rules;
+ NoRulesText.Visibility = S.Rules.Count == 0 ? Visibility.Visible : Visibility.Collapsed;
+
+ FencesToggle.IsChecked = S.FencesEnabled;
+
+ _loadingSettings = false;
+ UpdateBackupHint();
+ }
+
+ private long _storageSize = -1;
+
+ private async void UpdateBackupHint()
+ {
+ var cats = Storage.Categories().Count;
+ string Text() => $"{Plural(cats, "Kategorie", "Kategorien")}" +
+ (_storageSize >= 0 ? $", zusammen {Backup.FormatSize(_storageSize)}" : "") +
+ " – als ZIP-Datei, z. B. für einen neuen PC";
+ BackupHint.Text = Text();
+ _storageSize = await Task.Run(Backup.StorageSize);
+ BackupHint.Text = Text();
+ }
+
+ private void Design_Checked(object sender, RoutedEventArgs e)
+ {
+ if (_loadingSettings) return;
+ SetDesign(sender == DesignLight ? DesignMode.Light : sender == DesignDark ? DesignMode.Dark : DesignMode.System);
+ }
+
+ private void View_Checked(object sender, RoutedEventArgs e)
+ {
+ if (_loadingSettings) return;
+ ApplyView(sender == ViewSmall ? ViewMode.Small : sender == ViewLarge ? ViewMode.Large : sender == ViewList ? ViewMode.List : ViewMode.Medium);
+ S.Save();
+ }
+
+ private void AutostartToggle_Click(object sender, RoutedEventArgs e)
+ {
+ Autostart.Enabled = AutostartToggle.IsChecked == true;
+ // Autostart startet im Infobereich – dafür muss das Symbol aktiv sein
+ if (Autostart.Enabled && !S.TrayEnabled) { S.TrayEnabled = true; S.Save(); App.ApplyTray(); }
+ LoadSettingsView();
+ }
+
+ private void TrayToggle_Click(object sender, RoutedEventArgs e)
+ {
+ S.TrayEnabled = TrayToggle.IsChecked == true;
+ S.Save();
+ App.ApplyTray();
+ LoadSettingsView();
+ }
+
+ private void CloseToTrayToggle_Click(object sender, RoutedEventArgs e)
+ {
+ S.CloseToTray = CloseToTrayToggle.IsChecked == true;
+ S.Save();
+ }
+
+ private void HotkeyToggle_Click(object sender, RoutedEventArgs e)
+ {
+ S.HotkeyEnabled = HotkeyToggle.IsChecked == true;
+ S.Save();
+ var ok = App.ApplyHotkey();
+ LoadSettingsView();
+ UpdateHotkeyHint(ok);
+ }
+
+ private void HotkeyBox_PreviewKeyDown(object sender, KeyEventArgs e)
+ {
+ var key = e.Key == Key.System ? e.SystemKey : e.Key;
+ if (key is Key.Tab) return;
+ e.Handled = true;
+ if (key is Key.LeftCtrl or Key.RightCtrl or Key.LeftAlt or Key.RightAlt or Key.LeftShift or Key.RightShift
+ or Key.LWin or Key.RWin or Key.Escape) return;
+
+ int mods = (int)Keyboard.Modifiers; // Alt=1, Strg=2, Umschalt=4, Win=8 – gleiche Bits wie RegisterHotKey
+ if ((mods & (Hotkey.ModCtrl | Hotkey.ModAlt | Hotkey.ModWin)) == 0)
+ {
+ HotkeyHint.Text = "Bitte mit Strg, Alt oder Win kombinieren.";
+ return;
+ }
+ S.HotkeyModifiers = mods;
+ S.HotkeyKey = KeyInterop.VirtualKeyFromKey(key);
+ S.Save();
+ HotkeyBox.Text = Hotkey.Describe(S.HotkeyModifiers, S.HotkeyKey);
+ UpdateHotkeyHint(App.ApplyHotkey());
+ }
+
+ private void UpdateHotkeyHint(bool ok) =>
+ HotkeyHint.Text = !S.HotkeyEnabled ? "Ausgeschaltet"
+ : ok ? $"{Hotkey.Describe(S.HotkeyModifiers, S.HotkeyKey)} öffnet die App von überall – ins Feld klicken und neue Kombination drücken"
+ : "⚠ Diese Kombination ist schon von einem anderen Programm belegt – bitte eine andere wählen.";
+
+ private void AutoSortToggle_Click(object sender, RoutedEventArgs e)
+ {
+ S.AutoSort = AutoSortToggle.IsChecked == true;
+ S.Save();
+ if (S.AutoSort && S.Rules.Count == 0) ShowToast("Lege unten noch Regeln an", "", info: true);
+ }
+
+ private void AutoRemoveToggle_Click(object sender, RoutedEventArgs e)
+ {
+ S.AutoSortRemoveFromDesktop = AutoRemoveToggle.IsChecked == true;
+ S.Save();
+ }
+
+ private void AddRule_Click(object sender, RoutedEventArgs e)
+ {
+ var cats = Storage.Categories();
+ if (cats.Count == 0) { ShowToast("Lege zuerst eine Kategorie an", "", info: true); return; }
+ S.Rules.Add(new SortRule { Contains = "", Category = cats[0] });
+ S.Save();
+ LoadSettingsView();
+ }
+
+ private void RuleText_LostFocus(object sender, RoutedEventArgs e) => S.Save();
+
+ private void RuleDelete_Click(object sender, RoutedEventArgs e)
+ {
+ if ((sender as FrameworkElement)?.Tag is SortRule r)
+ {
+ S.Rules.Remove(r);
+ S.Save();
+ LoadSettingsView();
+ }
+ }
+
+ private void RuleCategory_Click(object sender, RoutedEventArgs e)
+ {
+ if (sender is not Button b || b.Tag is not SortRule r) return;
+ var menu = new ContextMenu { PlacementTarget = b, Placement = PlacementMode.Bottom };
+ foreach (var c in Storage.Categories())
+ {
+ menu.Items.Add(MenuItem(c, S.Categories.GetValueOrDefault(c)?.Glyph ?? "", () =>
+ {
+ r.Category = c;
+ S.Save();
+ LoadSettingsView();
+ }, bold: string.Equals(c, r.Category, StringComparison.OrdinalIgnoreCase)));
+ }
+ menu.IsOpen = true;
+ }
+
+ private void FencesToggle_Click(object sender, RoutedEventArgs e)
+ {
+ S.FencesEnabled = FencesToggle.IsChecked == true;
+ S.Save();
+ FenceManager.Sync();
+ if (S.FencesEnabled && S.Fences.Count == 0)
+ ShowToast("Wähle bei einer Kategorie „Als Box auf den Desktop“", "", info: true);
+ }
+
+ private async void Export_Click(object sender, RoutedEventArgs e)
+ {
+ var dlg = new Microsoft.Win32.SaveFileDialog
+ {
+ Title = "Kategorien sichern",
+ FileName = $"DeskCore-Sicherung-{DateTime.Now:yyyy-MM-dd}.zip",
+ Filter = "ZIP-Archiv (*.zip)|*.zip",
+ InitialDirectory = Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments),
+ };
+ if (dlg.ShowDialog(this) != true) return;
+ if (Storage.IsUnder(dlg.FileName, Storage.Root))
+ {
+ Error("Anderen Ort wählen", "Die Sicherung kann nicht im DeskCore-Ordner selbst gespeichert werden – sie würde sich sonst mitsichern. " +
+ "Am besten z. B. unter Dokumente oder auf einem USB-Stick speichern.");
+ return;
+ }
+ var size = await Task.Run(Backup.StorageSize);
+ if (size > 2L * 1024 * 1024 * 1024 &&
+ !Dialog.Confirm(this, "Große Sicherung",
+ $"Deine Kategorien sind zusammen {Backup.FormatSize(size)} groß (vor allem durch Ordner). Das Sichern kann eine Weile dauern " +
+ "und braucht entsprechend Platz.", "Trotzdem sichern"))
+ return;
+ if (IsBusy) return;
+ try
+ {
+ var file = dlg.FileName;
+ var count = await RunWithProgress("Sicherung wird erstellt …", Path.GetFileName(file), (sink, ct) =>
+ {
+ sink.SetTotal(size);
+ return Task.FromResult(Backup.Export(file, sink.Add, ct));
+ });
+ ShowToast($"{Plural(count, "Datei", "Dateien")} gesichert in {Path.GetFileName(dlg.FileName)}", "");
+ }
+ catch (OperationCanceledException) { ShowToast("Sicherung abgebrochen – es wurde keine Datei angelegt", "\uE711", info: true); }
+ catch (Exception ex) { Error("Sichern fehlgeschlagen", ex.Message); }
+ }
+
+ private async void Import_Click(object sender, RoutedEventArgs e)
+ {
+ var dlg = new Microsoft.Win32.OpenFileDialog
+ {
+ Title = "Sicherung wiederherstellen",
+ Filter = "ZIP-Archiv (*.zip)|*.zip",
+ InitialDirectory = Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments),
+ };
+ if (dlg.ShowDialog(this) != true) return;
+ if (!Dialog.Confirm(this, "Sicherung wiederherstellen?",
+ "Die Kategorien aus der Sicherung werden zu deinen vorhandenen hinzugefügt. Vorhandene Dateien werden nicht überschrieben.",
+ "Wiederherstellen"))
+ return;
+ if (IsBusy) return;
+ try
+ {
+ var file = dlg.FileName;
+ var (added, skipped) = await RunWithProgress("Sicherung wird wiederhergestellt …", Path.GetFileName(file), (sink, ct) =>
+ {
+ sink.SetTotal(Backup.ArchiveSize(file));
+ return Task.FromResult(Backup.Import(file, sink.Add, ct));
+ });
+ Refresh();
+ FenceManager.Sync();
+ LoadSettingsView();
+ ShowToast($"{Plural(added, "Datei", "Dateien")} wiederhergestellt" + (skipped > 0 ? $" · {skipped} waren schon da" : ""), "");
+ }
+ catch (OperationCanceledException)
+ {
+ Refresh();
+ ShowToast("Wiederherstellen abgebrochen – bereits übernommene Dateien bleiben erhalten", "\uE711", info: true);
+ }
+ catch (Exception ex) { Error("Wiederherstellen fehlgeschlagen", $"Die Datei ist keine gültige Sicherung.\n\n{ex.Message}"); }
+ }
+
+ private void License_Click(object sender, RoutedEventArgs e)
+ {
+ string text;
+ try
+ {
+ using var r = new StreamReader(Application.GetResourceStream(new Uri("pack://application:,,,/LICENSE.txt"))!.Stream);
+ text = r.ReadToEnd().Trim();
+ }
+ catch { text = "Lizenztext nicht gefunden."; }
+
+ var box = new Border
+ {
+ Background = (Brush)FindResource("ControlFillBrush"),
+ BorderBrush = (Brush)FindResource("ControlStrokeBrush"),
+ BorderThickness = new Thickness(1), CornerRadius = new CornerRadius(6),
+ Child = new ScrollViewer
+ {
+ MaxHeight = 380, VerticalScrollBarVisibility = ScrollBarVisibility.Auto,
+ Content = new TextBlock
+ {
+ Text = text, TextWrapping = TextWrapping.Wrap, FontSize = 13, LineHeight = 19,
+ Margin = new Thickness(16, 12, 16, 12), Foreground = (Brush)FindResource("TextBrush"),
+ },
+ },
+ };
+ Dialog.Show(this, "Lizenz- und Nutzungsbedingungen", box, "Schließen", null, null, width: 640);
+ }
+
+ private void OpenStorage_Click(object sender, RoutedEventArgs e) => Process.Start("explorer.exe", $"\"{Storage.Root}\"");
+ private void Quit_Click(object sender, RoutedEventArgs e) => App.Quit();
+
+ // =====================================================================
+ // Tastatur
+ // =====================================================================
+
+ private void OnPreviewKeyDown(object sender, KeyEventArgs e)
+ {
+ bool ctrl = Keyboard.Modifiers == ModifierKeys.Control;
+ bool inText = e.OriginalSource is TextBox;
+ if (e.OriginalSource == HotkeyBox) return;
+
+ if (Keyboard.Modifiers == ModifierKeys.Alt && e.Key == Key.System && e.SystemKey is Key.Up or Key.Down
+ && !inText && CategoryList.SelectedItem is CategoryItem moving)
+ {
+ // Alt+↑/↓: ausgewählte Kategorie verschieben
+ int idx = CategoryList.SelectedIndex + (e.SystemKey == Key.Up ? -1 : 1);
+ if (idx >= 0 && idx < CategoryList.Items.Count)
+ {
+ MoveCategory(moving.Name, idx);
+ (CategoryList.ItemContainerGenerator.ContainerFromIndex(idx) as ListBoxItem)?.Focus();
+ }
+ e.Handled = true;
+ }
+ else if (ctrl && e.Key == Key.Z && !inText) { DoUndo(); e.Handled = true; }
+ else if (ctrl && e.Key == Key.F) { ShowBrowse(); SearchBox.Focus(); SearchBox.SelectAll(); e.Handled = true; }
+ else if (ctrl && e.Key == Key.N) { NewCategory_Click(this, e); e.Handled = true; }
+ else if (ctrl && e.Key == Key.A && !inText && !InSettings) { IconList.SelectAll(); IconList.Focus(); e.Handled = true; }
+ else if (e.Key == Key.F5) { Refresh(); e.Handled = true; }
+ else if (e.Key == Key.Escape && e.OriginalSource == SearchBox && SearchBox.Text.Length > 0) { SearchBox.Text = ""; e.Handled = true; }
+ else if (e.Key == Key.Down && e.OriginalSource == SearchBox && IconList.Items.Count > 0)
+ {
+ IconList.SelectedIndex = 0;
+ (IconList.ItemContainerGenerator.ContainerFromIndex(0) as ListBoxItem)?.Focus();
+ e.Handled = true;
+ }
+ else if (ctrl && e.Key is >= Key.D1 and <= Key.D9)
+ {
+ int n = e.Key - Key.D1;
+ ShowBrowse();
+ SearchBox.Text = "";
+ if (n == 0) DesktopNav.SelectedIndex = 0;
+ else if (n - 1 < CategoryList.Items.Count) CategoryList.SelectedIndex = n - 1;
+ e.Handled = true;
+ }
+ }
+
+ // =====================================================================
+ // Rückmeldungen
+ // =====================================================================
+
+ private void ShowToast(string text, string glyph = "", bool undoable = false, bool info = false)
+ {
+ ToastText.Text = text;
+ ToastGlyph.Text = glyph;
+ ToastBadge.Background = (Brush)FindResource(info ? "AccentBrush" : "SuccessBrush");
+ ToastGlyph.Foreground = info ? (Brush)FindResource("OnAccentBrush") : Brushes.White;
+ ToastUndo.Visibility = undoable && Undo.CanUndo ? Visibility.Visible : Visibility.Collapsed;
+ Toast.IsHitTestVisible = true;
+ AnimateToast(true);
+ _toastTimer.Stop();
+ _toastTimer.Interval = TimeSpan.FromSeconds(undoable ? 6 : 3.5);
+ _toastTimer.Start();
+ }
+
+ private void AnimateToast(bool show)
+ {
+ if (!show) Toast.IsHitTestVisible = false;
+ var ease = new CubicEase { EasingMode = EasingMode.EaseOut };
+ var dur = TimeSpan.FromMilliseconds(show ? 220 : 300);
+ Toast.BeginAnimation(OpacityProperty, new DoubleAnimation(show ? 1 : 0, dur) { EasingFunction = ease });
+ ToastShift.BeginAnimation(TranslateTransform.YProperty, new DoubleAnimation(show ? 0 : 12, dur) { EasingFunction = ease });
+ }
+
+ private void Error(string title, string msg) => Dialog.Info(this, title, msg);
+
+ private void ShowErrors(List errors) =>
+ Error("Nicht alles hat geklappt",
+ string.Join("\n\n", errors.Take(8)) + (errors.Count > 8 ? $"\n\n… und {errors.Count - 8} weitere" : ""));
+
+ // =====================================================================
+ // Helfer
+ // =====================================================================
+
+ public static MenuItem MenuItem(string header, string glyph, Action? action, bool bold = false, string? gesture = null)
+ {
+ var mi = new MenuItem
+ {
+ Header = header,
+ Icon = new TextBlock { Text = glyph },
+ FontWeight = bold ? FontWeights.SemiBold : FontWeights.Normal,
+ };
+ if (gesture is not null) mi.InputGestureText = gesture;
+ if (action is not null) mi.Click += (_, _) => action();
+ return mi;
+ }
+
+ private object? ItemAt(object source) => FindParent(source as DependencyObject)?.DataContext;
+
+ public static T? FindParent(DependencyObject? d) where T : DependencyObject
+ {
+ while (d is not null and not T)
+ d = d is Visual or System.Windows.Media.Media3D.Visual3D ? VisualTreeHelper.GetParent(d) : LogicalTreeHelper.GetParent(d);
+ return d as T;
+ }
+}
diff --git a/Migration.cs b/Migration.cs
index 5838276..cf511fd 100644
--- a/Migration.cs
+++ b/Migration.cs
@@ -22,6 +22,7 @@ public static class Migration
public static void Run()
{
+ if (Environment.GetEnvironmentVariable("DESKCORE_DATA") is { Length: > 0 }) return; // Testmodus: nichts umziehen
if (FindLegacy() is { } legacy)
{
foreach (var folder in Folders)
@@ -67,6 +68,10 @@ public static class Migration
/// Neuer Ordner – außer der Umzug hat nicht geklappt und nur der alte existiert.
public static string Pick(Environment.SpecialFolder folder, params string[] sub)
{
+ // Testmodus: alle Daten in einem eigenen Ordner, damit Tests nie echte Einstellungen/Kategorien berühren
+ if (Environment.GetEnvironmentVariable("DESKCORE_DATA") is { Length: > 0 } testRoot)
+ return Path.Combine([testRoot, folder.ToString(), .. sub]);
+
var baseDir = Environment.GetFolderPath(folder);
if (!Directory.Exists(Path.Combine(baseDir, NewName)) && FindLegacy() is { } legacy
&& Directory.Exists(Path.Combine(baseDir, legacy)))
diff --git a/Models.cs b/Models.cs
index abb19d6..7863ca4 100644
--- a/Models.cs
+++ b/Models.cs
@@ -1,97 +1,109 @@
-using System.ComponentModel;
-using System.IO;
-using System.Windows.Media;
-
-namespace DeskCore;
-
-public class CategoryItem
-{
- public required string Name { get; init; }
- public bool IsDesktop { get; init; }
- public int Count { get; set; }
- public string Folder => IsDesktop ? Storage.UserDesktop : Storage.CategoryPath(Name);
-
- public CategoryMeta? Meta => IsDesktop ? null : AppSettings.Current.Categories.GetValueOrDefault(Name);
- public string Glyph => IsDesktop ? "" : Meta?.Glyph ?? "";
- public Brush? GlyphBrush => CategoryColors.Brush(Meta?.Color);
- public bool HasFence => Meta?.Fence == true;
-}
-
-public static class CategoryColors
-{
- public static readonly (string Name, string Hex)[] All =
- [
- ("Blau", "#3B82F6"), ("Türkis", "#12A594"), ("Grün", "#22A06B"), ("Gelb", "#E2A600"),
- ("Orange", "#F07A1A"), ("Rot", "#E5484D"), ("Pink", "#D6409F"), ("Lila", "#8E4EC6"), ("Grau", "#8B8D98"),
- ];
-
- public static Brush? Brush(string? hex)
- {
- if (string.IsNullOrEmpty(hex)) return null;
- try
- {
- var b = new SolidColorBrush((Color)ColorConverter.ConvertFromString(hex));
- b.Freeze();
- return b;
- }
- catch { return null; }
- }
-}
-
-public static class CategoryGlyphs
-{
- public static readonly (string Glyph, string Name)[] All =
- [
- ("", "Ordner"), ("", "Spiele"), ("", "Arbeit"), ("", "Code"),
- ("", "Musik"), ("", "Video"), ("", "Bilder"), ("", "Internet"),
- ("", "Mail"), ("", "Shopping"), ("", "System"), ("", "Werkzeuge"),
- ("", "Dokumente"), ("", "Favoriten"), ("", "Herz"), ("", "Cloud"),
- ("", "Downloads"), ("", "Personen"), ("", "Bibliothek"), ("", "Rechner"),
- ("", "Ideen"), ("", "Zuhause"), ("", "Lernen"), ("", "Erweiterungen"),
- ("", "Computer"), ("", "Sicherheit"), ("", "Ebenen"),
- ];
-}
-
-public class IconItem : INotifyPropertyChanged
-{
- public required string Path { get; init; }
- public string? Category { get; init; }
-
- private ImageSource? _icon;
- public ImageSource? Icon
- {
- get => _icon;
- set { _icon = value; PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Icon))); }
- }
-
- /// Nur für Desktop-Icons: Kategorie, in der bereits eine Kopie liegt.
- public string? SortedInto { get; init; }
- public bool IsSorted => SortedInto is not null;
-
- public string Name => Storage.DisplayName(Path);
-
- public string TypeText
- {
- get
- {
- if (Directory.Exists(Path)) return "Ordner";
- var ext = System.IO.Path.GetExtension(Path).ToLowerInvariant();
- return ext switch
- {
- ".lnk" => "Verknüpfung",
- ".url" => "Internetverknüpfung",
- ".exe" => "Programm",
- "" => "Datei",
- _ => ext.TrimStart('.').ToUpperInvariant() + "-Datei",
- };
- }
- }
-
- public string LocationText => SortedInto is not null ? $"✓ gesichert in {SortedInto}" : Category ?? "";
-
- public string ToolTip =>
- SortedInto is not null ? $"{Name}\n✓ Bereits gesichert in „{SortedInto}“ – kann vom Desktop gelöscht werden." :
- Category is not null ? $"{Name}\nOrt: {Category}" : $"{Name}\n{TypeText}";
-
- public event PropertyChangedEventHandler? PropertyChanged;
-}
+using System.ComponentModel;
+using System.IO;
+using System.Windows.Media;
+
+namespace DeskCore;
+
+public class CategoryItem
+{
+ public required string Name { get; init; }
+ public bool IsDesktop { get; init; }
+ public int Count { get; set; }
+ public string Folder => IsDesktop ? Storage.UserDesktop : Storage.CategoryPath(Name);
+
+ public CategoryMeta? Meta => IsDesktop ? null : AppSettings.Current.Categories.GetValueOrDefault(Name);
+ public string Glyph => IsDesktop ? "" : Meta?.Glyph ?? "";
+ public Brush? GlyphBrush => CategoryColors.Brush(Meta?.Color);
+ public bool HasFence => !IsDesktop && AppSettings.Current.HasFence(Name);
+}
+
+/// Eintrag einer Start-Gruppe in der Seitenleiste.
+public class LaunchGroupItem
+{
+ public required LaunchGroup Group { get; init; }
+ public string Name => Group.Name;
+ public int Count => Group.Items.Count;
+ public string Glyph => ""; // Abspielen
+ public Brush? GlyphBrush => CategoryColors.Brush(Group.Color);
+ public bool HasFence => false;
+ public bool IsDesktop => true; // blendet den Umsortier-Griff der Kategorien aus
+}
+
+public static class CategoryColors
+{
+ public static readonly (string Name, string Hex)[] All =
+ [
+ ("Blau", "#3B82F6"), ("Türkis", "#12A594"), ("Grün", "#22A06B"), ("Gelb", "#E2A600"),
+ ("Orange", "#F07A1A"), ("Rot", "#E5484D"), ("Pink", "#D6409F"), ("Lila", "#8E4EC6"), ("Grau", "#8B8D98"),
+ ];
+
+ public static Brush? Brush(string? hex)
+ {
+ if (string.IsNullOrEmpty(hex)) return null;
+ try
+ {
+ var b = new SolidColorBrush((Color)ColorConverter.ConvertFromString(hex));
+ b.Freeze();
+ return b;
+ }
+ catch { return null; }
+ }
+}
+
+public static class CategoryGlyphs
+{
+ public static readonly (string Glyph, string Name)[] All =
+ [
+ ("", "Ordner"), ("", "Spiele"), ("", "Arbeit"), ("", "Code"),
+ ("", "Musik"), ("", "Video"), ("", "Bilder"), ("", "Internet"),
+ ("", "Mail"), ("", "Shopping"), ("", "System"), ("", "Werkzeuge"),
+ ("", "Dokumente"), ("", "Favoriten"), ("", "Herz"), ("", "Cloud"),
+ ("", "Downloads"), ("", "Personen"), ("", "Bibliothek"), ("", "Rechner"),
+ ("", "Ideen"), ("", "Zuhause"), ("", "Lernen"), ("", "Erweiterungen"),
+ ("", "Computer"), ("", "Sicherheit"), ("", "Ebenen"),
+ ];
+}
+
+public class IconItem : INotifyPropertyChanged
+{
+ public required string Path { get; init; }
+ public string? Category { get; init; }
+
+ private ImageSource? _icon;
+ public ImageSource? Icon
+ {
+ get => _icon;
+ set { _icon = value; PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Icon))); }
+ }
+
+ /// Nur für Desktop-Icons: Kategorie, in der bereits eine Kopie liegt.
+ public string? SortedInto { get; init; }
+ public bool IsSorted => SortedInto is not null;
+
+ public string Name => Storage.DisplayName(Path);
+
+ public string TypeText
+ {
+ get
+ {
+ if (Directory.Exists(Path)) return "Ordner";
+ var ext = System.IO.Path.GetExtension(Path).ToLowerInvariant();
+ return ext switch
+ {
+ ".lnk" => "Verknüpfung",
+ ".url" => "Internetverknüpfung",
+ ".exe" => "Programm",
+ "" => "Datei",
+ _ => ext.TrimStart('.').ToUpperInvariant() + "-Datei",
+ };
+ }
+ }
+
+ public string LocationText => SortedInto is not null ? $"✓ gesichert in {SortedInto}" : Category ?? "";
+
+ public string ToolTip =>
+ SortedInto is not null ? $"{Name}\n✓ Bereits gesichert in „{SortedInto}“ – kann vom Desktop gelöscht werden." :
+ Category is not null ? $"{Name}\nOrt: {Category}" : $"{Name}\n{TypeText}";
+
+ public event PropertyChangedEventHandler? PropertyChanged;
+}
diff --git a/README.md b/README.md
index 64e774b..acfba93 100644
--- a/README.md
+++ b/README.md
@@ -24,18 +24,43 @@ Danach kannst du die Icons vom Desktop löschen. In DeskCore bleiben sie erhalte
---
+## Inhalt
+
+- [Auf einen Blick](#auf-einen-blick)
+- [Was du brauchst](#was-du-brauchst)
+- [Installieren](#installieren)
+- [So räumst du deinen Desktop auf](#so-räumst-du-deinen-desktop-auf)
+- [Programme starten](#programme-starten)
+- [Start-Gruppen – mehrere Programme mit einem Klick](#start-gruppen--mehrere-programme-mit-einem-klick)
+- [Boxen auf dem Desktop](#boxen-auf-dem-desktop)
+- [Desktop-Seiten](#desktop-seiten)
+- [Den Desktop schick machen](#den-desktop-schick-machen)
+- [Automatisch sortieren](#automatisch-sortieren)
+- [Weitere Extras](#weitere-extras)
+- [Alle Tastenkürzel](#alle-tastenkürzel)
+- [Hoppla, falsch geklickt?](#hoppla-falsch-geklickt)
+- [Häufige Fragen](#häufige-fragen)
+- [Deinstallieren](#deinstallieren)
+- [Lizenz](#lizenz)
+
+---
+
## Auf einen Blick
| | |
|---|---|
| 🗂️ **Eigene Kategorien** | mit Symbol und Farbe, Reihenfolge frei wählbar |
| ✅ **Sicher aufräumen** | Icons werden gesichert, bevor du sie vom Desktop löschst |
-| 🧱 **Desktop-Boxen** | Kategorien als verschiebbare Kästen direkt auf dem Desktop |
+| ▶️ **Start-Gruppen** | ein Klick startet mehrere Programme auf einmal |
+| 🧱 **Desktop-Boxen** | Kategorien oder Ordner als Kästen direkt auf dem Desktop, mit Tabs und eigenem Aussehen |
+| 📑 **Desktop-Seiten** | mehrere Desktops zum Umschalten, z. B. „Arbeit“ und „Freizeit“ |
+| 🎨 **Icons einfärben** | alle Desktop-Icons in einem einheitlichen Farbton |
+| 👁️ **Schnell verstecken** | Doppelklick auf den Desktop – alles weg, noch ein Doppelklick – alles wieder da |
| ⚡ **Automatisch sortieren** | neue Icons landen per Regel von selbst in der richtigen Kategorie |
+| 🧹 **Räumt selbst auf** | deinstallierte Programme verschwinden auch aus DeskCore |
| 🔍 **Suche** | jedes Programm in Sekunden finden |
| ↩️ **Rückgängig** | Strg + Z macht jeden Schritt rückgängig |
| 🌙 **Hell & Dunkel** | passt sich Windows an oder nach deinem Geschmack |
-| ⌨️ **Immer griffbereit** | Win + Umschalt + D oder Klick auf das Symbol neben der Uhr |
| 💾 **Sicherung** | alles als ZIP sichern, z. B. für einen neuen PC |
| 🔄 **Updates** | DeskCore sagt Bescheid, wenn es eine neue Version gibt |
@@ -65,11 +90,13 @@ Nur wenn „.NET“ fehlt und installiert wird, fragt Windows einmal nach. Einfa
> Bei neuen Programmen zeigt Windows manchmal „Der Computer wurde durch Windows geschützt“.
> Klick dann auf **„Weitere Informationen“** und danach auf **„Trotzdem ausführen“**.
+**Update auf eine neue Version:** Einfach den neuen Installer starten. Deine Kategorien und Einstellungen bleiben erhalten.
+
---
## So räumst du deinen Desktop auf
-### 1. Gruppe anlegen
+### 1. Kategorie anlegen
Unten links auf **„+ Neue Kategorie“** klicken und einen Namen eingeben, z. B. **Spiele**.
Auf Wunsch suchst du dir noch ein Symbol und eine Farbe aus.
@@ -91,6 +118,12 @@ Die kannst du ohne Sorge vom Desktop löschen:
**Fertig!** Dein Desktop ist leer, und deine Programme findest du jetzt in DeskCore.
+### Kategorien verwalten
+- **Umbenennen, Symbol oder Farbe ändern:** Kategorie anklicken → oben **„Bearbeiten“**
+- **Reihenfolge ändern:** Kategorie links mit der Maus **nach oben oder unten ziehen** (oder Rechtsklick → **Nach oben / Nach unten**)
+- **Ordner im Explorer ansehen:** oben **„Im Explorer öffnen“**
+- **Löschen:** oben **„Kategorie löschen“** – die Icons landen im Papierkorb, Strg + Z holt alles zurück
+
---
## Programme starten
@@ -98,51 +131,216 @@ Die kannst du ohne Sorge vom Desktop löschen:
- Kategorie links anklicken
- **Doppelklick** auf das Icon
-Oder schneller: **Suchfeld** oben benutzen, den Namen eintippen und Doppelklick.
+Oder schneller: **Suchfeld** oben benutzen (Strg + F), den Namen eintippen und Doppelklick.
+Die Suche findet Programme in allen Kategorien und auf dem Desktop.
+
+Auch über das **DeskCore-Symbol neben der Uhr** kommst du per Rechtsklick direkt an deine Kategorien.
---
-## Nützliche Extras
+## Start-Gruppen – mehrere Programme mit einem Klick
-### DeskCore schnell öffnen
-- **Win + Umschalt + D** drücken, von überall aus
-- oder unten rechts neben der Uhr auf das **DeskCore-Symbol** klicken
+Du startest jeden Tag dieselben Programme, z. B. Browser, Mail und Teams? Pack sie in eine **Start-Gruppe**.
+Ein Klick, und alle gehen nacheinander auf.
-Wenn du das Fenster mit dem **X** schließt, läuft DeskCore unten rechts weiter.
-Richtig beenden: **Rechtsklick auf das Symbol neben der Uhr → Beenden**
+**Gruppe anlegen**
+1. Links unten bei **„Start-Gruppen“** auf das **+** klicken
+2. Namen eingeben, z. B. **Feierabend** oder **Arbeit**
+3. Einstellen, wie viele Sekunden zwischen den Programmen liegen sollen (oder „gleichzeitig“)
+
+**Programme hinzufügen**
+- Ein Icon mit der Maus **auf die Start-Gruppe ziehen**, oder
+- Rechtsklick auf ein Icon → **„Zur Start-Gruppe hinzufügen“**
+
+**Starten**
+- Auf das kleine **▶** neben der Gruppe klicken, oder
+- Gruppe anklicken → **„Alle starten“**, oder
+- Rechtsklick auf das DeskCore-Symbol neben der Uhr → **„… starten“**
+
+**Noch bequemer:** Gruppe anklicken → **„Verknüpfung auf den Desktop“**.
+Dann liegt ein Symbol auf dem Desktop, das die ganze Gruppe startet.
+
+Die Reihenfolge änderst du per Rechtsklick auf ein Programm → **„Früher starten“ / „Später starten“**.
+Ein Programm aus der Gruppe nehmen: Rechtsklick → **„Aus Start-Gruppe entfernen“**. Das Programm selbst bleibt dabei unangetastet.
+
+---
+
+## Boxen auf dem Desktop
+
+Eine **Box** ist ein Kasten direkt auf deinem Desktop, in dem die Icons einer Kategorie liegen.
+So hast du deine Programme auf dem Desktop – aber ordentlich.
+
+### Box anlegen
+Es gibt drei Wege:
+- Kategorie in DeskCore anklicken → oben **„Als Box auf den Desktop“**
+- Mit **gedrückter rechter Maustaste** auf einer freien Stelle des Desktops **einen Rahmen ziehen**. Danach wählst du, welche Kategorie hinein soll – oder legst gleich eine neue an.
+ (Ein ganz normaler Rechtsklick öffnet weiterhin das gewohnte Windows-Menü.)
+- **Einstellungen → Desktop-Boxen → Boxen auf dem Desktop anzeigen** schaltet alle Boxen auf einmal ein oder aus
+
+### Mit Boxen arbeiten
+- **Verschieben:** an der Titelleiste ziehen
+- **Größe ändern:** am Rand ziehen
+- **Einklappen:** Doppelklick auf den Titel – noch ein Doppelklick klappt sie wieder auf. Fährst du mit der Maus über eine eingeklappte Box, geht sie kurz auf.
+- **Programm starten:** Doppelklick auf das Icon
+- **Rechtsklick auf ein Icon:** Öffnen, Umbenennen, Löschen, in eine andere Kategorie verschieben oder **„Zurück auf den Desktop legen“**
+- Icons lassen sich auch einfach in eine Box hinein- oder aus ihr herausziehen
+
+Das **Menü einer Box** (die drei Punkte ⋯ oben rechts) bietet:
+
+| Menüpunkt | Was passiert |
+|---|---|
+| **Tab hinzufügen** | Eine weitere Kategorie in dieselbe Box packen. Oben erscheinen dann Reiter zum Umschalten. |
+| **Ordner-Portal …** | Einen Ordner als Reiter hinzufügen (siehe unten) |
+| **Aussehen …** | Farbe, Deckkraft, Milchglas, Icon-Größe, Sortierung und Icon-Tönung der Box |
+| **Sperren** | Die Box lässt sich nicht mehr versehentlich verschieben oder in der Größe ändern |
+| **Auf Seite verschieben** | Die Box auf eine andere [Desktop-Seite](#desktop-seiten) schicken |
+| **Box schließen** | Die Box verschwindet vom Desktop. Die Kategorie in DeskCore bleibt natürlich erhalten. |
+
+Rechtsklick auf einen **Reiter**: **„Als eigene Box lösen“** macht daraus wieder eine eigene Box, **„Tab entfernen“** nimmt ihn heraus.
+
+### Ordner-Portal
+Ein Portal ist eine Box, die **live einen beliebigen Ordner** zeigt, z. B. **Downloads** oder einen Projektordner.
+Neue Dateien erscheinen sofort darin, und Unterordner öffnest du direkt in der Box.
+Anlegen: **Einstellungen → Desktop-Boxen → Ordner-Portal → „Ordner wählen …“** oder beim Aufziehen einer Box **„Ordner-Portal …“** wählen.
+
+### Peek – Boxen nach vorne holen
+Alle Fenster sind offen und verdecken den Desktop? Drück **Win + Umschalt + B**.
+Die Boxen kommen nach vorne, über alle Fenster. Nochmal drücken, **Esc** oder woanders hinklicken schickt sie zurück.
+Ein- und ausschalten unter **Einstellungen → Peek**.
+
+### Anordnungen speichern (Snapshots)
+Du hast deine Boxen perfekt angeordnet? Speicher dir das:
+**Einstellungen → Snapshots → „Aktuelle Anordnung speichern“**.
+Mit **„Wiederherstellen“** ist später alles wieder genau so – auch über das DeskCore-Symbol neben der Uhr.
+
+Arbeitest du mal am Laptop allein und mal mit Monitor? DeskCore merkt sich die Positionen der Boxen **für jede Bildschirm-Anordnung automatisch**.
+
+---
+
+## Desktop-Seiten
+
+Du kannst **mehrere Desktop-Seiten** anlegen, z. B. **Arbeit** und **Freizeit** – jede Seite hat ihre eigenen Boxen.
+So siehst du nach Feierabend nur deine Spiele und tagsüber nur deine Arbeitsprogramme.
+
+**Seiten anlegen:** **Einstellungen → Desktop-Seiten → „Seite hinzufügen“**.
+Dort kannst du Seiten auch umbenennen (Stift) oder löschen (Mülleimer). Die Boxen einer gelöschten Seite wandern auf die Seite davor, es geht also nichts verloren.
+
+**Zwischen Seiten wechseln:**
+- **Win + Alt + ←** oder **→**
+- **Mausrad** drehen, während die Maus über einer freien Stelle des Desktops ist
+- Rechtsklick auf das DeskCore-Symbol neben der Uhr → **„Desktop-Seite“**
+- in den Einstellungen bei der Seite auf **„Anzeigen“**
+
+Beim Wechseln zeigt DeskCore kurz unten am Bildschirm, auf welcher Seite du bist.
+Neue Boxen landen immer auf der Seite, die gerade angezeigt wird. Eine Box auf eine andere Seite schicken: Box-Menü ⋯ → **„Auf Seite verschieben“**.
+
+> Die Icons, die direkt auf dem Desktop liegen (nicht in einer Box), siehst du auf jeder Seite.
+
+---
+
+## Den Desktop schick machen
+
+### Desktop-Icons einfärben
+**Einstellungen → Desktop-Icons tönen:** eine Farbe anklicken, und alle Icons auf dem Desktop bekommen denselben Farbton.
+Mit dem Regler **„Stärke“** stellst du ein, wie kräftig. Der erste, leere Kreis schaltet die Tönung wieder aus.
+Die Icons in den Boxen färbst du getrennt über das Box-Menü ⋯ → **„Aussehen …“**.
+
+### Schnell verstecken
+**Doppelklick auf eine freie Stelle des Desktops** blendet alle Icons und Boxen aus – perfekt für ein sauberes Hintergrundbild oder eine Bildschirmfreigabe.
+Noch ein Doppelklick holt alles zurück. Einschalten unter **Einstellungen → Schnell verstecken**.
+
+### Chamäleon
+Die Boxen werden fast unsichtbar und erscheinen erst, wenn du mit der Maus darüberfährst.
+Einschalten unter **Einstellungen → Chamäleon**.
### Hell oder Dunkel
-Oben rechts auf die **Sonne bzw. den Mond** klicken und auswählen.
-
-### Boxen auf dem Desktop
-Eine Kategorie kann als kleine **Box direkt auf dem Desktop** erscheinen:
-Kategorie anklicken und oben auf **„Als Box auf den Desktop“** klicken.
-Die Box kannst du verschieben und in der Größe ändern. Ein Doppelklick auf ihren Titel klappt sie ein und aus.
-
-### Automatisch sortieren
-DeskCore kann neue Icons selbst einsortieren, z. B. alles mit „Steam“ im Namen → **Spiele**.
-Einstellen kannst du das unter **Einstellungen → Automatisch sortieren → Regel hinzufügen**.
-
-### Reihenfolge der Kategorien
-Kategorie links mit der Maus **nach oben oder unten ziehen**.
-Oder per Rechtsklick → **Nach oben / Nach unten**.
+Oben rechts auf die **Sonne bzw. den Mond** klicken und auswählen – oder **„Wie Windows“**, dann passt sich DeskCore automatisch an.
### Ansicht
Oben rechts gibt es den **Ansicht-Knopf**: kleine, mittlere oder große Symbole oder eine Liste.
-### Icons in den Desktop-Boxen
-Auch in einer Box auf dem Desktop kannst du per **Rechtsklick** umbenennen, löschen, in eine andere Kategorie verschieben
-oder zurück auf den Desktop legen. Icons lassen sich auch einfach aus der Box herausziehen.
+---
+
+## Automatisch sortieren
+
+DeskCore kann neue Icons selbst einsortieren. Sobald z. B. ein Installer eine Verknüpfung auf den Desktop legt, wandert sie in die richtige Kategorie.
+
+So geht's: **Einstellungen → Automatisch sortieren → „Regel hinzufügen“**. Es gibt zwei Arten von Regeln
+(zum Umschalten auf die Art der Regel klicken):
+
+| Regel | Beispiel |
+|---|---|
+| **Name enthält** | alles mit „Steam“ im Namen → **Spiele** |
+| **Dateityp ist** | Bilder, Videos, Musik, Dokumente, Archive, Programme, Ordner – oder Endungen wie „pdf, docx“ → **Dokumente** |
+
+Die erste passende Regel gewinnt. Groß- und Kleinschreibung ist egal.
+Mit **„Danach vom Desktop entfernen“** verschwindet das Original gleich vom Desktop.
+**„Jetzt auf den Desktop anwenden“** sortiert alles ein, was schon auf dem Desktop liegt.
+
+---
+
+## Weitere Extras
+
+### DeskCore schnell öffnen
+- **Win + Umschalt + D** drücken, von überall aus (änderbar unter **Einstellungen → Tastenkürzel**)
+- oder unten rechts neben der Uhr auf das **DeskCore-Symbol** klicken
+
+Wenn du das Fenster mit dem **X** schließt, läuft DeskCore unten rechts weiter – deine Boxen bleiben also da.
+Richtig beenden: **Rechtsklick auf das Symbol neben der Uhr → Beenden**
+
+### Das Symbol neben der Uhr
+Ein Rechtsklick darauf bietet dir alles Wichtige, ohne das Fenster zu öffnen:
+deine Kategorien, Start-Gruppen, gespeicherte Anordnungen, Desktop-Seiten und **„Desktop ausblenden“**.
+
+### Mit Windows starten
+**Einstellungen → Mit Windows starten.** DeskCore startet dann unauffällig neben der Uhr, und Boxen sowie Auto-Sortierung sind sofort da.
+
+### Deinstallierte Programme verschwinden von selbst
+Deinstallierst du ein Programm, entfernt DeskCore dessen Icon nach etwa einer Minute auch aus deinen Kategorien – auch bei **Steam- und Epic-Spielen**.
+Strg + Z holt es zurück. Ausschalten unter **Einstellungen → Deinstallierte Programme entfernen**.
+
+### Sicherung
+**Einstellungen → Sicherung → „Sichern …“** speichert alle Kategorien, Boxen, Start-Gruppen und Einstellungen in eine ZIP-Datei.
+Mit **„Wiederherstellen …“** holst du alles zurück, z. B. auf einem neuen PC.
### Updates
DeskCore schaut einmal am Tag nach, ob es eine neue Version gibt.
Wenn ja, erscheint oben ein Hinweis. Ein Klick auf **„Jetzt aktualisieren“** genügt, und DeskCore startet danach von selbst neu.
+Prüfen von Hand: **Einstellungen → Updates → „Jetzt prüfen“**.
+
+---
+
+## Alle Tastenkürzel
+
+**Überall in Windows**
+
+| Taste | Was passiert |
+|---|---|
+| **Win + Umschalt + D** | DeskCore öffnen / verstecken |
+| **Win + Umschalt + B** | Peek: Boxen nach vorne holen |
+| **Win + Alt + ← / →** | Desktop-Seite wechseln |
+
+**Im DeskCore-Fenster**
+
+| Taste | Was passiert |
+|---|---|
+| **Strg + F** | Suchen |
+| **Esc** | Suche leeren |
+| **Strg + A** | Alles markieren |
+| **Enter** | Öffnen |
+| **F2** | Umbenennen |
+| **Entf** | Löschen |
+| **Strg + Z** | Rückgängig |
+| **Strg + N** | Neue Kategorie |
+| **Alt + ↑ / ↓** | Kategorie nach oben / unten verschieben |
+| **Strg + 1 … 9** | Zur Kategorie 1 … 9 springen |
+| **F5** | Aktualisieren |
---
## Hoppla, falsch geklickt?
-**Strg + Z** macht den letzten Schritt rückgängig.
+**Strg + Z** macht den letzten Schritt rückgängig – auch mehrmals hintereinander.
Unten im Fenster erscheint nach vielen Aktionen auch kurz ein Knopf **„Rückgängig“**.
---
@@ -156,6 +354,21 @@ Auch wenn DeskCore mal nicht läuft, ist also nichts weg.
**Ich habe etwas gelöscht. Ist es für immer weg?**
Nein. Erst geht **Strg + Z**, und danach liegt es noch im **Papierkorb** von Windows.
+**Ich habe ein Programm deinstalliert – verschwindet es auch aus DeskCore?**
+Ja, automatisch nach etwa einer Minute – auch bei **Steam- und Epic-Spielen**. Hast du es dir anders überlegt: **Strg + Z** holt es zurück.
+Programme auf einem gerade abgesteckten USB- oder Netzlaufwerk bleiben natürlich erhalten.
+
+**Mein Rechtsklick auf den Desktop fühlt sich anders an.**
+Damit du Boxen aufziehen kannst, schaut DeskCore kurz, ob du ziehst oder nur klickst. Das Windows-Menü erscheint deshalb erst beim **Loslassen** der Taste.
+Stört dich das: **Einstellungen → Box auf dem Desktop aufziehen** ausschalten.
+
+**Das Mausrad wechselt ungewollt die Desktop-Seite.**
+Unter **Einstellungen → Desktop-Seiten** den Haken bei **„Mit dem Mausrad … wechseln“** entfernen.
+Mit gedrückter **Strg**-Taste ändert das Mausrad wie gewohnt die Icon-Größe des Desktops.
+
+**Die Farbe der Icon-Tönung sitzt nicht genau auf den Icons.**
+Kurz warten – DeskCore liest die Positionen alle zwei Sekunden neu. Hast du gerade die Bildschirmskalierung geändert, hilft ein Neustart von DeskCore.
+
**Ein Icon lässt sich nicht verschieben („Keine Berechtigung“).**
Manche Icons gehören allen Benutzern des PCs. Dafür DeskCore einmal mit
**Rechtsklick → „Als Administrator ausführen“** starten.
@@ -166,14 +379,26 @@ Beim Kopieren siehst du einen Fortschrittsbalken und kannst jederzeit **abbreche
**Ich habe einen Bildschirm abgesteckt und das Fenster ist weg.**
Keine Sorge: DeskCore und die Desktop-Boxen holen sich automatisch auf den verbliebenen Bildschirm zurück.
+Steckst du den Bildschirm wieder an, kommen die Boxen an ihre alten Plätze.
**Ich bekomme einen neuen PC. Was jetzt?**
**Einstellungen → Sicherung → „Sichern …“** speichert alles in eine ZIP-Datei.
Auf dem neuen PC installierst du DeskCore und wählst **„Wiederherstellen …“**.
-**Das Tastenkürzel funktioniert nicht.**
+**Ein Tastenkürzel funktioniert nicht.**
Ein anderes Programm benutzt es vielleicht schon. Unter **Einstellungen → Tastenkürzel** ins Feld klicken und eine andere Kombination drücken.
+**Kann ich DeskCore auch per Verknüpfung oder Skript steuern?**
+Ja, für Fortgeschrittene:
+
+| Befehl | Was passiert |
+|---|---|
+| `DeskCore.exe --launch "Feierabend"` | Start-Gruppe „Feierabend“ starten |
+| `DeskCore.exe --snapshot "Arbeit"` | Gespeicherte Anordnung „Arbeit“ wiederherstellen |
+| `DeskCore.exe --page "Freizeit"` | Zur Desktop-Seite „Freizeit“ wechseln (oder `--page 2`) |
+| `DeskCore.exe --toggle-desktop` | Desktop aus- / einblenden |
+| `DeskCore.exe --peek` | Boxen nach vorne holen |
+
---
## Deinstallieren
diff --git a/Screens.cs b/Screens.cs
index cdca928..81e7739 100644
--- a/Screens.cs
+++ b/Screens.cs
@@ -1,39 +1,39 @@
-using System.Windows;
-
-namespace DeskCore;
-
-/// Bildschirm-Hilfen, damit Fenster nie außerhalb des sichtbaren Bereichs landen (z. B. nach Abstecken eines Monitors).
-public static class Screens
-{
- /// Umrechnung Pixel → WPF-Einheiten (System-DPI).
- private static double Scale
- {
- get
- {
- using var g = System.Drawing.Graphics.FromHwnd(IntPtr.Zero);
- return g.DpiX / 96.0;
- }
- }
-
- /// Arbeitsbereiche (ohne Taskleiste) aller angeschlossenen Bildschirme in WPF-Einheiten.
- public static List WorkAreas()
- {
- var s = Scale;
- return System.Windows.Forms.Screen.AllScreens
- .Select(m => new Rect(m.WorkingArea.X / s, m.WorkingArea.Y / s, m.WorkingArea.Width / s, m.WorkingArea.Height / s))
- .ToList();
- }
-
- public static Rect PrimaryWorkArea => SystemParameters.WorkArea;
-
- /// Ist genug vom Fenster (Titelleiste) auf irgendeinem Bildschirm sichtbar, um es greifen zu können?
- public static bool IsReachable(Rect window)
- {
- var title = new Rect(window.X, window.Y, window.Width, Math.Min(40, window.Height));
- return WorkAreas().Any(a =>
- {
- var i = Rect.Intersect(a, title);
- return !i.IsEmpty && i.Width >= Math.Min(120, window.Width / 2) && i.Height >= 20;
- });
- }
-}
+using System.Windows;
+
+namespace DeskCore;
+
+/// Bildschirm-Hilfen, damit Fenster nie außerhalb des sichtbaren Bereichs landen (z. B. nach Abstecken eines Monitors).
+public static class Screens
+{
+ /// Umrechnung Pixel → WPF-Einheiten (System-DPI).
+ public static double Scale
+ {
+ get
+ {
+ using var g = System.Drawing.Graphics.FromHwnd(IntPtr.Zero);
+ return g.DpiX / 96.0;
+ }
+ }
+
+ /// Arbeitsbereiche (ohne Taskleiste) aller angeschlossenen Bildschirme in WPF-Einheiten.
+ public static List WorkAreas()
+ {
+ var s = Scale;
+ return System.Windows.Forms.Screen.AllScreens
+ .Select(m => new Rect(m.WorkingArea.X / s, m.WorkingArea.Y / s, m.WorkingArea.Width / s, m.WorkingArea.Height / s))
+ .ToList();
+ }
+
+ public static Rect PrimaryWorkArea => SystemParameters.WorkArea;
+
+ /// Ist genug vom Fenster (Titelleiste) auf irgendeinem Bildschirm sichtbar, um es greifen zu können?
+ public static bool IsReachable(Rect window)
+ {
+ var title = new Rect(window.X, window.Y, window.Width, Math.Min(40, window.Height));
+ return WorkAreas().Any(a =>
+ {
+ var i = Rect.Intersect(a, title);
+ return !i.IsEmpty && i.Width >= Math.Min(120, window.Width / 2) && i.Height >= 20;
+ });
+ }
+}
diff --git a/Settings.cs b/Settings.cs
index b6e0a11..9f34b4a 100644
--- a/Settings.cs
+++ b/Settings.cs
@@ -1,141 +1,303 @@
-using System.IO;
-using System.Text.Json;
-using System.Text.Json.Serialization;
-
-namespace DeskCore;
-
-public enum DesignMode { System, Light, Dark }
-public enum ViewMode { Small, Medium, Large, List }
-
-public class CategoryMeta
-{
- public string? Glyph { get; set; }
- public string? Color { get; set; }
- public bool Fence { get; set; }
- public double? FenceX { get; set; }
- public double? FenceY { get; set; }
- public double FenceW { get; set; } = 340;
- public double FenceH { get; set; } = 260;
- public bool FenceCollapsed { get; set; }
-}
-
-public class SortRule
-{
- public string Contains { get; set; } = "";
- public string Category { get; set; } = "";
-}
-
-/// Alle Einstellungen, gespeichert in %APPDATA%\DeskCore\settings.json.
-public class AppSettings
-{
- public DesignMode Design { get; set; } = DesignMode.System;
- public ViewMode View { get; set; } = ViewMode.Medium;
-
- public bool TrayEnabled { get; set; } = true;
- public bool CloseToTray { get; set; } = true;
- public bool TrayHintShown { get; set; }
-
- public bool HotkeyEnabled { get; set; } = true;
- public int HotkeyModifiers { get; set; } = Hotkey.ModWin | Hotkey.ModShift;
- public int HotkeyKey { get; set; } = 0x44; // D
-
- public bool AutoSort { get; set; }
- public bool AutoSortRemoveFromDesktop { get; set; }
- public List Rules { get; set; } = [];
-
- public bool FencesEnabled { get; set; } = true;
-
- // Fenster merken (WPF-Einheiten)
- public double? WindowLeft { get; set; }
- public double? WindowTop { get; set; }
- public double? WindowWidth { get; set; }
- public double? WindowHeight { get; set; }
- public bool WindowMaximized { get; set; }
-
- // Updates
- public bool AutoUpdateCheck { get; set; } = true;
- public DateTime? LastUpdateCheck { get; set; }
- public string? SkippedVersion { get; set; }
-
- public List CategoryOrder { get; set; } = [];
- public Dictionary Categories { get; set; } = new(StringComparer.OrdinalIgnoreCase);
-
- // ---------- Laden/Speichern ----------
-
- public static readonly string Folder = Migration.Pick(Environment.SpecialFolder.ApplicationData);
- private static readonly string FilePath = Path.Combine(Folder, "settings.json");
-
- public static readonly JsonSerializerOptions Json = new()
- {
- WriteIndented = true,
- Converters = { new JsonStringEnumConverter() },
- };
-
- public static AppSettings Current { get; private set; } = Load();
-
- public static event Action? Saved;
-
- private static AppSettings Load()
- {
- AppSettings s;
- try
- {
- s = File.Exists(FilePath)
- ? JsonSerializer.Deserialize(File.ReadAllText(FilePath), Json) ?? new()
- : new();
- }
- catch { s = new(); }
-
- s.Categories = new(s.Categories ?? [], StringComparer.OrdinalIgnoreCase);
- s.Rules ??= [];
- s.CategoryOrder ??= [];
-
- // Übernahme der alten Einstellung aus Version 2
- var old = Path.Combine(Folder, "design.txt");
- if (File.Exists(old))
- {
- try
- {
- var v = File.ReadAllText(old).Trim();
- s.Design = v == "Light" ? DesignMode.Light : v == "Dark" ? DesignMode.Dark : DesignMode.System;
- File.Delete(old);
- }
- catch { }
- }
- return s;
- }
-
- public void Save()
- {
- try
- {
- Directory.CreateDirectory(Folder);
- File.WriteAllText(FilePath, JsonSerializer.Serialize(this, Json));
- }
- catch { }
- Saved?.Invoke();
- }
-
- public CategoryMeta Meta(string category)
- {
- if (!Categories.TryGetValue(category, out var m))
- Categories[category] = m = new CategoryMeta();
- return m;
- }
-
- public void RenameCategory(string oldName, string newName)
- {
- if (Categories.Remove(oldName, out var m)) Categories[newName] = m;
- for (int i = 0; i < CategoryOrder.Count; i++)
- if (string.Equals(CategoryOrder[i], oldName, StringComparison.OrdinalIgnoreCase)) CategoryOrder[i] = newName;
- foreach (var r in Rules)
- if (string.Equals(r.Category, oldName, StringComparison.OrdinalIgnoreCase)) r.Category = newName;
- }
-
- public void RemoveCategory(string name)
- {
- Categories.Remove(name);
- CategoryOrder.RemoveAll(n => string.Equals(n, name, StringComparison.OrdinalIgnoreCase));
- Rules.RemoveAll(r => string.Equals(r.Category, name, StringComparison.OrdinalIgnoreCase));
- }
-}
+using System.IO;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace DeskCore;
+
+public enum DesignMode { System, Light, Dark }
+public enum ViewMode { Small, Medium, Large, List }
+
+public class CategoryMeta
+{
+ public string? Glyph { get; set; }
+ public string? Color { get; set; }
+ // Die folgenden Box-Felder stammen aus Version 1.0.x und werden nur noch beim Umstieg auf FenceDef gelesen
+ public bool Fence { get; set; }
+ public double? FenceX { get; set; }
+ public double? FenceY { get; set; }
+ public double FenceW { get; set; } = 340;
+ public double FenceH { get; set; } = 260;
+ public bool FenceCollapsed { get; set; }
+}
+
+/// Eine Start-Gruppe: ein Klick startet mehrere Programme nacheinander.
+public class LaunchGroup
+{
+ public string Name { get; set; } = "";
+ /// Pfade in Startreihenfolge (meist Verknüpfungen aus den Kategorien).
+ public List Items { get; set; } = [];
+ /// Wartezeit zwischen zwei Programmen in Sekunden.
+ public double DelaySeconds { get; set; } = 1;
+ public string? Color { get; set; }
+}
+
+public class SortRule
+{
+ /// "Name" = Name enthält …, "Typ" = Dateityp ist … (z. B. "Bilder" oder "pdf, docx")
+ public string Kind { get; set; } = "Name";
+ public string Contains { get; set; } = "";
+ public string Category { get; set; } = "";
+ [JsonIgnore] public string KindText => Kind == "Typ" ? "Dateityp ist" : "Name enthält";
+}
+
+/// Ein Tab einer Desktop-Box: eine Kategorie oder ein beliebiger Ordner ("Ordner-Portal").
+public class FenceTab
+{
+ public const string CategoryKind = "Kategorie", FolderKind = "Ordner";
+ public string Kind { get; set; } = CategoryKind;
+ /// Kategoriename bzw. Ordnerpfad.
+ public string Value { get; set; } = "";
+ [JsonIgnore] public bool IsFolder => Kind == FolderKind;
+ [JsonIgnore] public string Title => IsFolder ? Path.GetFileName(Value.TrimEnd('\\')) is { Length: > 0 } n ? n : Value : Value;
+}
+
+/// Eine Desktop-Box mit Position, Tabs und Aussehen.
+public class FenceDef
+{
+ public string Id { get; set; } = Guid.NewGuid().ToString("N");
+ public List Tabs { get; set; } = [];
+ public int ActiveTab { get; set; }
+ public double? X { get; set; }
+ public double? Y { get; set; }
+ public double W { get; set; } = 340;
+ public double H { get; set; } = 260;
+ public bool Collapsed { get; set; }
+ /// Gesperrt: nicht verschieb- oder vergrößerbar.
+ public bool Locked { get; set; }
+ /// Hintergrundfarbe (#RRGGBB) oder null = passend zum Design.
+ public string? Background { get; set; }
+ /// Deckkraft des Hintergrunds 0.2–1.
+ public double Opacity { get; set; } = 0.92;
+ /// Milchglas-Effekt (Hintergrund verschwommen).
+ public bool Blur { get; set; }
+ public ViewMode IconSize { get; set; } = ViewMode.Medium;
+ /// "Name", "Typ" oder "Datum"
+ public string Sort { get; set; } = "Name";
+ /// Icon-Tönung (#RRGGBB) oder null.
+ public string? Tint { get; set; }
+
+ /// Desktop-Seite, auf der die Box liegt (0 = erste Seite).
+ public int Page { get; set; }
+
+ [JsonIgnore] public FenceTab? Active => Tabs.Count == 0 ? null : Tabs[Math.Clamp(ActiveTab, 0, Tabs.Count - 1)];
+}
+
+/// Gespeicherte Anordnung aller Desktop-Boxen.
+public class FenceSnapshot
+{
+ public string Name { get; set; } = "";
+ public DateTime Created { get; set; } = DateTime.Now;
+ public List Fences { get; set; } = [];
+ [JsonIgnore] public string Info => $"{Fences.Count} {(Fences.Count == 1 ? "Box" : "Boxen")} · gespeichert am {Created:dd.MM.yyyy HH:mm}";
+}
+
+/// Alle Einstellungen, gespeichert in %APPDATA%\DeskCore\settings.json.
+public class AppSettings
+{
+ public DesignMode Design { get; set; } = DesignMode.System;
+ public ViewMode View { get; set; } = ViewMode.Medium;
+
+ public bool TrayEnabled { get; set; } = true;
+ public bool CloseToTray { get; set; } = true;
+ public bool TrayHintShown { get; set; }
+
+ public bool HotkeyEnabled { get; set; } = true;
+ public int HotkeyModifiers { get; set; } = Hotkey.ModWin | Hotkey.ModShift;
+ public int HotkeyKey { get; set; } = 0x44; // D
+
+ public bool AutoSort { get; set; }
+ public bool AutoSortRemoveFromDesktop { get; set; }
+ public List Rules { get; set; } = [];
+
+ public bool FencesEnabled { get; set; } = true;
+ public List Fences { get; set; } = [];
+ public List Snapshots { get; set; } = [];
+ /// Box-Positionen je Bildschirm-Anordnung (Signatur → Box-Id → x, y, w, h).
+ public Dictionary> ScreenLayouts { get; set; } = [];
+ /// Chamäleon: Boxen blenden sich aus, bis die Maus darüberfährt.
+ public bool FenceChameleon { get; set; }
+ /// Doppelklick auf eine freie Desktop-Stelle blendet Icons und Boxen aus/ein.
+ public bool QuickHide { get; set; } = true;
+ /// Peek: Tastenkürzel holt alle Boxen vor die Fenster.
+ public bool PeekEnabled { get; set; } = true;
+ public int PeekModifiers { get; set; } = Hotkey.ModWin | Hotkey.ModShift;
+ public int PeekKey { get; set; } = 0x42; // B wie "Boxen"
+
+ /// Desktop-Seiten: jede Seite hat ihre eigenen Boxen.
+ public List Pages { get; set; } = ["Seite 1"];
+ public int CurrentPage { get; set; }
+ /// Mausrad über einer freien Desktop-Stelle wechselt die Seite.
+ public bool PageWheel { get; set; } = true;
+
+ /// Rechte Maustaste auf freier Desktop-Fläche ziehen → neue Box.
+ public bool DrawFences { get; set; } = true;
+
+ /// Tönung der echten Desktop-Icons (#RRGGBB) oder null = aus.
+ public string? DesktopTint { get; set; }
+ public double DesktopTintStrength { get; set; } = 0.55;
+
+ /// Verknüpfungen zu deinstallierten Programmen automatisch entfernen.
+ public bool RemoveDeadShortcuts { get; set; } = true;
+ /// Tote Verknüpfungen, die der Nutzer per Rückgängig behalten wollte.
+ public List KeptShortcuts { get; set; } = [];
+
+ // Fenster merken (WPF-Einheiten)
+ public double? WindowLeft { get; set; }
+ public double? WindowTop { get; set; }
+ public double? WindowWidth { get; set; }
+ public double? WindowHeight { get; set; }
+ public bool WindowMaximized { get; set; }
+
+ // Updates
+ public bool AutoUpdateCheck { get; set; } = true;
+ public DateTime? LastUpdateCheck { get; set; }
+ public string? SkippedVersion { get; set; }
+
+ public List CategoryOrder { get; set; } = [];
+ public List LaunchGroups { get; set; } = [];
+ public Dictionary Categories { get; set; } = new(StringComparer.OrdinalIgnoreCase);
+
+ // ---------- Laden/Speichern ----------
+
+ public static readonly string Folder = Migration.Pick(Environment.SpecialFolder.ApplicationData);
+ private static readonly string FilePath = Path.Combine(Folder, "settings.json");
+
+ public static readonly JsonSerializerOptions Json = new()
+ {
+ WriteIndented = true,
+ Converters = { new JsonStringEnumConverter() },
+ };
+
+ public static AppSettings Current { get; private set; } = Load();
+
+ public static event Action? Saved;
+
+ private static AppSettings Load()
+ {
+ AppSettings s;
+ try
+ {
+ s = File.Exists(FilePath)
+ ? JsonSerializer.Deserialize(File.ReadAllText(FilePath), Json) ?? new()
+ : new();
+ }
+ catch { s = new(); }
+
+ s.Categories = new(s.Categories ?? [], StringComparer.OrdinalIgnoreCase);
+ s.Rules ??= [];
+ s.CategoryOrder ??= [];
+ s.KeptShortcuts ??= [];
+ s.LaunchGroups ??= [];
+ s.Fences ??= [];
+ s.Snapshots ??= [];
+ s.ScreenLayouts ??= [];
+ if (s.Pages is null || s.Pages.Count == 0) s.Pages = ["Seite 1"];
+ s.CurrentPage = Math.Clamp(s.CurrentPage, 0, s.Pages.Count - 1);
+
+ // Umstieg von 1.0.x: Box gehörte fest zu einer Kategorie → eigenständige Box mit einem Tab
+ foreach (var (name, meta) in s.Categories)
+ {
+ if (!meta.Fence) continue;
+ s.Fences.Add(new FenceDef
+ {
+ Tabs = [new FenceTab { Value = name }],
+ X = meta.FenceX, Y = meta.FenceY, W = meta.FenceW, H = meta.FenceH, Collapsed = meta.FenceCollapsed,
+ });
+ meta.Fence = false;
+ }
+
+ // Übernahme der alten Einstellung aus Version 2
+ var old = Path.Combine(Folder, "design.txt");
+ if (File.Exists(old))
+ {
+ try
+ {
+ var v = File.ReadAllText(old).Trim();
+ s.Design = v == "Light" ? DesignMode.Light : v == "Dark" ? DesignMode.Dark : DesignMode.System;
+ File.Delete(old);
+ }
+ catch { }
+ }
+ return s;
+ }
+
+ public void Save()
+ {
+ try
+ {
+ Directory.CreateDirectory(Folder);
+ File.WriteAllText(FilePath, JsonSerializer.Serialize(this, Json));
+ }
+ catch { }
+ Saved?.Invoke();
+ }
+
+ public CategoryMeta Meta(string category)
+ {
+ if (!Categories.TryGetValue(category, out var m))
+ Categories[category] = m = new CategoryMeta();
+ return m;
+ }
+
+ public void RenameCategory(string oldName, string newName)
+ {
+ if (Categories.Remove(oldName, out var m)) Categories[newName] = m;
+ for (int i = 0; i < CategoryOrder.Count; i++)
+ if (string.Equals(CategoryOrder[i], oldName, StringComparison.OrdinalIgnoreCase)) CategoryOrder[i] = newName;
+ foreach (var r in Rules)
+ if (string.Equals(r.Category, oldName, StringComparison.OrdinalIgnoreCase)) r.Category = newName;
+ foreach (var t in Fences.SelectMany(f => f.Tabs))
+ if (!t.IsFolder && string.Equals(t.Value, oldName, StringComparison.OrdinalIgnoreCase)) t.Value = newName;
+ // Start-Gruppen zeigen auf Dateien in der Kategorie → Pfade mit umbenennen
+ var oldDir = Storage.CategoryPath(oldName).TrimEnd('\\') + "\\";
+ var newDir = Storage.CategoryPath(newName).TrimEnd('\\') + "\\";
+ foreach (var g in LaunchGroups)
+ for (int i = 0; i < g.Items.Count; i++)
+ if (g.Items[i].StartsWith(oldDir, StringComparison.OrdinalIgnoreCase))
+ g.Items[i] = newDir + g.Items[i][oldDir.Length..];
+ }
+
+ /// Wird diese Kategorie in irgendeiner Box angezeigt?
+ public bool HasFence(string category) =>
+ Fences.Any(f => f.Tabs.Any(t => !t.IsFolder && string.Equals(t.Value, category, StringComparison.OrdinalIgnoreCase)));
+
+ /// Kategorie als eigene Box anzeigen bzw. aus allen Boxen entfernen.
+ public void SetFence(string category, bool on)
+ {
+ if (on)
+ {
+ if (!HasFence(category)) Fences.Add(new FenceDef { Page = CurrentPage, Tabs = [new FenceTab { Value = category }] });
+ FencesEnabled = true;
+ }
+ else
+ {
+ foreach (var f in Fences)
+ f.Tabs.RemoveAll(t => !t.IsFolder && string.Equals(t.Value, category, StringComparison.OrdinalIgnoreCase));
+ Fences.RemoveAll(f => f.Tabs.Count == 0);
+ }
+ }
+
+ /// Tiefe Kopie (für Snapshots).
+ public static List CloneFences(List fences) =>
+ JsonSerializer.Deserialize>(JsonSerializer.Serialize(fences, Json), Json) ?? [];
+
+ /// Datei wurde umbenannt/verschoben → Start-Gruppen zeigen auf den neuen Pfad.
+ public void ReplaceLaunchPath(string oldPath, string newPath)
+ {
+ bool changed = false;
+ foreach (var g in LaunchGroups)
+ for (int i = 0; i < g.Items.Count; i++)
+ if (string.Equals(g.Items[i], oldPath, StringComparison.OrdinalIgnoreCase)) { g.Items[i] = newPath; changed = true; }
+ if (changed) Save();
+ }
+
+ public void RemoveCategory(string name)
+ {
+ foreach (var f in Fences)
+ f.Tabs.RemoveAll(t => !t.IsFolder && string.Equals(t.Value, name, StringComparison.OrdinalIgnoreCase));
+ Fences.RemoveAll(f => f.Tabs.Count == 0);
+ Categories.Remove(name);
+ CategoryOrder.RemoveAll(n => string.Equals(n, name, StringComparison.OrdinalIgnoreCase));
+ Rules.RemoveAll(r => string.Equals(r.Category, name, StringComparison.OrdinalIgnoreCase));
+ }
+}
diff --git a/Storage.cs b/Storage.cs
index 13e1894..c507b70 100644
--- a/Storage.cs
+++ b/Storage.cs
@@ -1,392 +1,476 @@
-using System.Collections.Concurrent;
-using System.IO;
-
-namespace DeskCore;
-
-public enum TransferStatus { Ok, Skipped, Error, Cancelled }
-
-/// true = kopiert (Original bleibt), false = verschoben
-public record TransferResult(TransferStatus Status, string Source, string? Dest = null, bool Copied = false, string? Message = null);
-
-///
-/// Kategorien sind einfache Unterordner von %USERPROFILE%\DeskCore.
-/// Icons vom Desktop werden dorthin kopiert – es bleiben normale Dateien.
-///
-public static class Storage
-{
- public static readonly string Root = Migration.Pick(Environment.SpecialFolder.UserProfile);
-
- public static readonly string UserDesktop = Environment.GetFolderPath(Environment.SpecialFolder.DesktopDirectory);
- public static readonly string PublicDesktop = Environment.GetFolderPath(Environment.SpecialFolder.CommonDesktopDirectory);
-
- /// Hier liegen gelöschte Elemente, solange man sie noch rückgängig machen kann.
- public static readonly string Holding = Migration.Pick(Environment.SpecialFolder.LocalApplicationData, "Geloescht");
-
- // ---------- Kategorien ----------
-
- public static List Categories()
- {
- Directory.CreateDirectory(Root);
- var order = AppSettings.Current.CategoryOrder;
- return Directory.GetDirectories(Root)
- .Select(Path.GetFileName)
- .OfType()
- .OrderBy(n => order.FindIndex(o => string.Equals(o, n, StringComparison.OrdinalIgnoreCase)) is var i and >= 0 ? i : int.MaxValue)
- .ThenBy(n => n, StringComparer.CurrentCultureIgnoreCase)
- .ToList();
- }
-
- public static string CategoryPath(string name) => Path.Combine(Root, name);
-
- public static IEnumerable Entries(string dir)
- {
- if (!Directory.Exists(dir)) return [];
- try
- {
- return Directory.EnumerateFileSystemEntries(dir)
- .Where(p =>
- {
- var n = Path.GetFileName(p);
- if (n.Equals("desktop.ini", StringComparison.OrdinalIgnoreCase)) return false;
- try
- {
- var attr = File.GetAttributes(p);
- return !attr.HasFlag(FileAttributes.Hidden) && !attr.HasFlag(FileAttributes.System);
- }
- catch { return false; }
- })
- .ToList();
- }
- catch { return []; }
- }
-
- public static IEnumerable DesktopEntries() =>
- Entries(UserDesktop).Concat(Entries(PublicDesktop));
-
- public static bool IsDesktopDir(string dir) =>
- SamePath(dir, UserDesktop) || SamePath(dir, PublicDesktop);
-
- public static bool IsInStorage(string path) =>
- path.StartsWith(Root.TrimEnd('\\') + "\\", StringComparison.OrdinalIgnoreCase);
-
- public static string? ValidateName(string name)
- {
- name = name.Trim();
- if (name.Length == 0) return "Bitte einen Namen eingeben.";
- if (name.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0) return "Der Name enthält ungültige Zeichen ( \\ / : * ? \" < > | ).";
- if (name is "." or ".." || name.EndsWith('.')) return "Ungültiger Name.";
- return null;
- }
-
- // ---------- Duplikate ----------
-
- private static readonly ConcurrentDictionary _lnkCache = new(StringComparer.OrdinalIgnoreCase);
-
- /// Ziel einer Verknüpfung (.lnk/.url), sonst null.
- public static string? ShortcutTarget(string path)
- {
- var ext = Path.GetExtension(path).ToLowerInvariant();
- if (ext is not (".lnk" or ".url")) return null;
- DateTime stamp;
- try { stamp = File.GetLastWriteTimeUtc(path); } catch { return null; }
- if (_lnkCache.TryGetValue(path, out var c) && c.Stamp == stamp) return c.Target;
-
- string? target = null;
- try
- {
- if (ext == ".url")
- {
- target = File.ReadLines(path).FirstOrDefault(l => l.StartsWith("URL=", StringComparison.OrdinalIgnoreCase))?[4..];
- }
- else
- {
- dynamic shell = Activator.CreateInstance(Type.GetTypeFromProgID("WScript.Shell")!)!;
- dynamic lnk = shell.CreateShortcut(path);
- string t = lnk.TargetPath;
- string a = lnk.Arguments;
- target = string.IsNullOrEmpty(t) ? null : (t + " " + a).Trim();
- }
- }
- catch { }
- if (string.IsNullOrWhiteSpace(target)) target = null;
- _lnkCache[path] = (stamp, target);
- return target;
- }
-
- /// Gleicher Dateiname oder Verknüpfung mit gleichem Ziel?
- public static bool SameItem(string a, string b)
- {
- if (string.Equals(Path.GetFileName(a), Path.GetFileName(b), StringComparison.OrdinalIgnoreCase)) return true;
- var ta = ShortcutTarget(a);
- return ta is not null && string.Equals(ta, ShortcutTarget(b), StringComparison.OrdinalIgnoreCase);
- }
-
- /// Liegt das Element (oder ein gleichwertiges) schon in diesem Ordner?
- public static string? FindEquivalent(string dir, string item) =>
- Entries(dir).FirstOrDefault(e => !SamePath(e, item) && SameItem(e, item));
-
- /// In welcher Kategorie liegt das Element bereits? (für das ✓ auf dem Desktop)
- public static string? FindCategoryOf(string item, IReadOnlyDictionary> categoryEntries)
- {
- foreach (var (cat, entries) in categoryEntries)
- if (entries.Any(e => SameItem(e, item))) return cat;
- return null;
- }
-
- // ---------- Übertragen ----------
-
- ///
- /// Übernimmt Datei/Ordner nach targetDir. Kommt die Quelle von außerhalb der App (z. B. vom Desktop),
- /// wird kopiert – das Original kann danach gelöscht werden. Innerhalb der App und zurück auf den
- /// Desktop wird verschoben. Gleiche Elemente werden nicht doppelt angelegt.
- ///
- public static TransferResult Transfer(string source, string targetDir, bool forceMove = false,
- Action? onBytes = null, CancellationToken ct = default)
- {
- if (ct.IsCancellationRequested) return new(TransferStatus.Cancelled, source);
- var name = Path.GetFileName(source.TrimEnd('\\'));
- try
- {
- if (SamePath(Path.GetDirectoryName(source), targetDir))
- return new(TransferStatus.Skipped, source, Message: $"„{name}“ ist schon dort.");
- if (Directory.Exists(source) &&
- (targetDir.TrimEnd('\\') + "\\").StartsWith(source.TrimEnd('\\') + "\\", StringComparison.OrdinalIgnoreCase))
- return new(TransferStatus.Error, source, Message: $"„{name}“ kann nicht in sich selbst verschoben werden.");
-
- bool copy = !forceMove && !IsInStorage(source) && IsInStorage(targetDir);
- if (FindEquivalent(targetDir, source) is { } existing)
- {
- var where = IsDesktopDir(targetDir) ? "auf dem Desktop" : $"in „{Path.GetFileName(targetDir)}“";
- return new(TransferStatus.Skipped, source, existing, Message: $"„{DisplayName(source)}“ ist schon {where}.");
- }
-
- Directory.CreateDirectory(targetDir);
- var dest = UniquePath(targetDir, name);
- try
- {
- if (copy) CopyAny(source, dest, onBytes, ct);
- else MoveAny(source, dest, onBytes, ct);
- }
- catch (OperationCanceledException)
- {
- // halb kopiertes Ziel wieder entfernen – die Quelle ist unverändert
- // (beim Verschieben zwischen Laufwerken wird die Quelle erst nach vollständiger Kopie gelöscht)
- DeletePartial(dest);
- return new(TransferStatus.Cancelled, source);
- }
- return new(TransferStatus.Ok, source, dest, copy);
- }
- catch (UnauthorizedAccessException)
- {
- return new(TransferStatus.Error, source, Message: $"„{name}“: Keine Berechtigung. " +
- "(Icons auf dem öffentlichen Desktop brauchen Administratorrechte – App ggf. als Administrator starten.)");
- }
- catch (Exception ex)
- {
- return new(TransferStatus.Error, source, Message: $"„{name}“: {ex.Message}");
- }
- }
-
- /// Verschiebt Datei oder Ordner – auch zwischen Laufwerken.
- public static void MoveAny(string source, string dest, Action? onBytes = null, CancellationToken ct = default)
- {
- NoteCreated(dest);
- if (File.Exists(source))
- {
- File.Move(source, dest);
- return;
- }
- if (string.Equals(Path.GetPathRoot(source), Path.GetPathRoot(dest), StringComparison.OrdinalIgnoreCase))
- {
- Directory.Move(source, dest);
- return;
- }
- CopyDirectory(source, dest, onBytes, ct);
- Directory.Delete(source, recursive: true);
- }
-
- public static void CopyAny(string source, string dest, Action? onBytes = null, CancellationToken ct = default)
- {
- NoteCreated(dest);
- if (Directory.Exists(source)) CopyDirectory(source, dest, onBytes, ct);
- else CopyFile(source, dest, onBytes, ct);
- }
-
- /// Benennt ein Element um. Gibt den neuen Pfad zurück.
- public static string Rename(string path, string newName)
- {
- var dir = Path.GetDirectoryName(path)!;
- var dest = Path.Combine(dir, newName);
- if (string.Equals(path, dest, StringComparison.Ordinal)) return path;
- if (string.Equals(path, dest, StringComparison.OrdinalIgnoreCase))
- { // nur Groß-/Kleinschreibung geändert
- var tmp = path + ".umbenennen";
- MoveAny(path, tmp);
- MoveAny(tmp, dest);
- return dest;
- }
- if (File.Exists(dest) || Directory.Exists(dest)) throw new IOException($"„{newName}“ gibt es dort schon.");
- MoveAny(path, dest);
- return dest;
- }
-
- ///
- /// "Löscht" ein Element: es wandert erst in einen internen Ordner, damit Strg+Z funktioniert.
- /// Erst wenn es nicht mehr rückgängig gemacht werden kann, geht es in den Papierkorb.
- ///
- public static string Delete(string path)
- {
- var dir = Path.Combine(Holding, Guid.NewGuid().ToString("N"));
- Directory.CreateDirectory(dir);
- var dest = Path.Combine(dir, Path.GetFileName(path.TrimEnd('\\')));
- MoveAny(path, dest);
- return dest;
- }
-
- /// Endgültig in den Windows-Papierkorb.
- public static void Recycle(string path)
- {
- try
- {
- if (Directory.Exists(path))
- Microsoft.VisualBasic.FileIO.FileSystem.DeleteDirectory(path,
- Microsoft.VisualBasic.FileIO.UIOption.OnlyErrorDialogs, Microsoft.VisualBasic.FileIO.RecycleOption.SendToRecycleBin);
- else if (File.Exists(path))
- Microsoft.VisualBasic.FileIO.FileSystem.DeleteFile(path,
- Microsoft.VisualBasic.FileIO.UIOption.OnlyErrorDialogs, Microsoft.VisualBasic.FileIO.RecycleOption.SendToRecycleBin);
-
- // leeren Hilfsordner aufräumen
- var parent = Path.GetDirectoryName(path);
- if (parent is not null && IsUnder(parent, Holding) && Directory.Exists(parent) && !Directory.EnumerateFileSystemEntries(parent).Any())
- Directory.Delete(parent);
- }
- catch { }
- }
-
- /// Reste von einem Absturz in den Papierkorb legen.
- public static void FlushHolding()
- {
- if (!Directory.Exists(Holding)) return;
- foreach (var d in Directory.GetDirectories(Holding))
- foreach (var e in Directory.EnumerateFileSystemEntries(d).ToList())
- Recycle(e);
- }
-
- // ---------- Selbst angelegte Desktop-Elemente merken (damit die Auto-Sortierung sie ignoriert) ----------
-
- private static readonly ConcurrentDictionary _created = new(StringComparer.OrdinalIgnoreCase);
-
- private static void NoteCreated(string path) => _created[path] = DateTime.UtcNow;
-
- public static bool CreatedByApp(string path) =>
- _created.TryGetValue(path, out var t) && DateTime.UtcNow - t < TimeSpan.FromSeconds(15);
-
- /// Größe eines Ordners in Bytes (bricht ab, sobald limit überschritten ist).
- public static long DirectorySize(string dir, long limit = long.MaxValue)
- {
- long sum = 0;
- try
- {
- foreach (var f in Directory.EnumerateFiles(dir, "*", SearchOption.AllDirectories))
- {
- try { sum += new FileInfo(f).Length; } catch { }
- if (sum > limit) break;
- }
- }
- catch { }
- return sum;
- }
-
- // ---------- Helfer ----------
-
- public static string DisplayName(string path)
- {
- var n = Path.GetFileName(path.TrimEnd('\\'));
- var ext = Path.GetExtension(n).ToLowerInvariant();
- return ext is ".lnk" or ".url" or ".appref-ms" ? Path.GetFileNameWithoutExtension(n) : n;
- }
-
- /// Endung, die beim Umbenennen erhalten bleibt (bei Verknüpfungen versteckt).
- public static string HiddenExtension(string path)
- {
- var ext = Path.GetExtension(path);
- return ext.ToLowerInvariant() is ".lnk" or ".url" or ".appref-ms" ? ext : "";
- }
-
- public static bool SamePath(string? a, string? b) =>
- a is not null && b is not null &&
- string.Equals(Path.GetFullPath(a).TrimEnd('\\'), Path.GetFullPath(b).TrimEnd('\\'), StringComparison.OrdinalIgnoreCase);
-
- public static bool IsUnder(string path, string dir) =>
- (Path.GetFullPath(path).TrimEnd('\\') + "\\").StartsWith(Path.GetFullPath(dir).TrimEnd('\\') + "\\", StringComparison.OrdinalIgnoreCase);
-
- public static string UniquePath(string dir, string fileName)
- {
- var path = Path.Combine(dir, fileName);
- if (!File.Exists(path) && !Directory.Exists(path)) return path;
- var stem = Path.GetFileNameWithoutExtension(fileName);
- var ext = Path.GetExtension(fileName);
- for (int i = 2; ; i++)
- {
- path = Path.Combine(dir, $"{stem} ({i}){ext}");
- if (!File.Exists(path) && !Directory.Exists(path)) return path;
- }
- }
-
- private static void CopyDirectory(string source, string dest, Action? onBytes, CancellationToken ct)
- {
- Directory.CreateDirectory(dest);
- foreach (var f in Directory.GetFiles(source))
- CopyFile(f, Path.Combine(dest, Path.GetFileName(f)), onBytes, ct);
- foreach (var d in Directory.GetDirectories(source))
- CopyDirectory(d, Path.Combine(dest, Path.GetFileName(d)), onBytes, ct);
- }
-
- /// Kopiert in 1-MB-Stücken, damit Fortschritt angezeigt und abgebrochen werden kann.
- private static void CopyFile(string source, string dest, Action? onBytes, CancellationToken ct)
- {
- ct.ThrowIfCancellationRequested();
- const int Chunk = 1 << 20;
- using (var input = new FileStream(source, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete, Chunk, FileOptions.SequentialScan))
- using (var output = new FileStream(dest, FileMode.CreateNew, FileAccess.Write, FileShare.None, Chunk))
- {
- var buffer = new byte[Chunk];
- int read;
- while ((read = input.Read(buffer, 0, buffer.Length)) > 0)
- {
- ct.ThrowIfCancellationRequested();
- output.Write(buffer, 0, read);
- onBytes?.Invoke(read);
- }
- }
- // Zeitstempel und Attribute wie beim normalen Kopieren übernehmen
- try
- {
- File.SetCreationTimeUtc(dest, File.GetCreationTimeUtc(source));
- File.SetLastWriteTimeUtc(dest, File.GetLastWriteTimeUtc(source));
- File.SetAttributes(dest, File.GetAttributes(source) & ~FileAttributes.ReadOnly);
- }
- catch { }
- }
-
- private static void DeletePartial(string path)
- {
- try
- {
- if (Directory.Exists(path)) Directory.Delete(path, recursive: true);
- else if (File.Exists(path)) File.Delete(path);
- }
- catch { }
- }
-
- /// Wie viele Bytes müssen für diese Übertragung tatsächlich kopiert werden? (0 = nur umbenennen)
- public static long BytesToCopy(string source, string targetDir, bool forceMove)
- {
- bool copy = !forceMove && !IsInStorage(source) && IsInStorage(targetDir);
- bool crossDrive = !string.Equals(Path.GetPathRoot(Path.GetFullPath(source)), Path.GetPathRoot(Path.GetFullPath(targetDir)),
- StringComparison.OrdinalIgnoreCase);
- if (!copy && !crossDrive) return 0;
- try { return Directory.Exists(source) ? DirectorySize(source) : new FileInfo(source).Length; }
- catch { return 0; }
- }
-}
+using System.Collections.Concurrent;
+using System.IO;
+
+namespace DeskCore;
+
+public enum TransferStatus { Ok, Skipped, Error, Cancelled }
+
+/// true = kopiert (Original bleibt), false = verschoben
+public record TransferResult(TransferStatus Status, string Source, string? Dest = null, bool Copied = false, string? Message = null);
+
+///
+/// Kategorien sind einfache Unterordner von %USERPROFILE%\DeskCore.
+/// Icons vom Desktop werden dorthin kopiert – es bleiben normale Dateien.
+///
+public static class Storage
+{
+ public static readonly string Root = Migration.Pick(Environment.SpecialFolder.UserProfile);
+
+ public static readonly string UserDesktop = Environment.GetFolderPath(Environment.SpecialFolder.DesktopDirectory);
+ public static readonly string PublicDesktop = Environment.GetFolderPath(Environment.SpecialFolder.CommonDesktopDirectory);
+
+ /// Hier liegen gelöschte Elemente, solange man sie noch rückgängig machen kann.
+ public static readonly string Holding = Migration.Pick(Environment.SpecialFolder.LocalApplicationData, "Geloescht");
+
+ // ---------- Kategorien ----------
+
+ public static List Categories()
+ {
+ Directory.CreateDirectory(Root);
+ var order = AppSettings.Current.CategoryOrder;
+ return Directory.GetDirectories(Root)
+ .Select(Path.GetFileName)
+ .OfType()
+ .OrderBy(n => order.FindIndex(o => string.Equals(o, n, StringComparison.OrdinalIgnoreCase)) is var i and >= 0 ? i : int.MaxValue)
+ .ThenBy(n => n, StringComparer.CurrentCultureIgnoreCase)
+ .ToList();
+ }
+
+ public static string CategoryPath(string name) => Path.Combine(Root, name);
+
+ public static IEnumerable Entries(string dir)
+ {
+ if (!Directory.Exists(dir)) return [];
+ try
+ {
+ return Directory.EnumerateFileSystemEntries(dir)
+ .Where(p =>
+ {
+ var n = Path.GetFileName(p);
+ if (n.Equals("desktop.ini", StringComparison.OrdinalIgnoreCase)) return false;
+ try
+ {
+ var attr = File.GetAttributes(p);
+ return !attr.HasFlag(FileAttributes.Hidden) && !attr.HasFlag(FileAttributes.System);
+ }
+ catch { return false; }
+ })
+ .ToList();
+ }
+ catch { return []; }
+ }
+
+ public static IEnumerable DesktopEntries() =>
+ Entries(UserDesktop).Concat(Entries(PublicDesktop));
+
+ public static bool IsDesktopDir(string dir) =>
+ SamePath(dir, UserDesktop) || SamePath(dir, PublicDesktop);
+
+ public static bool IsInStorage(string path) =>
+ path.StartsWith(Root.TrimEnd('\\') + "\\", StringComparison.OrdinalIgnoreCase);
+
+ public static string? ValidateName(string name)
+ {
+ name = name.Trim();
+ if (name.Length == 0) return "Bitte einen Namen eingeben.";
+ if (name.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0) return "Der Name enthält ungültige Zeichen ( \\ / : * ? \" < > | ).";
+ if (name is "." or ".." || name.EndsWith('.')) return "Ungültiger Name.";
+ return null;
+ }
+
+ // ---------- Duplikate ----------
+
+ private static readonly ConcurrentDictionary _lnkCache = new(StringComparer.OrdinalIgnoreCase);
+
+ /// Ziel einer Verknüpfung (.lnk/.url), sonst null.
+ public static string? ShortcutTarget(string path)
+ {
+ var ext = Path.GetExtension(path).ToLowerInvariant();
+ if (ext is not (".lnk" or ".url")) return null;
+ DateTime stamp;
+ try { stamp = File.GetLastWriteTimeUtc(path); } catch { return null; }
+ if (_lnkCache.TryGetValue(path, out var c) && c.Stamp == stamp) return c.Target;
+
+ string? target = null;
+ try
+ {
+ if (ext == ".url")
+ {
+ target = File.ReadLines(path).FirstOrDefault(l => l.StartsWith("URL=", StringComparison.OrdinalIgnoreCase))?[4..];
+ }
+ else
+ {
+ dynamic shell = Activator.CreateInstance(Type.GetTypeFromProgID("WScript.Shell")!)!;
+ dynamic lnk = shell.CreateShortcut(path);
+ string t = lnk.TargetPath;
+ string a = lnk.Arguments;
+ target = string.IsNullOrEmpty(t) ? null : (t + " " + a).Trim();
+ }
+ }
+ catch { }
+ if (string.IsNullOrWhiteSpace(target)) target = null;
+ _lnkCache[path] = (stamp, target);
+ return target;
+ }
+
+ // ---------- Verknüpfungen zu deinstallierten Programmen ----------
+
+ private static readonly ConcurrentDictionary _targetPathCache = new(StringComparer.OrdinalIgnoreCase);
+
+ /// Reiner Zielpfad einer .lnk-Verknüpfung (ohne Argumente), sonst null.
+ public static string? LinkTargetPath(string path)
+ {
+ if (!string.Equals(Path.GetExtension(path), ".lnk", StringComparison.OrdinalIgnoreCase)) return null;
+ DateTime stamp;
+ try { stamp = File.GetLastWriteTimeUtc(path); } catch { return null; }
+ if (_targetPathCache.TryGetValue(path, out var c) && c.Stamp == stamp) return c.Target;
+ string? target = null;
+ try
+ {
+ // Direkt über IShellLink lesen (Rohpfad). WScript.Shell "repariert" Ziele, die es nicht findet,
+ // z. B. \\server\… zu C:\server\… – darauf darf eine Lösch-Entscheidung nicht beruhen.
+ var link = (IShellLinkW)new ShellLinkCoClass();
+ try
+ {
+ ((System.Runtime.InteropServices.ComTypes.IPersistFile)link).Load(path, 0); // STGM_READ
+ var sb = new System.Text.StringBuilder(32768);
+ link.GetPath(sb, sb.Capacity, IntPtr.Zero, 0x4); // SLGP_RAWPATH
+ var raw = sb.ToString().Trim();
+ target = raw.Length == 0 ? null : Environment.ExpandEnvironmentVariables(raw);
+ }
+ finally { System.Runtime.InteropServices.Marshal.ReleaseComObject(link); }
+ }
+ catch { }
+ _targetPathCache[path] = (stamp, target);
+ return target;
+ }
+
+ [System.Runtime.InteropServices.ComImport, System.Runtime.InteropServices.Guid("00021401-0000-0000-C000-000000000046")]
+ private class ShellLinkCoClass { }
+
+ [System.Runtime.InteropServices.ComImport, System.Runtime.InteropServices.Guid("000214F9-0000-0000-C000-000000000046"),
+ System.Runtime.InteropServices.InterfaceType(System.Runtime.InteropServices.ComInterfaceType.InterfaceIsIUnknown)]
+ private interface IShellLinkW
+ {
+ void GetPath([System.Runtime.InteropServices.MarshalAs(System.Runtime.InteropServices.UnmanagedType.LPWStr)] System.Text.StringBuilder pszFile,
+ int cch, IntPtr pfd, uint fFlags);
+ // weitere Methoden werden nicht gebraucht (Reihenfolge der vtable ab hier egal)
+ }
+
+ ///
+ /// Zeigt die Verknüpfung auf ein Programm, das es nicht mehr gibt (z. B. deinstalliert)?
+ /// Bewusst vorsichtig – im Zweifel "nein":
+ /// .lnk nur mit festem Ziel auf einer eingebauten, erreichbaren Festplatte; .url nur bei Steam/Epic, wenn deren
+ /// Installationsliste das Spiel nicht mehr kennt. Netzwerk-/USB-Laufwerke, Web-Links, Store-Apps und
+ /// Windows-Installer-Verknüpfungen werden nie als tot gewertet.
+ ///
+ public static bool IsDeadShortcut(string path)
+ {
+ // Steam-/Epic-Links: deren eigene Installationslisten fragen
+ if (string.Equals(Path.GetExtension(path), ".url", StringComparison.OrdinalIgnoreCase))
+ {
+ var url = ShortcutTarget(path);
+ return url is not null && GameLibraries.IsInstalled(url) == false;
+ }
+
+ var target = LinkTargetPath(path);
+ if (target is null) return false;
+ try
+ {
+ if (!Path.IsPathFullyQualified(target) || target.StartsWith(@"\\")) return false; // relativ oder Netzwerk
+ var drive = new DriveInfo(Path.GetPathRoot(target)!);
+ if (!drive.IsReady || drive.DriveType != DriveType.Fixed) return false; // abgesteckt/USB/Netz
+ var installer = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Windows), "Installer");
+ if (IsUnder(target, installer)) return false; // MSI-"Werbe"-Verknüpfung
+ return !File.Exists(target) && !Directory.Exists(target);
+ }
+ catch { return false; }
+ }
+
+ /// Alle toten Verknüpfungen in den Kategorien (ohne die, die der Nutzer behalten will).
+ public static List FindDeadShortcuts(ICollection keep) =>
+ Categories()
+ .SelectMany(c => Entries(CategoryPath(c)))
+ .Where(p => !keep.Contains(p, StringComparer.OrdinalIgnoreCase) && IsDeadShortcut(p))
+ .ToList();
+
+ /// Gleicher Dateiname oder Verknüpfung mit gleichem Ziel?
+ public static bool SameItem(string a, string b)
+ {
+ if (string.Equals(Path.GetFileName(a), Path.GetFileName(b), StringComparison.OrdinalIgnoreCase)) return true;
+ var ta = ShortcutTarget(a);
+ return ta is not null && string.Equals(ta, ShortcutTarget(b), StringComparison.OrdinalIgnoreCase);
+ }
+
+ /// Liegt das Element (oder ein gleichwertiges) schon in diesem Ordner?
+ public static string? FindEquivalent(string dir, string item) =>
+ Entries(dir).FirstOrDefault(e => !SamePath(e, item) && SameItem(e, item));
+
+ /// In welcher Kategorie liegt das Element bereits? (für das ✓ auf dem Desktop)
+ public static string? FindCategoryOf(string item, IReadOnlyDictionary> categoryEntries)
+ {
+ foreach (var (cat, entries) in categoryEntries)
+ if (entries.Any(e => SameItem(e, item))) return cat;
+ return null;
+ }
+
+ // ---------- Übertragen ----------
+
+ ///
+ /// Übernimmt Datei/Ordner nach targetDir. Kommt die Quelle von außerhalb der App (z. B. vom Desktop),
+ /// wird kopiert – das Original kann danach gelöscht werden. Innerhalb der App und zurück auf den
+ /// Desktop wird verschoben. Gleiche Elemente werden nicht doppelt angelegt.
+ ///
+ public static TransferResult Transfer(string source, string targetDir, bool forceMove = false,
+ Action? onBytes = null, CancellationToken ct = default)
+ {
+ if (ct.IsCancellationRequested) return new(TransferStatus.Cancelled, source);
+ var name = Path.GetFileName(source.TrimEnd('\\'));
+ try
+ {
+ if (SamePath(Path.GetDirectoryName(source), targetDir))
+ return new(TransferStatus.Skipped, source, Message: $"„{name}“ ist schon dort.");
+ if (Directory.Exists(source) &&
+ (targetDir.TrimEnd('\\') + "\\").StartsWith(source.TrimEnd('\\') + "\\", StringComparison.OrdinalIgnoreCase))
+ return new(TransferStatus.Error, source, Message: $"„{name}“ kann nicht in sich selbst verschoben werden.");
+
+ bool copy = !forceMove && !IsInStorage(source) && IsInStorage(targetDir);
+ if (FindEquivalent(targetDir, source) is { } existing)
+ {
+ var where = IsDesktopDir(targetDir) ? "auf dem Desktop" : $"in „{Path.GetFileName(targetDir)}“";
+ return new(TransferStatus.Skipped, source, existing, Message: $"„{DisplayName(source)}“ ist schon {where}.");
+ }
+
+ Directory.CreateDirectory(targetDir);
+ var dest = UniquePath(targetDir, name);
+ try
+ {
+ if (copy) CopyAny(source, dest, onBytes, ct);
+ else MoveAny(source, dest, onBytes, ct);
+ }
+ catch (OperationCanceledException)
+ {
+ // halb kopiertes Ziel wieder entfernen – die Quelle ist unverändert
+ // (beim Verschieben zwischen Laufwerken wird die Quelle erst nach vollständiger Kopie gelöscht)
+ DeletePartial(dest);
+ return new(TransferStatus.Cancelled, source);
+ }
+ return new(TransferStatus.Ok, source, dest, copy);
+ }
+ catch (UnauthorizedAccessException)
+ {
+ return new(TransferStatus.Error, source, Message: $"„{name}“: Keine Berechtigung. " +
+ "(Icons auf dem öffentlichen Desktop brauchen Administratorrechte – App ggf. als Administrator starten.)");
+ }
+ catch (Exception ex)
+ {
+ return new(TransferStatus.Error, source, Message: $"„{name}“: {ex.Message}");
+ }
+ }
+
+ /// Verschiebt Datei oder Ordner – auch zwischen Laufwerken.
+ public static void MoveAny(string source, string dest, Action? onBytes = null, CancellationToken ct = default)
+ {
+ NoteCreated(dest);
+ if (File.Exists(source))
+ {
+ File.Move(source, dest);
+ return;
+ }
+ if (string.Equals(Path.GetPathRoot(source), Path.GetPathRoot(dest), StringComparison.OrdinalIgnoreCase))
+ {
+ Directory.Move(source, dest);
+ return;
+ }
+ CopyDirectory(source, dest, onBytes, ct);
+ Directory.Delete(source, recursive: true);
+ }
+
+ public static void CopyAny(string source, string dest, Action? onBytes = null, CancellationToken ct = default)
+ {
+ NoteCreated(dest);
+ if (Directory.Exists(source)) CopyDirectory(source, dest, onBytes, ct);
+ else CopyFile(source, dest, onBytes, ct);
+ }
+
+ /// Benennt ein Element um. Gibt den neuen Pfad zurück.
+ public static string Rename(string path, string newName)
+ {
+ var dir = Path.GetDirectoryName(path)!;
+ var dest = Path.Combine(dir, newName);
+ if (string.Equals(path, dest, StringComparison.Ordinal)) return path;
+ if (string.Equals(path, dest, StringComparison.OrdinalIgnoreCase))
+ { // nur Groß-/Kleinschreibung geändert
+ var tmp = path + ".umbenennen";
+ MoveAny(path, tmp);
+ MoveAny(tmp, dest);
+ return dest;
+ }
+ if (File.Exists(dest) || Directory.Exists(dest)) throw new IOException($"„{newName}“ gibt es dort schon.");
+ MoveAny(path, dest);
+ return dest;
+ }
+
+ ///
+ /// "Löscht" ein Element: es wandert erst in einen internen Ordner, damit Strg+Z funktioniert.
+ /// Erst wenn es nicht mehr rückgängig gemacht werden kann, geht es in den Papierkorb.
+ ///
+ public static string Delete(string path)
+ {
+ var dir = Path.Combine(Holding, Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(dir);
+ var dest = Path.Combine(dir, Path.GetFileName(path.TrimEnd('\\')));
+ MoveAny(path, dest);
+ return dest;
+ }
+
+ /// Endgültig in den Windows-Papierkorb.
+ public static void Recycle(string path)
+ {
+ try
+ {
+ if (Directory.Exists(path))
+ Microsoft.VisualBasic.FileIO.FileSystem.DeleteDirectory(path,
+ Microsoft.VisualBasic.FileIO.UIOption.OnlyErrorDialogs, Microsoft.VisualBasic.FileIO.RecycleOption.SendToRecycleBin);
+ else if (File.Exists(path))
+ Microsoft.VisualBasic.FileIO.FileSystem.DeleteFile(path,
+ Microsoft.VisualBasic.FileIO.UIOption.OnlyErrorDialogs, Microsoft.VisualBasic.FileIO.RecycleOption.SendToRecycleBin);
+
+ // leeren Hilfsordner aufräumen
+ var parent = Path.GetDirectoryName(path);
+ if (parent is not null && IsUnder(parent, Holding) && Directory.Exists(parent) && !Directory.EnumerateFileSystemEntries(parent).Any())
+ Directory.Delete(parent);
+ }
+ catch { }
+ }
+
+ /// Reste von einem Absturz in den Papierkorb legen.
+ public static void FlushHolding()
+ {
+ if (!Directory.Exists(Holding)) return;
+ foreach (var d in Directory.GetDirectories(Holding))
+ foreach (var e in Directory.EnumerateFileSystemEntries(d).ToList())
+ Recycle(e);
+ }
+
+ // ---------- Selbst angelegte Desktop-Elemente merken (damit die Auto-Sortierung sie ignoriert) ----------
+
+ private static readonly ConcurrentDictionary _created = new(StringComparer.OrdinalIgnoreCase);
+
+ private static void NoteCreated(string path) => _created[path] = DateTime.UtcNow;
+
+ /// Von der App selbst angelegte Datei merken (z. B. Desktop-Verknüpfung einer Start-Gruppe).
+ public static void MarkCreated(string path) => NoteCreated(path);
+
+ public static bool CreatedByApp(string path) =>
+ _created.TryGetValue(path, out var t) && DateTime.UtcNow - t < TimeSpan.FromSeconds(15);
+
+ /// Größe eines Ordners in Bytes (bricht ab, sobald limit überschritten ist).
+ public static long DirectorySize(string dir, long limit = long.MaxValue)
+ {
+ long sum = 0;
+ try
+ {
+ foreach (var f in Directory.EnumerateFiles(dir, "*", SearchOption.AllDirectories))
+ {
+ try { sum += new FileInfo(f).Length; } catch { }
+ if (sum > limit) break;
+ }
+ }
+ catch { }
+ return sum;
+ }
+
+ // ---------- Helfer ----------
+
+ public static string DisplayName(string path)
+ {
+ var n = Path.GetFileName(path.TrimEnd('\\'));
+ var ext = Path.GetExtension(n).ToLowerInvariant();
+ return ext is ".lnk" or ".url" or ".appref-ms" ? Path.GetFileNameWithoutExtension(n) : n;
+ }
+
+ /// Endung, die beim Umbenennen erhalten bleibt (bei Verknüpfungen versteckt).
+ public static string HiddenExtension(string path)
+ {
+ var ext = Path.GetExtension(path);
+ return ext.ToLowerInvariant() is ".lnk" or ".url" or ".appref-ms" ? ext : "";
+ }
+
+ public static bool SamePath(string? a, string? b) =>
+ a is not null && b is not null &&
+ string.Equals(Path.GetFullPath(a).TrimEnd('\\'), Path.GetFullPath(b).TrimEnd('\\'), StringComparison.OrdinalIgnoreCase);
+
+ public static bool IsUnder(string path, string dir) =>
+ (Path.GetFullPath(path).TrimEnd('\\') + "\\").StartsWith(Path.GetFullPath(dir).TrimEnd('\\') + "\\", StringComparison.OrdinalIgnoreCase);
+
+ public static string UniquePath(string dir, string fileName)
+ {
+ var path = Path.Combine(dir, fileName);
+ if (!File.Exists(path) && !Directory.Exists(path)) return path;
+ var stem = Path.GetFileNameWithoutExtension(fileName);
+ var ext = Path.GetExtension(fileName);
+ for (int i = 2; ; i++)
+ {
+ path = Path.Combine(dir, $"{stem} ({i}){ext}");
+ if (!File.Exists(path) && !Directory.Exists(path)) return path;
+ }
+ }
+
+ private static void CopyDirectory(string source, string dest, Action? onBytes, CancellationToken ct)
+ {
+ Directory.CreateDirectory(dest);
+ foreach (var f in Directory.GetFiles(source))
+ CopyFile(f, Path.Combine(dest, Path.GetFileName(f)), onBytes, ct);
+ foreach (var d in Directory.GetDirectories(source))
+ CopyDirectory(d, Path.Combine(dest, Path.GetFileName(d)), onBytes, ct);
+ }
+
+ /// Kopiert in 1-MB-Stücken, damit Fortschritt angezeigt und abgebrochen werden kann.
+ private static void CopyFile(string source, string dest, Action? onBytes, CancellationToken ct)
+ {
+ ct.ThrowIfCancellationRequested();
+ const int Chunk = 1 << 20;
+ using (var input = new FileStream(source, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete, Chunk, FileOptions.SequentialScan))
+ using (var output = new FileStream(dest, FileMode.CreateNew, FileAccess.Write, FileShare.None, Chunk))
+ {
+ var buffer = new byte[Chunk];
+ int read;
+ while ((read = input.Read(buffer, 0, buffer.Length)) > 0)
+ {
+ ct.ThrowIfCancellationRequested();
+ output.Write(buffer, 0, read);
+ onBytes?.Invoke(read);
+ }
+ }
+ // Zeitstempel und Attribute wie beim normalen Kopieren übernehmen
+ try
+ {
+ File.SetCreationTimeUtc(dest, File.GetCreationTimeUtc(source));
+ File.SetLastWriteTimeUtc(dest, File.GetLastWriteTimeUtc(source));
+ File.SetAttributes(dest, File.GetAttributes(source) & ~FileAttributes.ReadOnly);
+ }
+ catch { }
+ }
+
+ private static void DeletePartial(string path)
+ {
+ try
+ {
+ if (Directory.Exists(path)) Directory.Delete(path, recursive: true);
+ else if (File.Exists(path)) File.Delete(path);
+ }
+ catch { }
+ }
+
+ /// Wie viele Bytes müssen für diese Übertragung tatsächlich kopiert werden? (0 = nur umbenennen)
+ public static long BytesToCopy(string source, string targetDir, bool forceMove)
+ {
+ bool copy = !forceMove && !IsInStorage(source) && IsInStorage(targetDir);
+ bool crossDrive = !string.Equals(Path.GetPathRoot(Path.GetFullPath(source)), Path.GetPathRoot(Path.GetFullPath(targetDir)),
+ StringComparison.OrdinalIgnoreCase);
+ if (!copy && !crossDrive) return 0;
+ try { return Directory.Exists(source) ? DirectorySize(source) : new FileInfo(source).Length; }
+ catch { return 0; }
+ }
+}
diff --git a/Theme.cs b/Theme.cs
index ae1a082..12c2a8c 100644
--- a/Theme.cs
+++ b/Theme.cs
@@ -1,74 +1,74 @@
-using System.Windows;
-using System.Windows.Media;
-using Microsoft.Win32;
-
-namespace DeskCore;
-
-/// Übernimmt Hell/Dunkel-Modus und Akzentfarbe von Windows – auch live, wenn man sie ändert.
-public static class Theme
-{
- public static bool IsLight { get; private set; } = true;
-
- /// Vom Benutzer gewählter Modus; "System" folgt der Windows-Einstellung.
- public static DesignMode Mode => AppSettings.Current.Design;
-
- public static void Init()
- {
- Apply();
- SystemEvents.UserPreferenceChanged += (_, e) =>
- {
- if (e.Category is UserPreferenceCategory.General or UserPreferenceCategory.Color or UserPreferenceCategory.VisualStyle)
- Application.Current.Dispatcher.BeginInvoke(Apply);
- };
- }
-
- public static void Apply()
- {
- IsLight = Mode switch
- {
- DesignMode.Light => true,
- DesignMode.Dark => false,
- _ => ReadDword(@"Software\Microsoft\Windows\CurrentVersion\Themes\Personalize", "AppsUseLightTheme") != 0,
- };
- var res = Application.Current.Resources;
- res.MergedDictionaries[0] = new ResourceDictionary
- {
- Source = new Uri($"pack://application:,,,/Themes/Colors.{(IsLight ? "Light" : "Dark")}.xaml")
- };
-
- // DWM speichert die Akzentfarbe als ABGR
- int abgr = ReadDword(@"Software\Microsoft\Windows\DWM", "AccentColor") is var v and not -1 ? v : unchecked((int)0xFFD77800);
- var accent = Color.FromRgb((byte)abgr, (byte)(abgr >> 8), (byte)(abgr >> 16));
-
- // Wie Windows 11: im Hellmodus etwas dunkler + weiße Schrift, im Dunkelmodus deutlich heller + schwarze Schrift
- var a = IsLight ? Mix(accent, Colors.Black, 0.12) : Mix(accent, Colors.White, 0.45);
- res["AccentBrush"] = Brush(a);
- res["AccentHoverBrush"] = Brush(IsLight ? Mix(a, Colors.White, 0.1) : Mix(a, Colors.Black, 0.1));
- res["OnAccentBrush"] = Brush(IsLight ? Colors.White : Colors.Black);
- res["AccentSubtleBrush"] = Brush(Color.FromArgb(IsLight ? (byte)0x22 : (byte)0x30, a.R, a.G, a.B));
- res["AccentSubtleStrokeBrush"] = Brush(Color.FromArgb(IsLight ? (byte)0x55 : (byte)0x60, a.R, a.G, a.B));
- Changed?.Invoke();
- }
-
- public static event Action? Changed;
-
- private static int ReadDword(string key, string name)
- {
- try
- {
- using var k = Registry.CurrentUser.OpenSubKey(key);
- return k?.GetValue(name) is int i ? i : -1;
- }
- catch { return -1; }
- }
-
- private static Color Mix(Color c, Color with, double t) => Color.FromRgb(
- (byte)(c.R + (with.R - c.R) * t), (byte)(c.G + (with.G - c.G) * t), (byte)(c.B + (with.B - c.B) * t));
-
- private static SolidColorBrush Brush(Color c)
- {
- var b = new SolidColorBrush(c);
- b.Freeze();
- return b;
- }
-}
+using System.Windows;
+using System.Windows.Media;
+using Microsoft.Win32;
+
+namespace DeskCore;
+
+/// Übernimmt Hell/Dunkel-Modus und Akzentfarbe von Windows – auch live, wenn man sie ändert.
+public static class Theme
+{
+ public static bool IsLight { get; private set; } = true;
+
+ /// Vom Benutzer gewählter Modus; "System" folgt der Windows-Einstellung.
+ public static DesignMode Mode => AppSettings.Current.Design;
+
+ public static void Init()
+ {
+ Apply();
+ SystemEvents.UserPreferenceChanged += (_, e) =>
+ {
+ if (e.Category is UserPreferenceCategory.General or UserPreferenceCategory.Color or UserPreferenceCategory.VisualStyle)
+ Application.Current.Dispatcher.BeginInvoke(Apply);
+ };
+ }
+
+ public static void Apply()
+ {
+ IsLight = Mode switch
+ {
+ DesignMode.Light => true,
+ DesignMode.Dark => false,
+ _ => ReadDword(@"Software\Microsoft\Windows\CurrentVersion\Themes\Personalize", "AppsUseLightTheme") != 0,
+ };
+ var res = Application.Current.Resources;
+ res.MergedDictionaries[0] = new ResourceDictionary
+ {
+ Source = new Uri($"pack://application:,,,/Themes/Colors.{(IsLight ? "Light" : "Dark")}.xaml")
+ };
+
+ // DWM speichert die Akzentfarbe als ABGR
+ int abgr = ReadDword(@"Software\Microsoft\Windows\DWM", "AccentColor") is var v and not -1 ? v : unchecked((int)0xFFD77800);
+ var accent = Color.FromRgb((byte)abgr, (byte)(abgr >> 8), (byte)(abgr >> 16));
+
+ // Wie Windows 11: im Hellmodus etwas dunkler + weiße Schrift, im Dunkelmodus deutlich heller + schwarze Schrift
+ var a = IsLight ? Mix(accent, Colors.Black, 0.12) : Mix(accent, Colors.White, 0.45);
+ res["AccentBrush"] = Brush(a);
+ res["AccentHoverBrush"] = Brush(IsLight ? Mix(a, Colors.White, 0.1) : Mix(a, Colors.Black, 0.1));
+ res["OnAccentBrush"] = Brush(IsLight ? Colors.White : Colors.Black);
+ res["AccentSubtleBrush"] = Brush(Color.FromArgb(IsLight ? (byte)0x22 : (byte)0x30, a.R, a.G, a.B));
+ res["AccentSubtleStrokeBrush"] = Brush(Color.FromArgb(IsLight ? (byte)0x55 : (byte)0x60, a.R, a.G, a.B));
+ Changed?.Invoke();
+ }
+
+ public static event Action? Changed;
+
+ private static int ReadDword(string key, string name)
+ {
+ try
+ {
+ using var k = Registry.CurrentUser.OpenSubKey(key);
+ return k?.GetValue(name) is int i ? i : -1;
+ }
+ catch { return -1; }
+ }
+
+ private static Color Mix(Color c, Color with, double t) => Color.FromRgb(
+ (byte)(c.R + (with.R - c.R) * t), (byte)(c.G + (with.G - c.G) * t), (byte)(c.B + (with.B - c.B) * t));
+
+ private static SolidColorBrush Brush(Color c)
+ {
+ var b = new SolidColorBrush(c);
+ b.Freeze();
+ return b;
+ }
+}
diff --git a/Watcher.cs b/Watcher.cs
index e1d4198..d3c791a 100644
--- a/Watcher.cs
+++ b/Watcher.cs
@@ -1,111 +1,140 @@
-using System.IO;
-using System.Windows.Threading;
-
-namespace DeskCore;
-
-///
-/// Beobachtet Desktop und Kategorie-Ordner. Meldet Änderungen gebündelt (statt bei jedem einzelnen Ereignis)
-/// und sammelt neu auf dem Desktop aufgetauchte Elemente für die automatische Sortierung.
-///
-public sealed class Watcher : IDisposable
-{
- private readonly List _watchers = [];
- private readonly DispatcherTimer _changedTimer;
- private readonly DispatcherTimer _newTimer;
- private readonly HashSet _newOnDesktop = new(StringComparer.OrdinalIgnoreCase);
- private readonly object _lock = new();
-
- /// Irgendetwas hat sich geändert (UI-Thread).
- public event Action? Changed;
- /// Neue Elemente auf dem Desktop (UI-Thread, nach kurzer Wartezeit).
- public event Action>? NewOnDesktop;
-
- public Watcher(Dispatcher dispatcher)
- {
- _changedTimer = new DispatcherTimer(TimeSpan.FromMilliseconds(350), DispatcherPriority.Background, (_, _) =>
- {
- _changedTimer!.Stop();
- Changed?.Invoke();
- }, dispatcher);
- _changedTimer.Stop();
-
- // Etwas länger warten: Installer legen Verknüpfungen oft in mehreren Schritten an
- _newTimer = new DispatcherTimer(TimeSpan.FromSeconds(2), DispatcherPriority.Background, (_, _) =>
- {
- _newTimer!.Stop();
- List items;
- lock (_lock) { items = _newOnDesktop.ToList(); _newOnDesktop.Clear(); }
- items = items.Where(p => (File.Exists(p) || Directory.Exists(p)) && !Storage.CreatedByApp(p)).ToList();
- if (items.Count > 0) NewOnDesktop?.Invoke(items);
- }, dispatcher);
- _newTimer.Stop();
-
- Directory.CreateDirectory(Storage.Root);
- Add(Storage.UserDesktop, false, true);
- Add(Storage.PublicDesktop, false, true);
- Add(Storage.Root, true, false);
- }
-
- private void Add(string dir, bool subdirs, bool isDesktop)
- {
- if (!Directory.Exists(dir)) return;
- try
- {
- var w = new FileSystemWatcher(dir)
- {
- IncludeSubdirectories = subdirs,
- NotifyFilter = NotifyFilters.FileName | NotifyFilters.DirectoryName | NotifyFilters.LastWrite,
- InternalBufferSize = 64 * 1024,
- };
- FileSystemEventHandler onChange = (_, e) =>
- {
- Poke();
- if (isDesktop && e.ChangeType == WatcherChangeTypes.Created) NoteNew(e.FullPath);
- };
- w.Created += onChange;
- w.Deleted += onChange;
- w.Changed += onChange;
- w.Renamed += (_, e) => { Poke(); if (isDesktop) NoteNew(e.FullPath); };
- w.Error += (_, _) => Poke();
- w.EnableRaisingEvents = true;
- _watchers.Add(w);
- }
- catch { }
- }
-
- private void NoteNew(string path)
- {
- var name = Path.GetFileName(path);
- if (name.StartsWith('~') || name.EndsWith(".tmp", StringComparison.OrdinalIgnoreCase) ||
- name.Equals("desktop.ini", StringComparison.OrdinalIgnoreCase)) return;
- lock (_lock) _newOnDesktop.Add(path);
- _newTimer.Dispatcher.BeginInvoke(() => { _newTimer.Stop(); _newTimer.Start(); });
- }
-
- private void Poke() => _changedTimer.Dispatcher.BeginInvoke(() => { _changedTimer.Stop(); _changedTimer.Start(); });
-
- public void Dispose()
- {
- foreach (var w in _watchers) w.Dispose();
- _changedTimer.Stop();
- _newTimer.Stop();
- }
-}
-
-public static class AutoSorter
-{
- /// Erste passende Regel → Kategoriename (nur existierende Kategorien).
- public static string? Match(string path)
- {
- var name = Storage.DisplayName(path);
- var cats = Storage.Categories();
- foreach (var r in AppSettings.Current.Rules)
- {
- if (string.IsNullOrWhiteSpace(r.Contains)) continue;
- if (!name.Contains(r.Contains.Trim(), StringComparison.CurrentCultureIgnoreCase)) continue;
- var cat = cats.FirstOrDefault(c => string.Equals(c, r.Category, StringComparison.OrdinalIgnoreCase));
- if (cat is not null) return cat;
- }
- return null;
- }
-}
+using System.IO;
+using System.Windows.Threading;
+
+namespace DeskCore;
+
+///
+/// Beobachtet Desktop und Kategorie-Ordner. Meldet Änderungen gebündelt (statt bei jedem einzelnen Ereignis)
+/// und sammelt neu auf dem Desktop aufgetauchte Elemente für die automatische Sortierung.
+///
+public sealed class Watcher : IDisposable
+{
+ private readonly List _watchers = [];
+ private readonly DispatcherTimer _changedTimer;
+ private readonly DispatcherTimer _newTimer;
+ private readonly HashSet _newOnDesktop = new(StringComparer.OrdinalIgnoreCase);
+ private readonly object _lock = new();
+
+ /// Irgendetwas hat sich geändert (UI-Thread).
+ public event Action? Changed;
+ /// Neue Elemente auf dem Desktop (UI-Thread, nach kurzer Wartezeit).
+ public event Action>? NewOnDesktop;
+
+ public Watcher(Dispatcher dispatcher)
+ {
+ _changedTimer = new DispatcherTimer(TimeSpan.FromMilliseconds(350), DispatcherPriority.Background, (_, _) =>
+ {
+ _changedTimer!.Stop();
+ Changed?.Invoke();
+ }, dispatcher);
+ _changedTimer.Stop();
+
+ // Etwas länger warten: Installer legen Verknüpfungen oft in mehreren Schritten an
+ _newTimer = new DispatcherTimer(TimeSpan.FromSeconds(2), DispatcherPriority.Background, (_, _) =>
+ {
+ _newTimer!.Stop();
+ List items;
+ lock (_lock) { items = _newOnDesktop.ToList(); _newOnDesktop.Clear(); }
+ items = items.Where(p => (File.Exists(p) || Directory.Exists(p)) && !Storage.CreatedByApp(p)).ToList();
+ if (items.Count > 0) NewOnDesktop?.Invoke(items);
+ }, dispatcher);
+ _newTimer.Stop();
+
+ Directory.CreateDirectory(Storage.Root);
+ Add(Storage.UserDesktop, false, true);
+ Add(Storage.PublicDesktop, false, true);
+ Add(Storage.Root, true, false);
+ }
+
+ private void Add(string dir, bool subdirs, bool isDesktop)
+ {
+ if (!Directory.Exists(dir)) return;
+ try
+ {
+ var w = new FileSystemWatcher(dir)
+ {
+ IncludeSubdirectories = subdirs,
+ NotifyFilter = NotifyFilters.FileName | NotifyFilters.DirectoryName | NotifyFilters.LastWrite,
+ InternalBufferSize = 64 * 1024,
+ };
+ FileSystemEventHandler onChange = (_, e) =>
+ {
+ Poke();
+ if (isDesktop && e.ChangeType == WatcherChangeTypes.Created) NoteNew(e.FullPath);
+ };
+ w.Created += onChange;
+ w.Deleted += onChange;
+ w.Changed += onChange;
+ w.Renamed += (_, e) => { Poke(); if (isDesktop) NoteNew(e.FullPath); };
+ w.Error += (_, _) => Poke();
+ w.EnableRaisingEvents = true;
+ _watchers.Add(w);
+ }
+ catch { }
+ }
+
+ private void NoteNew(string path)
+ {
+ var name = Path.GetFileName(path);
+ if (name.StartsWith('~') || name.EndsWith(".tmp", StringComparison.OrdinalIgnoreCase) ||
+ name.Equals("desktop.ini", StringComparison.OrdinalIgnoreCase)) return;
+ lock (_lock) _newOnDesktop.Add(path);
+ _newTimer.Dispatcher.BeginInvoke(() => { _newTimer.Stop(); _newTimer.Start(); });
+ }
+
+ private void Poke() => _changedTimer.Dispatcher.BeginInvoke(() => { _changedTimer.Stop(); _changedTimer.Start(); });
+
+ public void Dispose()
+ {
+ foreach (var w in _watchers) w.Dispose();
+ _changedTimer.Stop();
+ _newTimer.Stop();
+ }
+}
+
+public static class AutoSorter
+{
+ /// Dateityp-Gruppen für Regeln vom Typ "Dateityp ist".
+ private static readonly Dictionary Groups = new(StringComparer.CurrentCultureIgnoreCase)
+ {
+ ["Bilder"] = ["jpg", "jpeg", "png", "gif", "bmp", "webp", "heic", "tif", "tiff", "svg", "ico", "raw", "psd"],
+ ["Videos"] = ["mp4", "mkv", "avi", "mov", "wmv", "webm", "m4v", "flv", "mpg", "mpeg"],
+ ["Musik"] = ["mp3", "wav", "flac", "aac", "ogg", "m4a", "wma", "opus"],
+ ["Dokumente"] = ["pdf", "doc", "docx", "xls", "xlsx", "ppt", "pptx", "odt", "ods", "odp", "txt", "rtf", "md", "csv"],
+ ["Archive"] = ["zip", "rar", "7z", "tar", "gz", "bz2", "xz", "iso"],
+ ["Programme"] = ["exe", "msi", "lnk", "url", "appref-ms", "bat", "cmd"],
+ ["Verknüpfungen"] = ["lnk", "url", "appref-ms"],
+ };
+
+ /// Passt eine Regel auf das Element?
+ public static bool Matches(SortRule r, string path)
+ {
+ var value = r.Contains.Trim();
+ if (value.Length == 0) return false;
+ if (r.Kind != "Typ") return Storage.DisplayName(path).Contains(value, StringComparison.CurrentCultureIgnoreCase);
+
+ bool isFolder = Directory.Exists(path);
+ var ext = Path.GetExtension(path).TrimStart('.').ToLowerInvariant();
+ foreach (var token in value.Split([',', ';', ' '], StringSplitOptions.RemoveEmptyEntries))
+ {
+ var t = token.Trim().TrimStart('*', '.').ToLowerInvariant();
+ if (t is "ordner") { if (isFolder) return true; continue; }
+ if (Groups.TryGetValue(t, out var exts)) { if (!isFolder && exts.Contains(ext)) return true; continue; }
+ if (!isFolder && t == ext) return true;
+ }
+ return false;
+ }
+
+ /// Erste passende Regel → Kategoriename (nur existierende Kategorien).
+ public static string? Match(string path)
+ {
+ var cats = Storage.Categories();
+ foreach (var r in AppSettings.Current.Rules)
+ {
+ if (!Matches(r, path)) continue;
+ var cat = cats.FirstOrDefault(c => string.Equals(c, r.Category, StringComparison.OrdinalIgnoreCase));
+ if (cat is not null) return cat;
+ }
+ return null;
+ }
+}
diff --git a/artifacts/app/DeskCore.exe b/artifacts/app/DeskCore.exe
new file mode 100644
index 0000000..f0bf02f
Binary files /dev/null and b/artifacts/app/DeskCore.exe differ
diff --git a/artifacts/app/LICENSE.txt b/artifacts/app/LICENSE.txt
new file mode 100644
index 0000000..dda0c59
--- /dev/null
+++ b/artifacts/app/LICENSE.txt
@@ -0,0 +1,38 @@
+DeskCore – Lizenz- und Nutzungsbedingungen
+
+Copyright (c) M_Viper. Alle Rechte vorbehalten.
+
+Diese Software („DeskCore") sowie sämtlicher zugehöriger Quellcode, Ressourcen und
+Dokumentation sind urheberrechtlich geschützt. Mit der Nutzung dieser Software
+erkennst du die folgenden Bedingungen an.
+
+1. Erlaubte Nutzung
+ Die Nutzung von DeskCore ist ausschließlich für den privaten, nicht-kommerziellen
+ Gebrauch gestattet.
+
+2. Untersagt ist insbesondere
+ - Die Weitergabe dieser Software oder von Teilen davon an Dritte
+ - Das Verändern, Dekompilieren, Zurückentwickeln (Reverse Engineering) oder
+ Bearbeiten des Codes
+ - Die Veröffentlichung – auch in veränderter oder abgeleiteter Form –
+ an anderer Stelle als den offiziellen Kanälen des Autors
+ - Der Verkauf sowie jede sonstige kommerzielle Nutzung
+
+3. Keine Gewährleistung
+ Die Software wird ohne jegliche Gewährleistung bereitgestellt, weder
+ ausdrücklich noch stillschweigend, einschließlich, aber nicht beschränkt auf
+ die Gewährleistung der Marktgängigkeit, der Eignung für einen bestimmten
+ Zweck und der Nichtverletzung von Rechten Dritter. Der Autor haftet nicht für
+ Schäden, die aus der Nutzung oder Unmöglichkeit der Nutzung der Software
+ entstehen.
+
+4. Rechtsfolgen bei Verstoß
+ Wer gegen diese Bedingungen verstößt, macht sich strafbar und kann rechtlich
+ belangt werden. Alle Rechte, die hier nicht ausdrücklich eingeräumt werden,
+ bleiben beim Autor vorbehalten.
+
+5. Markenhinweis
+ DeskCore ist kein offizielles Produkt von Microsoft. Windows ist
+ eine Marke der Microsoft Corporation.
+
+Kontakt: https://m-viper.de
diff --git a/artifacts/setup/DeskCore-Setup.exe b/artifacts/setup/DeskCore-Setup.exe
new file mode 100644
index 0000000..4a287d4
Binary files /dev/null and b/artifacts/setup/DeskCore-Setup.exe differ
diff --git a/artifacts/setup/DeskCore-Setup.exe.config b/artifacts/setup/DeskCore-Setup.exe.config
new file mode 100644
index 0000000..ef94b7c
--- /dev/null
+++ b/artifacts/setup/DeskCore-Setup.exe.config
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/bin/Release/net9.0-windows/DeskCore.deps.json b/bin/Release/net9.0-windows/DeskCore.deps.json
new file mode 100644
index 0000000..a9af38f
--- /dev/null
+++ b/bin/Release/net9.0-windows/DeskCore.deps.json
@@ -0,0 +1,23 @@
+{
+ "runtimeTarget": {
+ "name": ".NETCoreApp,Version=v9.0",
+ "signature": ""
+ },
+ "compilationOptions": {},
+ "targets": {
+ ".NETCoreApp,Version=v9.0": {
+ "DeskCore/1.1.0": {
+ "runtime": {
+ "DeskCore.dll": {}
+ }
+ }
+ }
+ },
+ "libraries": {
+ "DeskCore/1.1.0": {
+ "type": "project",
+ "serviceable": false,
+ "sha512": ""
+ }
+ }
+}
\ No newline at end of file
diff --git a/bin/Release/net9.0-windows/DeskCore.dll b/bin/Release/net9.0-windows/DeskCore.dll
new file mode 100644
index 0000000..d3a606a
Binary files /dev/null and b/bin/Release/net9.0-windows/DeskCore.dll differ
diff --git a/bin/Release/net9.0-windows/DeskCore.exe b/bin/Release/net9.0-windows/DeskCore.exe
new file mode 100644
index 0000000..de10b72
Binary files /dev/null and b/bin/Release/net9.0-windows/DeskCore.exe differ
diff --git a/bin/Release/net9.0-windows/DeskCore.pdb b/bin/Release/net9.0-windows/DeskCore.pdb
new file mode 100644
index 0000000..0350a66
Binary files /dev/null and b/bin/Release/net9.0-windows/DeskCore.pdb differ
diff --git a/bin/Release/net9.0-windows/DeskCore.runtimeconfig.json b/bin/Release/net9.0-windows/DeskCore.runtimeconfig.json
new file mode 100644
index 0000000..7f70d46
--- /dev/null
+++ b/bin/Release/net9.0-windows/DeskCore.runtimeconfig.json
@@ -0,0 +1,20 @@
+{
+ "runtimeOptions": {
+ "tfm": "net9.0",
+ "frameworks": [
+ {
+ "name": "Microsoft.NETCore.App",
+ "version": "9.0.0"
+ },
+ {
+ "name": "Microsoft.WindowsDesktop.App",
+ "version": "9.0.0"
+ }
+ ],
+ "configProperties": {
+ "System.Reflection.Metadata.MetadataUpdater.IsSupported": false,
+ "System.Runtime.Serialization.EnableUnsafeBinaryFormatterSerialization": false,
+ "CSWINRT_USE_WINDOWS_UI_XAML_PROJECTIONS": false
+ }
+ }
+}
\ No newline at end of file
diff --git a/bin/Release/net9.0-windows/LICENSE.txt b/bin/Release/net9.0-windows/LICENSE.txt
new file mode 100644
index 0000000..dda0c59
--- /dev/null
+++ b/bin/Release/net9.0-windows/LICENSE.txt
@@ -0,0 +1,38 @@
+DeskCore – Lizenz- und Nutzungsbedingungen
+
+Copyright (c) M_Viper. Alle Rechte vorbehalten.
+
+Diese Software („DeskCore") sowie sämtlicher zugehöriger Quellcode, Ressourcen und
+Dokumentation sind urheberrechtlich geschützt. Mit der Nutzung dieser Software
+erkennst du die folgenden Bedingungen an.
+
+1. Erlaubte Nutzung
+ Die Nutzung von DeskCore ist ausschließlich für den privaten, nicht-kommerziellen
+ Gebrauch gestattet.
+
+2. Untersagt ist insbesondere
+ - Die Weitergabe dieser Software oder von Teilen davon an Dritte
+ - Das Verändern, Dekompilieren, Zurückentwickeln (Reverse Engineering) oder
+ Bearbeiten des Codes
+ - Die Veröffentlichung – auch in veränderter oder abgeleiteter Form –
+ an anderer Stelle als den offiziellen Kanälen des Autors
+ - Der Verkauf sowie jede sonstige kommerzielle Nutzung
+
+3. Keine Gewährleistung
+ Die Software wird ohne jegliche Gewährleistung bereitgestellt, weder
+ ausdrücklich noch stillschweigend, einschließlich, aber nicht beschränkt auf
+ die Gewährleistung der Marktgängigkeit, der Eignung für einen bestimmten
+ Zweck und der Nichtverletzung von Rechten Dritter. Der Autor haftet nicht für
+ Schäden, die aus der Nutzung oder Unmöglichkeit der Nutzung der Software
+ entstehen.
+
+4. Rechtsfolgen bei Verstoß
+ Wer gegen diese Bedingungen verstößt, macht sich strafbar und kann rechtlich
+ belangt werden. Alle Rechte, die hier nicht ausdrücklich eingeräumt werden,
+ bleiben beim Autor vorbehalten.
+
+5. Markenhinweis
+ DeskCore ist kein offizielles Produkt von Microsoft. Windows ist
+ eine Marke der Microsoft Corporation.
+
+Kontakt: https://m-viper.de
diff --git a/bin/Release/net9.0-windows/win-x64/DeskCore.deps.json b/bin/Release/net9.0-windows/win-x64/DeskCore.deps.json
new file mode 100644
index 0000000..2847f67
--- /dev/null
+++ b/bin/Release/net9.0-windows/win-x64/DeskCore.deps.json
@@ -0,0 +1,24 @@
+{
+ "runtimeTarget": {
+ "name": ".NETCoreApp,Version=v9.0/win-x64",
+ "signature": ""
+ },
+ "compilationOptions": {},
+ "targets": {
+ ".NETCoreApp,Version=v9.0": {},
+ ".NETCoreApp,Version=v9.0/win-x64": {
+ "DeskCore/1.0.2": {
+ "runtime": {
+ "DeskCore.dll": {}
+ }
+ }
+ }
+ },
+ "libraries": {
+ "DeskCore/1.0.2": {
+ "type": "project",
+ "serviceable": false,
+ "sha512": ""
+ }
+ }
+}
\ No newline at end of file
diff --git a/bin/Release/net9.0-windows/win-x64/DeskCore.dll b/bin/Release/net9.0-windows/win-x64/DeskCore.dll
new file mode 100644
index 0000000..9dfce12
Binary files /dev/null and b/bin/Release/net9.0-windows/win-x64/DeskCore.dll differ