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 - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +