diff --git a/App.xaml.cs b/App.xaml.cs index ca0aff3..916da43 100644 --- a/App.xaml.cs +++ b/App.xaml.cs @@ -14,7 +14,7 @@ public partial class App : Application private static Hotkey? _hotkey; private static Hotkey? _peekHotkey; - private static Hotkey? _pagePrev, _pageNext; + private static Hotkey? _pagePrev, _pageNext, _quickSearch; private static System.Windows.Threading.DispatcherTimer? _desktopTimer; private static Watcher? _watcher; private Mutex? _mutex; @@ -65,6 +65,9 @@ public partial class App : Application _pageNext = new Hotkey(hwnd, 0xB004); _pageNext.Pressed += () => FenceManager.NextPage(1); _pageNext.Register(Hotkey.ModWin | Hotkey.ModAlt, 0x27); + _quickSearch = new Hotkey(hwnd, 0xB005); + _quickSearch.Pressed += QuickSearch.Toggle; + ApplyQuickSearchHotkey(); TrayIcon = new Tray(); TrayIcon.Clicked += () => Win.ShowFromBackground(); @@ -110,6 +113,7 @@ public partial class App : Application { var s = AppSettings.Current; if (Quitting) return; + PageScheduler.Tick(); // 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. Ä. */ } @@ -124,6 +128,7 @@ public partial class App : Application 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.Has(args, "--search")) QuickSearch.Toggle(); else if (Commands.Value(args, "--page") is { } page) SwitchPageByName(page); else if (Commands.WantsWindow(args)) Win.ShowFromBackground(); } @@ -138,6 +143,17 @@ public partial class App : Application else TrayIcon?.Notify("Seite nicht gefunden", $"Es gibt keine Desktop-Seite „{page}“."); } + public static bool QuickSearchRegistered { get; private set; } + + /// Tastenkürzel der Schnellstart-Suche neu registrieren. false = von anderem Programm belegt. + public static bool ApplyQuickSearchHotkey() + { + if (_quickSearch is null) return false; + var s = AppSettings.Current; + if (!s.QuickSearchEnabled) { _quickSearch.Unregister(); return QuickSearchRegistered = true; } + return QuickSearchRegistered = _quickSearch.Register(s.QuickSearchModifiers, s.QuickSearchKey); + } + /// Tastenkürzel neu registrieren. false = von anderem Programm belegt. public static bool ApplyHotkey() { @@ -172,6 +188,8 @@ public partial class App : Application DesktopToggle.RestoreOnExit(); _peekHotkey?.Dispose(); _pagePrev?.Dispose(); + _quickSearch?.Dispose(); + Usage.Flush(); _pageNext?.Dispose(); Win?.Undo.FlushAll(); FenceManager.CloseAll(); diff --git a/BoxPicker.cs b/BoxPicker.cs new file mode 100644 index 0000000..01b2284 --- /dev/null +++ b/BoxPicker.cs @@ -0,0 +1,190 @@ +using System.Runtime.InteropServices; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Input; +using System.Windows.Interop; +using System.Windows.Media; + +namespace DeskCore; + +/// +/// Auswahl nach dem Aufziehen einer Box: Welche Kategorie (oder welcher Ordner) soll hinein? +/// Ein eigenes Fenster statt eines Kontextmenüs – ein Menü ohne aktives Fenster schließt sich sonst gelegentlich von selbst. +/// +public sealed class BoxPicker : Window +{ + private static BoxPicker? _open; + private readonly long _shownAt = Environment.TickCount64; + private bool _done; + + private static AppSettings S => AppSettings.Current; + + /// Fläche der neuen Box in WPF-Einheiten. + /// Legt die Box mit dem gewählten Tab an. + public static void Show(Rect area, Action create) + { + _open?.Finish(null); + _open = new BoxPicker(area, create); + _open.Show(); + } + + private readonly Action _create; + + private BoxPicker(Rect area, Action create) + { + _create = create; + WindowStyle = WindowStyle.None; + AllowsTransparency = true; + Background = Brushes.Transparent; + ResizeMode = ResizeMode.NoResize; + ShowInTaskbar = false; + Topmost = true; + SizeToContent = SizeToContent.WidthAndHeight; + Title = "Neue Box"; + + var res = Application.Current.Resources; + var list = new StackPanel(); + + list.Children.Add(new TextBlock + { + Text = "Neue Box hier", FontSize = 14, FontWeight = FontWeights.SemiBold, Margin = new Thickness(12, 8, 12, 0), + Foreground = (Brush)res["TextBrush"], + }); + list.Children.Add(new TextBlock + { + Text = "Was soll hinein?", FontSize = 12, Margin = new Thickness(12, 2, 12, 6), + Foreground = (Brush)res["TextSecondaryBrush"], + }); + + var cats = Storage.Categories(); + var catPanel = new StackPanel(); + foreach (var c in cats) + { + var meta = S.Categories.GetValueOrDefault(c); + // Pro Seite steht eine Kategorie nur in einer Box – andere Seiten behalten ihre + string? hint = S.HasFence(c) ? "wird aus ihrer Box auf dieser Seite hierher geholt" : null; + catPanel.Children.Add(Row(meta?.Glyph ?? "", CategoryColors.Brush(meta?.Color), c, hint, + () => Pick(new FenceTab { Value = c }))); + } + if (cats.Count > 0) + { + list.Children.Add(new ScrollViewer + { + Content = catPanel, MaxHeight = 360, VerticalScrollBarVisibility = ScrollBarVisibility.Auto, + }); + list.Children.Add(Divider()); + } + list.Children.Add(Row("", null, "Zuletzt benutzt", "zeigt, was du zuletzt gestartet hast", + () => Pick(new FenceTab { Kind = FenceTab.RecentKind }))); + list.Children.Add(Row("", null, "Neue Kategorie …", null, () => PickLater(() => + App.Win?.CreateCategoryInteractive() is { } name ? new FenceTab { Value = name } : null))); + list.Children.Add(Row("", null, "Ordner-Portal …", null, () => PickLater(() => + FenceManager.PickFolder() is { } folder ? new FenceTab { Kind = FenceTab.FolderKind, Value = folder } : null))); + list.Children.Add(Divider()); + list.Children.Add(Row("", null, "Abbrechen", null, () => Finish(null))); + + Content = new Border + { + Margin = new Thickness(14), Padding = new Thickness(4), MinWidth = 240, CornerRadius = new CornerRadius(8), + Background = (Brush)res["FlyoutBrush"], BorderBrush = (Brush)res["FlyoutStrokeBrush"], BorderThickness = new Thickness(1), + Effect = (System.Windows.Media.Effects.Effect)res["FlyoutShadow"], + Child = list, + }; + + // Neben dem Mauszeiger öffnen, aber immer ganz auf dem Bildschirm + Loaded += (_, _) => + { + var mouse = GetCursorPos(out var p) ? new Point(p.X / Screens.Scale, p.Y / Screens.Scale) : area.BottomRight; + var wa = Screens.WorkAreaAt(mouse); + Left = Math.Clamp(mouse.X - 14, wa.Left, Math.Max(wa.Left, wa.Right - ActualWidth)); + Top = Math.Clamp(mouse.Y - 14, wa.Top, Math.Max(wa.Top, wa.Bottom - ActualHeight)); + BringToFront(); + }; + KeyDown += (_, e) => { if (e.Key == Key.Escape) Finish(null); }; + // Klick daneben schließt – aber nicht durch ein kurzes Flackern direkt nach dem Öffnen + Deactivated += (_, _) => + { + if (_done) return; + if (Environment.TickCount64 - _shownAt < 400) BringToFront(); + else Finish(null); + }; + Closed += (_, _) => { if (_open == this) _open = null; DrawBoxOverlay.Hide(); }; + } + + private Button Row(string glyph, Brush? glyphBrush, string text, string? hint, Action click) + { + var res = Application.Current.Resources; + var content = new DockPanel(); + var icon = new TextBlock + { + Text = glyph, FontFamily = (FontFamily)res["IconFont"], FontSize = 15, Width = 20, Margin = new Thickness(0, 0, 12, 0), + VerticalAlignment = VerticalAlignment.Center, Foreground = glyphBrush ?? (Brush)res["TextBrush"], + }; + DockPanel.SetDock(icon, Dock.Left); + content.Children.Add(icon); + var texts = new StackPanel { VerticalAlignment = VerticalAlignment.Center }; + texts.Children.Add(new TextBlock { Text = text, Foreground = (Brush)res["TextBrush"] }); + if (hint is not null) + texts.Children.Add(new TextBlock { Text = hint, FontSize = 11, Foreground = (Brush)res["TextTertiaryBrush"] }); + content.Children.Add(texts); + + var b = new Button + { + Style = (Style)res["SubtleButton"], Content = content, HorizontalContentAlignment = HorizontalAlignment.Stretch, + Padding = new Thickness(10, 7, 10, 7), Focusable = true, + }; + b.Click += (_, _) => click(); + return b; + } + + private static Border Divider() => new() + { + Height = 1, Margin = new Thickness(4, 4, 4, 4), Background = (Brush)Application.Current.Resources["ControlStrokeBrush"], + }; + + private void Pick(FenceTab tab) => Finish(tab); + + /// Für Auswahlen mit eigenem Dialog: erst die Auswahl schließen, Rahmen aber stehen lassen. + private void PickLater(Func ask) + { + _done = true; + Hide(); + var tab = ask(); + if (tab is not null) _create(tab); + Close(); + } + + private void Finish(FenceTab? tab) + { + if (_done) return; + _done = true; + Close(); + if (tab is not null) _create(tab); + } + + /// + /// DeskCore läuft im Hintergrund – Windows lässt es nicht ohne Weiteres nach vorne. Ein kurzer Alt-Tastendruck + /// gibt die Erlaubnis (bewährter Weg, der auch von Windows selbst so akzeptiert wird). + /// + private void BringToFront() + { + var hwnd = new WindowInteropHelper(this).Handle; + if (GetForegroundWindow() != hwnd) + { + // Loslassen erst nach dem Wechsel – so landet es bei uns und öffnet nirgends ein Menü + keybd_event(VK_MENU, 0, 0, UIntPtr.Zero); + SetForegroundWindow(hwnd); + keybd_event(VK_MENU, 0, KEYEVENTF_KEYUP, UIntPtr.Zero); + } + Activate(); + Focus(); + } + + private const byte VK_MENU = 0x12; + private const uint KEYEVENTF_KEYUP = 0x2; + [StructLayout(LayoutKind.Sequential)] private struct POINT { public int X, Y; } + [DllImport("user32.dll")] private static extern bool GetCursorPos(out POINT p); + [DllImport("user32.dll")] private static extern IntPtr GetForegroundWindow(); + [DllImport("user32.dll")] private static extern bool SetForegroundWindow(IntPtr hwnd); + [DllImport("user32.dll")] private static extern void keybd_event(byte vk, byte scan, uint flags, UIntPtr extra); +} diff --git a/DesktopIcons.cs b/DesktopIcons.cs index f8b1c36..add5c64 100644 --- a/DesktopIcons.cs +++ b/DesktopIcons.cs @@ -81,10 +81,11 @@ public static class DesktopIcons return changed; } - public static bool HitsIcon(int x, int y) => Cached.Any(i => + /// Zusätzlicher Rand in Pixeln, damit Klicks knapp neben Bild oder Beschriftung auch zählen. + public static bool HitsIcon(int x, int y, int margin = 0) => Cached.Any(i => { var (l, t, w, h) = CellRect(i); - return x >= l && x < l + w && y >= t && y < t + h; + return x >= l - margin && x < l + w + margin && y >= t - margin && y < t + h + margin; }); // ---------- Desktop-Ansicht finden ---------- diff --git a/DesktopOverlays.cs b/DesktopOverlays.cs index 350ffc0..2f609ea 100644 --- a/DesktopOverlays.cs +++ b/DesktopOverlays.cs @@ -1,261 +1,263 @@ -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(); - } -} +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) + { + 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) { Hide(); return; } // zu klein – war wohl nur ein Versehen + Update(px); // Rahmen bleibt stehen, bis etwas ausgewählt ist + FenceManager.ShowCreateMenu(dip); + } + + public static void Hide() => _w?.Hide(); +} + +// ========================================================================= +// 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 index eb20e1b..2dacdb5 100644 --- a/DesktopToggle.cs +++ b/DesktopToggle.cs @@ -68,7 +68,10 @@ public static class DesktopToggle { _lastDownTick = 0; var pt = info.pt; - dispatcher?.BeginInvoke(() => OnDoubleClick(pt)); + var first = _lastDownPt; + // Schon im Hook (ohne COM) aussortieren: Doppelklick auf ein Icon = Programmstart + if (!DesktopIcons.HitsIcon(pt.x, pt.y, margin: 8) && !DesktopIcons.HitsIcon(first.x, first.y, margin: 8)) + dispatcher?.BeginInvoke(() => OnDoubleClick(pt, first)); } else { @@ -144,9 +147,15 @@ public static class DesktopToggle SendInput((uint)inputs.Length, inputs, Marshal.SizeOf()); } - private static void OnDoubleClick(POINT pt) + private static void OnDoubleClick(POINT pt, POINT first) { - if (!IsEmptyDesktopSpot(pt)) return; + if (!IsEmptyDesktopSpot(pt) || !IsEmptyDesktopSpot(first)) return; + // Positionen frisch lesen – der Zwischenspeicher kann ein paar Sekunden alt sein (Icon gerade verschoben) + if (IconsVisible()) + { + try { DesktopIcons.RefreshCache(); } catch { return; } + if (DesktopIcons.HitsIcon(pt.x, pt.y, margin: 8) || DesktopIcons.HitsIcon(first.x, first.y, margin: 8)) return; + } Toggle(); } diff --git a/Directory.Build.props b/Directory.Build.props index 35b1e28..478fe53 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,7 +1,7 @@  - 1.0.2 + 1.0.4 false diff --git a/FenceManager.cs b/FenceManager.cs index 4a22b90..9bb46bd 100644 --- a/FenceManager.cs +++ b/FenceManager.cs @@ -73,14 +73,30 @@ public static class FenceManager 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) + /// + /// Kategorie aus den anderen Boxen derselben Seite entfernen – pro Seite steht eine Kategorie nur in einer Box. + /// Auf anderen Seiten darf sie zusätzlich liegen (z. B. „Internet“ auf „Arbeit“ und auf „Freizeit“). + /// + public static void RemoveCategoryTab(string category, FenceDef? except, int page) { - foreach (var f in S.Fences.Where(f => f != except)) - f.Tabs.RemoveAll(t => !t.IsFolder && string.Equals(t.Value, category, StringComparison.OrdinalIgnoreCase)); + foreach (var f in S.Fences.Where(f => f != except && f.Page == page)) + f.Tabs.RemoveAll(t => t.IsCategory && string.Equals(t.Value, category, StringComparison.OrdinalIgnoreCase)); S.Fences.RemoveAll(f => f != except && f.Tabs.Count == 0); } + /// Nach Verschieben/Zusammenlegen: doppelte Kategorien auf einer Seite auflösen – „keep“ behält seine. + private static void ResolveDuplicates(int page, FenceDef? keep) + { + if (keep is not null) + foreach (var t in keep.Tabs.Where(t => t.IsCategory).ToList()) + RemoveCategoryTab(t.Value, keep, page); + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var f in S.Fences.Where(f => f.Page == page)) + f.Tabs.RemoveAll(t => t.IsCategory && !seen.Add(t.Value)); + S.Fences.RemoveAll(f => f.Tabs.Count == 0); + foreach (var f in S.Fences) f.ActiveTab = Math.Clamp(f.ActiveTab, 0, Math.Max(0, f.Tabs.Count - 1)); + } + /// Tab als eigene Box daneben lösen. public static void DetachTab(FenceDef def, int index) { @@ -117,18 +133,12 @@ public static class FenceManager 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. + /// Nach dem Aufziehen: Auswahl "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) + BoxPicker.Show(area, tab => { - if (!tab.IsFolder) RemoveCategoryTab(tab.Value, except: null); + if (tab.IsCategory) RemoveCategoryTab(tab.Value, except: null, S.CurrentPage); S.Fences.Add(new FenceDef { Page = S.CurrentPage, Tabs = [tab], @@ -138,35 +148,9 @@ public static class FenceManager 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 // ===================================================================== @@ -186,6 +170,7 @@ public static class FenceManager S.CurrentPage = index; S.Save(); if (changed) Sync(); + PageScheduler.ApplyWallpaper(index); PageIndicator.Show(S.Pages[index], index, S.Pages.Count); App.Win?.RefreshFromFences(); } @@ -193,8 +178,24 @@ public static class FenceManager public static void MoveToPage(FenceDef def, int page) { def.Page = Math.Clamp(page, 0, S.Pages.Count - 1); + ResolveDuplicates(def.Page, keep: def); S.Save(); Sync(); + App.Win?.RefreshFromFences(); + } + + /// Box mit gleichem Inhalt und Aussehen zusätzlich auf eine andere Seite legen. + public static void CopyToPage(FenceDef def, int page) + { + var copy = AppSettings.CloneFences([def])[0]; + copy.Id = Guid.NewGuid().ToString("N"); + copy.Page = Math.Clamp(page, 0, S.Pages.Count - 1); + S.Fences.Add(copy); + ResolveDuplicates(copy.Page, keep: copy); + S.Save(); + Sync(); + App.Win?.RefreshFromFences(); + App.TrayIcon?.Notify("Box kopiert", $"Die Box liegt jetzt auch auf „{S.Pages[copy.Page]}“ – an derselben Stelle."); } /// Seite löschen: ihre Boxen wandern auf die Seite davor. @@ -205,6 +206,8 @@ public static class FenceManager foreach (var f in S.Fences) if (f.Page == index) f.Page = Math.Max(0, index - 1); else if (f.Page > index) f.Page--; + // Kategorien, die es auf beiden Seiten gab, nicht doppelt zeigen + ResolveDuplicates(Math.Max(0, index - 1), keep: null); if (S.CurrentPage >= index && S.CurrentPage > 0) S.CurrentPage--; S.Save(); CloseAll(); @@ -267,10 +270,16 @@ public static class FenceManager v => def.IconSize = Enum.Parse(v)), }); panel.Children.Add(Label("Sortieren nach")); + panel.Children.Add(new TextBlock + { + Text = "„Eigene“: Icons in der Box einfach an die gewünschte Stelle ziehen.", FontSize = 12, Margin = new Thickness(0, -4, 0, 8), + TextWrapping = TextWrapping.Wrap, Foreground = (Brush)res["TextSecondaryBrush"], + }); 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), + Child = Segments("sort", [("Name", "Name"), ("Typ", "Typ"), ("Datum", "Datum"), ("Meistgenutzt", "Häufig"), ("Eigene", "Eigene")], + def.Sort, v => def.Sort = v), }); // Icon-Tönung diff --git a/FenceWindow.xaml b/FenceWindow.xaml index 4051720..2324616 100644 --- a/FenceWindow.xaml +++ b/FenceWindow.xaml @@ -53,7 +53,7 @@ @@ -84,7 +84,11 @@ + Foreground="{DynamicResource TextTertiaryBrush}" IsHitTestVisible="False" Margin="16,0,16,10" + TextWrapping="Wrap" TextAlignment="Center"/> + + diff --git a/FenceWindow.xaml.cs b/FenceWindow.xaml.cs index 3de280a..7b5d15c 100644 --- a/FenceWindow.xaml.cs +++ b/FenceWindow.xaml.cs @@ -55,7 +55,8 @@ public partial class FenceWindow : Window SourceInitialized += (_, _) => PinToDesktop(); MouseEnter += (_, _) => OnHover(true); MouseLeave += (_, _) => OnHover(false); - Closed += (_, _) => _portalWatcher?.Dispose(); + Usage.Changed += OnUsageChanged; + Closed += (_, _) => { _portalWatcher?.Dispose(); Usage.Changed -= OnUsageChanged; }; Refresh(); } @@ -215,29 +216,52 @@ public partial class FenceWindow : Window WatchPortal(portal ? folder : null); List items = []; - if (folder is not null && Directory.Exists(folder)) + if (tab?.IsRecent == true) + items = Usage.Recent(RecentCount).Select(p => new IconItem { Path = p }).ToList(); + else 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); + items = Sort(items, tab); 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.Text = tab?.IsRecent == true ? "Noch nichts gestartet – was du über DeskCore öffnest, erscheint hier" + : 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) + /// Wie viele Einträge eine „Zuletzt benutzt“-Box zeigt. + private const int RecentCount = 24; + + private void OnUsageChanged() { + // Zuletzt-Tab oder Sortierung nach Häufigkeit → neu ordnen (kurz verzögert, falls mehrere Starts kommen) + if (Tab?.IsRecent == true || Def.Sort == "Häufig") Dispatcher.BeginInvoke(Refresh, DispatcherPriority.Background); + } + + private List Sort(List items, FenceTab? tab) + { + // Zuletzt benutzt: neueste zuerst – außer es ist ausdrücklich "Häufig" gewählt + if (tab?.IsRecent == true) + return Def.Sort == "Häufig" ? items.OrderByDescending(i => Usage.Score(i.Path)).ToList() : items; + // Eigene Reihenfolge: wie gezogen; Neues hinten dran (alphabetisch) + if (Def.Sort == "Eigene" && tab is not null) + { + var order = tab.Order; + return items.OrderBy(i => order.FindIndex(n => string.Equals(n, Path.GetFileName(i.Path), StringComparison.OrdinalIgnoreCase)) is var k and >= 0 ? k : int.MaxValue) + .ThenBy(i => i.Name, StringComparer.CurrentCultureIgnoreCase).ToList(); + } 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; } }), + "Häufig" => x.OrderByDescending(i => Usage.Score(i.Path)).ThenBy(i => i.Name, StringComparer.CurrentCultureIgnoreCase), _ => x.OrderBy(i => i.Name, StringComparer.CurrentCultureIgnoreCase), }; return Order(folders).Concat(Order(files)).ToList(); @@ -250,13 +274,13 @@ public partial class FenceWindow : Window { 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 meta = t.IsCategory ? S.Categories.GetValueOrDefault(t.Value) : null; var glyph = new TextBlock { - Text = t.IsFolder ? "" : meta?.Glyph ?? "", + Text = t.IsRecent ? "" : 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"), + Foreground = CategoryColors.Brush(meta?.Color) ?? (Brush)FindResource(t.IsCategory ? "TextBrush" : "AccentBrush"), }; var label = new TextBlock { @@ -275,7 +299,7 @@ public partial class FenceWindow : Window { 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}“", + ToolTip = t.IsRecent ? "Was du zuletzt über DeskCore gestartet hast" : 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); @@ -344,7 +368,7 @@ public partial class FenceWindow : Window Refresh(); return; } - try { Process.Start(new ProcessStartInfo(i.Path) { UseShellExecute = true }); } + try { Usage.Start(i.Path); } catch { } } @@ -383,7 +407,7 @@ public partial class FenceWindow : Window 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; + var ownCategory = Tab is { IsCategory: true } 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)))); @@ -423,18 +447,85 @@ public partial class FenceWindow : Window 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; + if (_dragging) + { + // Innerhalb der eigenen Box: neu anordnen (nicht bei „Zuletzt benutzt“ – die ordnet sich selbst) + e.Effects = Tab is { IsRecent: false } ? DragDropEffects.Copy : DragDropEffects.None; + ShowDropMarker(e.Effects == DragDropEffects.None ? null : e.GetPosition(Items)); + } + else + e.Effects = e.Data.GetDataPresent(DataFormats.FileDrop) && CurrentFolder is not null + ? (e.AllowedEffects.HasFlag(DragDropEffects.Copy) ? DragDropEffects.Copy : DragDropEffects.Move) + : DragDropEffects.None; e.Handled = true; } + private void Items_DragLeave(object sender, DragEventArgs e) => ShowDropMarker(null); + private void Items_Drop(object sender, DragEventArgs e) { - if (!_dragging && CurrentFolder is { } folder && e.Data.GetData(DataFormats.FileDrop) is string[] files) + ShowDropMarker(null); + if (_dragging) + { + if (Tab is { IsRecent: false } t) Reorder(t, Selected, e.GetPosition(Items)); + return; + } + if (CurrentFolder is { } folder && e.Data.GetData(DataFormats.FileDrop) is string[] files) App.Win?.TransferAll(files, folder); } + // ---------- Eigene Reihenfolge ---------- + + /// Einfügestelle an der Mausposition: vor welchem Eintrag landet das Gezogene? (Count = ans Ende) + private (int Index, Rect? Marker) InsertionAt(Point p) + { + var list = Items.ItemsSource as List ?? []; + for (int k = 0; k < list.Count; k++) + { + if (Items.ItemContainerGenerator.ContainerFromIndex(k) is not ListBoxItem c || !c.IsVisible) continue; + var r = new Rect(c.TranslatePoint(new Point(0, 0), Items), c.RenderSize); + if (p.Y < r.Top) return (k, new Rect(r.Left - 2, r.Top, 3, r.Height)); // Zeile darüber, ganz links + if (p.Y <= r.Bottom && p.X < r.Left + r.Width / 2) return (k, new Rect(r.Left - 2, r.Top, 3, r.Height)); + } + if (list.Count > 0 && Items.ItemContainerGenerator.ContainerFromIndex(list.Count - 1) is ListBoxItem last) + { + var r = new Rect(last.TranslatePoint(new Point(0, 0), Items), last.RenderSize); + return (list.Count, new Rect(r.Right - 1, r.Top, 3, r.Height)); + } + return (list.Count, null); + } + + private void ShowDropMarker(Point? p) + { + if (p is { } pos && InsertionAt(pos).Marker is { } m) + { + DropMarker.Margin = new Thickness(m.Left, m.Top, 0, 0); + DropMarker.Height = m.Height; + DropMarker.Visibility = Visibility.Visible; + } + else DropMarker.Visibility = Visibility.Collapsed; + } + + private void Reorder(FenceTab tab, List moved, Point drop) + { + var list = Items.ItemsSource as List ?? []; + if (moved.Count == 0) return; + int index = InsertionAt(drop).Index; + // Einfügestelle in der Liste ohne die gezogenen Einträge + int before = list.Take(index).Count(i => moved.Contains(i)); + var rest = list.Where(i => !moved.Contains(i)).ToList(); + rest.InsertRange(Math.Clamp(index - before, 0, rest.Count), moved); + + tab.Order = rest.Select(i => Path.GetFileName(i.Path)).ToList(); + bool switched = Def.Sort != "Eigene"; + Def.Sort = "Eigene"; + S.Save(); + Refresh(); + foreach (var i in (Items.ItemsSource as List ?? []).Where(i => moved.Any(m => m.Path == i.Path))) + Items.SelectedItems.Add(i); + if (switched) App.TrayIcon?.Notify("Eigene Reihenfolge", "Die Box ist jetzt so sortiert, wie du die Icons ziehst. Ändern unter „Aussehen …“."); + } + // ===================================================================== // Menüs // ===================================================================== @@ -448,15 +539,17 @@ public partial class FenceWindow : Window private void MenuButton_Click(object sender, RoutedEventArgs e) { FenceMenu.Items.Clear(); - if (Tab is { IsFolder: false } t) + if (Tab is { IsCategory: true } 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)))) + foreach (var c in Storage.Categories().Where(c => !Def.Tabs.Any(x => x.IsCategory && string.Equals(x.Value, c, StringComparison.OrdinalIgnoreCase)))) add.Items.Add(MainWindow.MenuItem(c, S.Categories.GetValueOrDefault(c)?.Glyph ?? "", () => AddTab(new FenceTab { Value = c }))); + if (!Def.Tabs.Any(x => x.IsRecent)) + add.Items.Add(MainWindow.MenuItem("Zuletzt benutzt", "", () => AddTab(new FenceTab { Kind = FenceTab.RecentKind }))); if (add.Items.Count > 0) add.Items.Add(new Separator()); add.Items.Add(MainWindow.MenuItem("Ordner-Portal…", "", () => { @@ -475,14 +568,19 @@ public partial class FenceWindow : Window if (S.Pages.Count > 1) { var move = MainWindow.MenuItem("Auf Seite verschieben", "\uE8AB", null); + var copy = MainWindow.MenuItem("Auf Seite kopieren", "\uE8C8", 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); + var ci = MainWindow.MenuItem(S.Pages[p], "\uE8A1", () => FenceManager.CopyToPage(Def, page)); + ci.IsEnabled = page != Def.Page; + copy.Items.Add(ci); } FenceMenu.Items.Add(move); + FenceMenu.Items.Add(copy); } FenceMenu.Items.Add(new Separator()); FenceMenu.Items.Add(MainWindow.MenuItem("Box schließen", "", () => FenceManager.Remove(Def))); @@ -495,8 +593,8 @@ public partial class FenceWindow : Window 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 (t.IsCategory) menu.Items.Add(MainWindow.MenuItem("In DeskCore öffnen", "", () => App.Win?.ShowCategory(t.Value))); + else if (t.IsFolder) 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))); @@ -507,8 +605,8 @@ public partial class FenceWindow : Window 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); + // Kategorie steckt auf dieser Seite schon in einer anderen Box? Dann hierher holen statt doppelt anzeigen + if (tab.IsCategory) FenceManager.RemoveCategoryTab(tab.Value, except: Def, Def.Page); Def.Tabs.Add(tab); Def.ActiveTab = Def.Tabs.Count - 1; _portalSub = null; diff --git a/MainWindow.Extras.cs b/MainWindow.Extras.cs index 78198fa..bdc9633 100644 --- a/MainWindow.Extras.cs +++ b/MainWindow.Extras.cs @@ -1,244 +1,249 @@ -using System.Diagnostics; -using System.Windows; -using System.Windows.Threading; - -namespace DeskCore; - -/// Fortschrittsanzeige für lange Vorgänge, Fensterposition merken und Update-Hinweis. -public partial class MainWindow -{ - // ===================================================================== - // Fortschritt mit Abbrechen - // ===================================================================== - - /// Meldet Fortschritt aus dem Hintergrund (threadsicher). - public sealed class ProgressSink - { - private long _done, _total; - public long Done => Interlocked.Read(ref _done); - public long Total => Interlocked.Read(ref _total); - public void Add(long bytes) => Interlocked.Add(ref _done, bytes); - public void SetTotal(long total) => Interlocked.Exchange(ref _total, total); - public void SetDone(long done) => Interlocked.Exchange(ref _done, done); - public volatile string? Detail; - } - - private CancellationTokenSource? _busyCts; - private Task? _busyTask; - - public bool IsBusy => _busyCts is not null; - - /// - /// Führt einen langen Vorgang im Hintergrund aus. Das Fenster bleibt bedienbar; nach kurzer Zeit erscheint - /// eine Fortschrittsanzeige mit Abbrechen-Knopf (bei schnellen Vorgängen gar nicht erst). - /// - private async Task RunWithProgress(string title, string detail, Func> work) - { - var cts = new CancellationTokenSource(); - _busyCts = cts; - var sink = new ProgressSink { Detail = detail }; - var sw = Stopwatch.StartNew(); - - ProgressTitle.Text = title; - ProgressDetail.Text = detail; - ProgressStats.Text = ""; - ProgressFill.Width = 0; - ProgressCancel.IsEnabled = true; - ProgressCancel.Content = "Abbrechen"; - - var timer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(200) }; - timer.Tick += (_, _) => - { - if (ProgressOverlay.Visibility != Visibility.Visible && sw.ElapsedMilliseconds > 500) - ProgressOverlay.Visibility = Visibility.Visible; - if (sink.Detail is { } d) ProgressDetail.Text = d; - long done = sink.Done, total = sink.Total; - if (total > 0) - { - double frac = Math.Min(1, done / (double)total); - ProgressFill.Width = ProgressTrack.ActualWidth * frac; - ProgressStats.Text = $"{Backup.FormatSize(done)} von {Backup.FormatSize(total)}{Remaining(done, total, sw.Elapsed)}"; - } - else if (done > 0) ProgressStats.Text = Backup.FormatSize(done); - }; - timer.Start(); - - var task = Task.Run(() => work(sink, cts.Token)); - _busyTask = task; - try - { - return await task; - } - finally - { - timer.Stop(); - ProgressOverlay.Visibility = Visibility.Collapsed; - _busyCts = null; - _busyTask = null; - cts.Dispose(); - } - } - - private static string Remaining(long done, long total, TimeSpan elapsed) - { - if (done <= 0 || elapsed.TotalSeconds < 2) return ""; - var rest = TimeSpan.FromSeconds((total - done) / (done / elapsed.TotalSeconds)); - return rest.TotalSeconds < 5 ? " · gleich fertig" - : rest.TotalMinutes < 1 ? $" · noch ca. {Math.Ceiling(rest.TotalSeconds / 5) * 5:0} Sek." - : rest.TotalHours < 1 ? $" · noch ca. {Math.Ceiling(rest.TotalMinutes):0} Min." - : $" · noch ca. {rest.TotalHours:0.#} Std."; - } - - private void ProgressCancel_Click(object sender, RoutedEventArgs e) - { - _busyCts?.Cancel(); - ProgressCancel.IsEnabled = false; - ProgressCancel.Content = "Wird abgebrochen …"; - } - - /// Beim Beenden: laufenden Vorgang abbrechen und kurz aufs Aufräumen warten. - public void CancelBusyAndWait() - { - if (_busyCts is null) return; - _busyCts.Cancel(); - try { _busyTask?.Wait(TimeSpan.FromSeconds(5)); } catch { } - } - - // ===================================================================== - // Fensterposition merken - // ===================================================================== - - private void RestorePlacement() - { - if (S.WindowLeft is double l && S.WindowTop is double t && S.WindowWidth is double w && S.WindowHeight is double h) - { - var r = new Rect(l, t, Math.Max(MinWidth, w), Math.Max(MinHeight, h)); - // nur übernehmen, wenn das Fenster dort noch erreichbar ist (Monitor evtl. abgesteckt) - if (Screens.IsReachable(r)) - { - WindowStartupLocation = WindowStartupLocation.Manual; - Left = r.Left; - Top = r.Top; - Width = r.Width; - Height = r.Height; - } - } - if (S.WindowMaximized) WindowState = WindowState.Maximized; - } - - public void SavePlacement() - { - if (!IsLoaded) return; // nie angezeigt (Start im Infobereich) → nichts zu merken - var r = WindowState == WindowState.Normal ? new Rect(Left, Top, ActualWidth, ActualHeight) : RestoreBounds; - if (r.IsEmpty || r.Width < 100) return; - S.WindowLeft = r.Left; - S.WindowTop = r.Top; - S.WindowWidth = r.Width; - S.WindowHeight = r.Height; - S.WindowMaximized = WindowState == WindowState.Maximized; - S.Save(); - } - - /// Bildschirm abgesteckt/umgestellt: Fenster zurückholen, falls es nicht mehr erreichbar ist. - public void EnsureOnScreen() - { - if (WindowState != WindowState.Normal) return; - if (Screens.IsReachable(new Rect(Left, Top, ActualWidth, ActualHeight))) return; - var wa = Screens.PrimaryWorkArea; - Width = Math.Min(Width, wa.Width); - Height = Math.Min(Height, wa.Height); - Left = wa.Left + (wa.Width - Width) / 2; - Top = wa.Top + (wa.Height - Height) / 2; - } - - // ===================================================================== - // Updates - // ===================================================================== - - private UpdateInfo? _update; - - /// Beim Start: höchstens einmal am Tag nachsehen, leise im Hintergrund. - public async void CheckForUpdatesOnStartup() - { - if (!S.AutoUpdateCheck) return; - if (S.LastUpdateCheck is { } last && DateTime.Now - last < TimeSpan.FromHours(20)) return; - await Task.Delay(TimeSpan.FromSeconds(8)); // Start nicht ausbremsen - await CheckForUpdates(manual: false); - } - - private async Task CheckForUpdates(bool manual) - { - if (manual) - { - CheckUpdateButton.IsEnabled = false; - UpdateStatusText.Text = "Suche nach Updates …"; - } - try - { - var info = await UpdateChecker.CheckAsync(); - S.LastUpdateCheck = DateTime.Now; - S.Save(); - if (info is null) - { - if (manual) UpdateStatusText.Text = $"Du hast die neueste Version ({UpdateChecker.Current})."; - return; - } - if (!manual && S.SkippedVersion == info.Version.ToString()) return; - - _update = info; - UpdateText.Text = $"DeskCore {info.Version} ist verfügbar – du hast Version {UpdateChecker.Current}."; - UpdateNowButton.Content = info.SetupUrl is not null ? "Jetzt aktualisieren" : "Zur Download-Seite"; - UpdateBar.Visibility = Visibility.Visible; - if (manual) UpdateStatusText.Text = $"Version {info.Version} ist verfügbar – oben auf der Startseite aktualisieren."; - else if (!IsVisible) App.TrayIcon?.Notify("Update verfügbar", $"DeskCore {info.Version} ist verfügbar. Zum Aktualisieren DeskCore öffnen."); - } - catch (Exception ex) - { - if (manual) UpdateStatusText.Text = $"Konnte nicht prüfen: {ex.Message}"; - } - finally - { - if (manual) CheckUpdateButton.IsEnabled = true; - } - } - - private async void CheckUpdate_Click(object sender, RoutedEventArgs e) => await CheckForUpdates(manual: true); - - private void AutoUpdateToggle_Click(object sender, RoutedEventArgs e) - { - S.AutoUpdateCheck = AutoUpdateToggle.IsChecked == true; - S.Save(); - } - - private void UpdateLater_Click(object sender, RoutedEventArgs e) => UpdateBar.Visibility = Visibility.Collapsed; - - private void UpdateSkip_Click(object sender, RoutedEventArgs e) - { - if (_update is not null) - { - S.SkippedVersion = _update.Version.ToString(); - S.Save(); - } - UpdateBar.Visibility = Visibility.Collapsed; - } - - private async void UpdateNow_Click(object sender, RoutedEventArgs e) - { - if (_update is not { } info || IsBusy) return; - if (info.SetupUrl is null) - { - Process.Start(new ProcessStartInfo(info.PageUrl) { UseShellExecute = true }); - return; - } - try - { - var setup = await RunWithProgress($"Update auf {info.Version}", "Neue Version wird heruntergeladen …", - (sink, ct) => UpdateChecker.DownloadSetupAsync(info, (done, total) => { sink.SetTotal(total); sink.SetDone(done); }, ct)); - // Installer still ausführen und DeskCore danach automatisch neu starten - Process.Start(new ProcessStartInfo(setup, "/S /launch") { UseShellExecute = true }); - App.Quit(); - } - catch (OperationCanceledException) { ShowToast("Update abgebrochen", "", info: true); } - catch (Exception ex) { Error("Update fehlgeschlagen", $"{ex.Message}\n\nDu kannst die neue Version auch selbst herunterladen:\n{info.PageUrl}"); } - } -} +using System.Diagnostics; +using System.Windows; +using System.Windows.Threading; + +namespace DeskCore; + +/// Fortschrittsanzeige für lange Vorgänge, Fensterposition merken und Update-Hinweis. +public partial class MainWindow +{ + // ===================================================================== + // Fortschritt mit Abbrechen + // ===================================================================== + + /// Meldet Fortschritt aus dem Hintergrund (threadsicher). + public sealed class ProgressSink + { + private long _done, _total; + public long Done => Interlocked.Read(ref _done); + public long Total => Interlocked.Read(ref _total); + public void Add(long bytes) => Interlocked.Add(ref _done, bytes); + public void SetTotal(long total) => Interlocked.Exchange(ref _total, total); + public void SetDone(long done) => Interlocked.Exchange(ref _done, done); + public volatile string? Detail; + } + + private CancellationTokenSource? _busyCts; + private Task? _busyTask; + + public bool IsBusy => _busyCts is not null; + + /// + /// Führt einen langen Vorgang im Hintergrund aus. Das Fenster bleibt bedienbar; nach kurzer Zeit erscheint + /// eine Fortschrittsanzeige mit Abbrechen-Knopf (bei schnellen Vorgängen gar nicht erst). + /// + private async Task RunWithProgress(string title, string detail, Func> work) + { + var cts = new CancellationTokenSource(); + _busyCts = cts; + var sink = new ProgressSink { Detail = detail }; + var sw = Stopwatch.StartNew(); + + ProgressTitle.Text = title; + ProgressDetail.Text = detail; + ProgressStats.Text = ""; + ProgressFill.Width = 0; + ProgressCancel.IsEnabled = true; + ProgressCancel.Content = "Abbrechen"; + + var timer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(200) }; + timer.Tick += (_, _) => + { + if (ProgressOverlay.Visibility != Visibility.Visible && sw.ElapsedMilliseconds > 500) + ProgressOverlay.Visibility = Visibility.Visible; + if (sink.Detail is { } d) ProgressDetail.Text = d; + long done = sink.Done, total = sink.Total; + if (total > 0) + { + double frac = Math.Min(1, done / (double)total); + ProgressFill.Width = ProgressTrack.ActualWidth * frac; + ProgressStats.Text = $"{Backup.FormatSize(done)} von {Backup.FormatSize(total)}{Remaining(done, total, sw.Elapsed)}"; + } + else if (done > 0) ProgressStats.Text = Backup.FormatSize(done); + }; + timer.Start(); + + var task = Task.Run(() => work(sink, cts.Token)); + _busyTask = task; + try + { + return await task; + } + finally + { + timer.Stop(); + ProgressOverlay.Visibility = Visibility.Collapsed; + _busyCts = null; + _busyTask = null; + cts.Dispose(); + } + } + + private static string Remaining(long done, long total, TimeSpan elapsed) + { + if (done <= 0 || elapsed.TotalSeconds < 2) return ""; + var rest = TimeSpan.FromSeconds((total - done) / (done / elapsed.TotalSeconds)); + return rest.TotalSeconds < 5 ? " · gleich fertig" + : rest.TotalMinutes < 1 ? $" · noch ca. {Math.Ceiling(rest.TotalSeconds / 5) * 5:0} Sek." + : rest.TotalHours < 1 ? $" · noch ca. {Math.Ceiling(rest.TotalMinutes):0} Min." + : $" · noch ca. {rest.TotalHours:0.#} Std."; + } + + private void ProgressCancel_Click(object sender, RoutedEventArgs e) + { + _busyCts?.Cancel(); + ProgressCancel.IsEnabled = false; + ProgressCancel.Content = "Wird abgebrochen …"; + } + + /// Beim Beenden: laufenden Vorgang abbrechen und kurz aufs Aufräumen warten. + public void CancelBusyAndWait() + { + if (_busyCts is null) return; + _busyCts.Cancel(); + try { _busyTask?.Wait(TimeSpan.FromSeconds(5)); } catch { } + } + + // ===================================================================== + // Fensterposition merken + // ===================================================================== + + private void RestorePlacement() + { + if (S.WindowLeft is double l && S.WindowTop is double t && S.WindowWidth is double w && S.WindowHeight is double h) + { + var r = new Rect(l, t, Math.Max(MinWidth, w), Math.Max(MinHeight, h)); + // nur übernehmen, wenn das Fenster dort noch erreichbar ist (Monitor evtl. abgesteckt) + if (Screens.IsReachable(r)) + { + WindowStartupLocation = WindowStartupLocation.Manual; + Left = r.Left; + Top = r.Top; + Width = r.Width; + Height = r.Height; + } + } + if (S.WindowMaximized) WindowState = WindowState.Maximized; + } + + public void SavePlacement() + { + if (!IsLoaded) return; // nie angezeigt (Start im Infobereich) → nichts zu merken + var r = WindowState == WindowState.Normal ? new Rect(Left, Top, ActualWidth, ActualHeight) : RestoreBounds; + if (r.IsEmpty || r.Width < 100) return; + S.WindowLeft = r.Left; + S.WindowTop = r.Top; + S.WindowWidth = r.Width; + S.WindowHeight = r.Height; + S.WindowMaximized = WindowState == WindowState.Maximized; + S.Save(); + } + + /// Bildschirm abgesteckt/umgestellt: Fenster zurückholen, falls es nicht mehr erreichbar ist. + public void EnsureOnScreen() + { + if (WindowState != WindowState.Normal) return; + if (Screens.IsReachable(new Rect(Left, Top, ActualWidth, ActualHeight))) return; + var wa = Screens.PrimaryWorkArea; + Width = Math.Min(Width, wa.Width); + Height = Math.Min(Height, wa.Height); + Left = wa.Left + (wa.Width - Width) / 2; + Top = wa.Top + (wa.Height - Height) / 2; + } + + // ===================================================================== + // Updates + // ===================================================================== + + private UpdateInfo? _update; + + /// Beim Start: höchstens einmal am Tag nachsehen, leise im Hintergrund. + public async void CheckForUpdatesOnStartup() + { + if (!S.AutoUpdateCheck) return; + if (S.LastUpdateCheck is { } last && DateTime.Now - last < TimeSpan.FromHours(20)) return; + await Task.Delay(TimeSpan.FromSeconds(8)); // Start nicht ausbremsen + await CheckForUpdates(manual: false); + } + + private async Task CheckForUpdates(bool manual) + { + if (manual) + { + CheckUpdateButton.IsEnabled = false; + UpdateStatusText.Text = "Suche nach Updates …"; + } + try + { + var info = await UpdateChecker.CheckAsync(); + S.LastUpdateCheck = DateTime.Now; + S.Save(); + if (info is null) + { + if (manual) UpdateStatusText.Text = $"Du hast die neueste Version ({UpdateChecker.Current})."; + return; + } + if (!manual && S.SkippedVersion == info.Version.ToString()) return; + + _update = info; + UpdateText.Text = $"DeskCore {info.Version} ist verfügbar – du hast Version {UpdateChecker.Current}."; + UpdateNowButton.Content = info.SetupUrl is not null ? "Jetzt aktualisieren" : "Zur Download-Seite"; + UpdateBar.Visibility = Visibility.Visible; + if (manual) UpdateStatusText.Text = $"Version {info.Version} ist verfügbar – oben auf der Startseite aktualisieren."; + else if (!IsVisible) App.TrayIcon?.Notify("Update verfügbar", $"DeskCore {info.Version} ist verfügbar. Zum Aktualisieren DeskCore öffnen."); + } + catch (Exception ex) + { + if (manual) UpdateStatusText.Text = $"Konnte nicht prüfen: {ex.Message}"; + } + finally + { + if (manual) CheckUpdateButton.IsEnabled = true; + } + } + + private async void CheckUpdate_Click(object sender, RoutedEventArgs e) => await CheckForUpdates(manual: true); + + private void AutoUpdateToggle_Click(object sender, RoutedEventArgs e) + { + S.AutoUpdateCheck = AutoUpdateToggle.IsChecked == true; + S.Save(); + } + + private void UpdateLater_Click(object sender, RoutedEventArgs e) => UpdateBar.Visibility = Visibility.Collapsed; + + private void UpdateSkip_Click(object sender, RoutedEventArgs e) + { + if (_update is not null) + { + S.SkippedVersion = _update.Version.ToString(); + S.Save(); + } + UpdateBar.Visibility = Visibility.Collapsed; + } + + private async void UpdateNow_Click(object sender, RoutedEventArgs e) + { + if (_update is not { } info || IsBusy) return; + if (info.SetupUrl is null) + { + Process.Start(new ProcessStartInfo(info.PageUrl) { UseShellExecute = true }); + return; + } + try + { + var setup = await RunWithProgress($"Update auf {info.Version}", "Neue Version wird heruntergeladen …", + (sink, ct) => UpdateChecker.DownloadSetupAsync(info, (done, total) => { sink.SetTotal(total); sink.SetDone(done); }, ct)); + // Installer still ausführen und DeskCore danach automatisch neu starten. + // Windows fragt dabei nach Administratorrechten (der Installer kümmert sich auch um die .NET-Laufzeit). + Process.Start(new ProcessStartInfo(setup, "/S /launch") { UseShellExecute = true }); + App.Quit(); + } + catch (OperationCanceledException) { ShowToast("Update abgebrochen", "", info: true); } + catch (System.ComponentModel.Win32Exception ex) when (ex.NativeErrorCode == 1223) + { + ShowToast("Update abgebrochen – bitte die Windows-Abfrage mit „Ja“ bestätigen", "", info: true); + } + catch (Exception ex) { Error("Update fehlgeschlagen", $"{ex.Message}\n\nDu kannst die neue Version auch selbst herunterladen:\n{info.PageUrl}"); } + } +} diff --git a/MainWindow.LaunchGroups.cs b/MainWindow.LaunchGroups.cs index 3977dee..8504a13 100644 --- a/MainWindow.LaunchGroups.cs +++ b/MainWindow.LaunchGroups.cs @@ -62,6 +62,7 @@ public partial class MainWindow ShowToast($"„{g.Name}“ ist noch leer", "", info: true); return; } + Usage.Record(Usage.GroupPrefix + g.Name); // damit die Schnellstart-Suche häufige Gruppen weiter oben zeigt int started = 0; var problems = new List(); var items = g.Items.ToList(); @@ -72,7 +73,7 @@ public partial class MainWindow 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 }); + Usage.Start(p); started++; } catch (Exception ex) { problems.Add($"„{Storage.DisplayName(p)}“: {ex.Message}"); } @@ -206,6 +207,7 @@ public partial class MainWindow if (form is null) return; var before = (g.Name, g.DelaySeconds, g.Color); (g.Name, g.DelaySeconds, g.Color) = form.Value; + Usage.Rename(Usage.GroupPrefix + before.Name, Usage.GroupPrefix + g.Name); S.Save(); var entry = new UndoEntry($"Start-Gruppe „{before.Name}“ bearbeiten"); entry.Add(() => { (g.Name, g.DelaySeconds, g.Color) = before; S.Save(); return null; }); diff --git a/MainWindow.Pages.cs b/MainWindow.Pages.cs index 571e43f..26ac962 100644 --- a/MainWindow.Pages.cs +++ b/MainWindow.Pages.cs @@ -1,3 +1,4 @@ +using System.IO; using System.Windows; using System.Windows.Controls; using System.Windows.Media; @@ -23,6 +24,10 @@ public partial class MainWindow { int boxes = S.Fences.Count(f => f.Page == i); var info = (boxes == 1 ? "1 Box" : $"{boxes} Boxen") + (i == S.CurrentPage ? " · wird gerade angezeigt" : ""); + var o = S.PageOptions.GetValueOrDefault(name); + if (o?.Scheduled == true) info += " · " + PageScheduler.Describe(o); + if (string.Equals(S.PageFallback, name, StringComparison.OrdinalIgnoreCase)) info += " · sonst immer"; + if (o?.Wallpaper is not null) info += " · eigenes Hintergrundbild"; return new PageRow(i, name, info, (Brush)res[i == S.CurrentPage ? "AccentBrush" : "ControlStrongStrokeBrush"]); }).ToList(); @@ -46,7 +51,7 @@ public partial class MainWindow DesktopTintSwatches.Children.Add(rb); } AddSwatch(null, Brushes.Transparent, "Keine Tönung"); - foreach (var (name, hex) in CategoryColors.All.Concat([("Grau", "#8A8A8A"), ("Weiß", "#F5F5F5")])) + foreach (var (name, hex) in CategoryColors.All.Concat([("Weiß", "#F5F5F5")])) AddSwatch(hex, CategoryColors.Brush(hex)!, name); DesktopTintStrength.Value = Math.Round(S.DesktopTintStrength * 100); DesktopTintStrength.IsEnabled = S.DesktopTint is not null; @@ -106,11 +111,134 @@ public partial class MainWindow 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.RenamePage(i, name.Trim()); S.Save(); LoadDesktopSettings(); } + // ---------- Zeitplan & Hintergrundbild ---------- + + private void PageSchedule_Click(object sender, RoutedEventArgs e) + { + if ((sender as FrameworkElement)?.Tag is not int i || i >= S.Pages.Count) return; + var page = S.Pages[i]; + var o = S.PageOption(page); + var res = Application.Current.Resources; + var panel = new StackPanel(); + TextBlock Label(string t, double top = 18) => new() + { + Text = t, Margin = new Thickness(0, top, 0, 8), FontWeight = FontWeights.SemiBold, FontSize = 13, + }; + TextBlock Hint(string t) => new() + { + Text = t, FontSize = 12, TextWrapping = TextWrapping.Wrap, Foreground = (Brush)res["TextSecondaryBrush"], + Margin = new Thickness(0, 4, 0, 0), + }; + + // Zeitplan + var scheduled = new CheckBox { Content = "Diese Seite automatisch anzeigen", IsChecked = o.Scheduled }; + panel.Children.Add(scheduled); + var details = new StackPanel { Margin = new Thickness(28, 0, 0, 0) }; + details.Children.Add(Label("An diesen Tagen", 12)); + var days = new WrapPanel(); + string[] dayNames = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"]; + var dayBoxes = new List(); + for (int d = 0; d < 7; d++) + { + var cb = new CheckBox { Content = dayNames[d], IsChecked = (o.Days & (1 << d)) != 0, Margin = new Thickness(0, 0, 14, 4) }; + dayBoxes.Add(cb); + days.Children.Add(cb); + } + details.Children.Add(days); + details.Children.Add(Label("Von – bis")); + var from = new TextBox { Text = o.From, Width = 76, HorizontalContentAlignment = HorizontalAlignment.Center }; + var to = new TextBox { Text = o.To, Width = 76, HorizontalContentAlignment = HorizontalAlignment.Center }; + details.Children.Add(new StackPanel + { + Orientation = Orientation.Horizontal, + Children = + { + from, + new TextBlock { Text = "bis", Margin = new Thickness(12, 0, 12, 0), VerticalAlignment = VerticalAlignment.Center }, + to, + new TextBlock { Text = "Uhr", Margin = new Thickness(12, 0, 0, 0), VerticalAlignment = VerticalAlignment.Center }, + }, + }); + details.Children.Add(Hint("Über Mitternacht geht auch, z. B. 22:00 bis 06:00.")); + panel.Children.Add(details); + void UpdateEnabled() => details.IsEnabled = scheduled.IsChecked == true; + scheduled.Click += (_, _) => UpdateEnabled(); + UpdateEnabled(); + + var fallback = new CheckBox + { + Content = "Zu allen anderen Zeiten diese Seite anzeigen", Margin = new Thickness(0, 18, 0, 0), + IsChecked = string.Equals(S.PageFallback, page, StringComparison.OrdinalIgnoreCase), + }; + panel.Children.Add(fallback); + panel.Children.Add(Hint("Beispiel: „Arbeit“ Mo–Fr 08:00–17:00 und bei „Freizeit“ diesen Haken setzen.")); + + // Hintergrundbild + panel.Children.Add(Label("Hintergrundbild", 22)); + string? wallpaper = o.Wallpaper; + var wpText = new TextBlock { TextTrimming = TextTrimming.CharacterEllipsis, VerticalAlignment = VerticalAlignment.Center }; + var wpRemove = new Button { Content = "Entfernen", Margin = new Thickness(8, 0, 0, 0) }; + void UpdateWallpaper() + { + wpText.Text = wallpaper is null ? "Keins – der Hintergrund bleibt, wie er ist" : Path.GetFileName(wallpaper); + wpText.ToolTip = wallpaper; + wpRemove.IsEnabled = wallpaper is not null; + } + var wpPick = new Button { Content = "Bild wählen …" }; + wpPick.Click += (_, _) => + { + var dlg = new Microsoft.Win32.OpenFileDialog + { + Title = $"Hintergrundbild für „{page}“", Filter = "Bilder|*.jpg;*.jpeg;*.png;*.bmp;*.gif;*.webp|Alle Dateien|*.*", + }; + if (wallpaper is not null) dlg.InitialDirectory = Path.GetDirectoryName(wallpaper); + if (dlg.ShowDialog() == true) { wallpaper = dlg.FileName; UpdateWallpaper(); } + }; + wpRemove.Click += (_, _) => { wallpaper = null; UpdateWallpaper(); }; + UpdateWallpaper(); + var wpRow = new DockPanel(); + var wpButtons = new StackPanel { Orientation = Orientation.Horizontal, Margin = new Thickness(12, 0, 0, 0), Children = { wpPick, wpRemove } }; + DockPanel.SetDock(wpButtons, Dock.Right); + wpRow.Children.Add(wpButtons); + wpRow.Children.Add(wpText); + panel.Children.Add(wpRow); + panel.Children.Add(Hint("Wechselst du auf eine Seite ohne eigenes Bild, kommt dein ursprünglicher Hintergrund zurück.")); + + string? Validate() + { + if (scheduled.IsChecked != true) return null; + if (!TimeSpan.TryParse(from.Text.Trim(), out var f) || f.TotalHours >= 24 || + !TimeSpan.TryParse(to.Text.Trim(), out var t) || t.TotalHours >= 24) + return "Bitte die Uhrzeiten so eingeben: 08:00"; + if (f == t) return "Start und Ende dürfen nicht gleich sein."; + if (!dayBoxes.Any(b => b.IsChecked == true)) return "Bitte mindestens einen Tag auswählen."; + return null; + } + + if (!Dialog.Show(this, $"Seite „{page}“", panel, "Speichern", "Abbrechen", Validate)) return; + + o.Scheduled = scheduled.IsChecked == true; + o.Days = dayBoxes.Select((b, d) => b.IsChecked == true ? 1 << d : 0).Sum(); + if (TimeSpan.TryParse(from.Text.Trim(), out var ff)) o.From = ff.ToString(@"hh\:mm"); + if (TimeSpan.TryParse(to.Text.Trim(), out var tt)) o.To = tt.ToString(@"hh\:mm"); + if (fallback.IsChecked == true) S.PageFallback = page; + else if (string.Equals(S.PageFallback, page, StringComparison.OrdinalIgnoreCase)) S.PageFallback = null; + bool wallpaperChanged = !string.Equals(o.Wallpaper, wallpaper, StringComparison.OrdinalIgnoreCase); + o.Wallpaper = wallpaper; + S.Save(); + + if (wallpaperChanged && i == S.CurrentPage) PageScheduler.ApplyWallpaper(i); + PageScheduler.Reset(); + PageScheduler.Tick(); + LoadDesktopSettings(); + ShowToast($"Seite „{page}“ gespeichert", ""); + } + private void DeletePage_Click(object sender, RoutedEventArgs e) { if ((sender as FrameworkElement)?.Tag is not int i || i >= S.Pages.Count) return; @@ -126,6 +254,8 @@ public partial class MainWindow "Löschen")) return; FenceManager.DeletePage(i); + S.ForgetPage(name); + S.Save(); DesktopToggle.Apply(); Refresh(); LoadDesktopSettings(); diff --git a/MainWindow.QuickSearch.cs b/MainWindow.QuickSearch.cs new file mode 100644 index 0000000..f5b1a83 --- /dev/null +++ b/MainWindow.QuickSearch.cs @@ -0,0 +1,58 @@ +using System.Windows; +using System.Windows.Input; + +namespace DeskCore; + +/// Einstellungen der Schnellstart-Suche. +public partial class MainWindow +{ + private void LoadQuickSearchSettings() + { + QuickSearchToggle.IsChecked = S.QuickSearchEnabled; + QuickSearchHotkeyBox.Text = Hotkey.Describe(S.QuickSearchModifiers, S.QuickSearchKey); + QuickSearchHotkeyBox.IsEnabled = S.QuickSearchEnabled; + QuickSearchStartMenuBox.IsChecked = S.QuickSearchStartMenu; + UpdateQuickSearchHint(App.QuickSearchRegistered); + } + + private void QuickSearchToggle_Click(object sender, RoutedEventArgs e) + { + S.QuickSearchEnabled = QuickSearchToggle.IsChecked == true; + S.Save(); + App.ApplyQuickSearchHotkey(); + LoadQuickSearchSettings(); + } + + private void QuickSearchStartMenu_Click(object sender, RoutedEventArgs e) + { + S.QuickSearchStartMenu = QuickSearchStartMenuBox.IsChecked == true; + S.Save(); + } + + private void QuickSearchHotkeyBox_PreviewKeyDown(object sender, KeyEventArgs e) + { + var key = e.Key == Key.System ? e.SystemKey : e.Key; + if (key is Key.Tab) return; + e.Handled = true; + if (key is Key.LeftCtrl or Key.RightCtrl or Key.LeftAlt or Key.RightAlt or Key.LeftShift or Key.RightShift + or Key.LWin or Key.RWin or Key.Escape) return; + + int mods = (int)Keyboard.Modifiers; // Alt=1, Strg=2, Umschalt=4, Win=8 – gleiche Bits wie RegisterHotKey + if ((mods & (Hotkey.ModCtrl | Hotkey.ModAlt | Hotkey.ModWin)) == 0) + { + QuickSearchHint.Text = "Bitte mit Strg, Alt oder Win kombinieren."; + return; + } + S.QuickSearchModifiers = mods; + S.QuickSearchKey = KeyInterop.VirtualKeyFromKey(key); + S.Save(); + QuickSearchHotkeyBox.Text = Hotkey.Describe(S.QuickSearchModifiers, S.QuickSearchKey); + UpdateQuickSearchHint(App.ApplyQuickSearchHotkey()); + } + + private void UpdateQuickSearchHint(bool ok) => + QuickSearchHint.Text = !S.QuickSearchEnabled ? "Ausgeschaltet" + : ok ? $"{Hotkey.Describe(S.QuickSearchModifiers, S.QuickSearchKey)} öffnet von überall eine Suchleiste: ein paar Buchstaben tippen, Enter – fertig. " + + "Was du oft startest, steht weiter oben." + : "⚠ Diese Kombination ist schon von einem anderen Programm belegt – bitte ins Feld klicken und eine andere drücken."; +} diff --git a/MainWindow.xaml b/MainWindow.xaml index c2fddb4..9cf1213 100644 --- a/MainWindow.xaml +++ b/MainWindow.xaml @@ -27,7 +27,8 @@ - + + @@ -74,7 +75,7 @@