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