using System.ComponentModel; using System.Diagnostics; using System.IO; using System.Runtime.InteropServices; using System.Windows; using System.Windows.Controls; using System.Windows.Controls.Primitives; using System.Windows.Input; using System.Windows.Interop; using System.Windows.Media; using System.Windows.Media.Animation; using System.Windows.Threading; namespace DeskCore; public partial class MainWindow : Window { private const string DesktopName = "Desktop"; private const string CategoryDragFormat = "DeskCore.Kategorie"; public UndoManager Undo { get; } = new(); private readonly DispatcherTimer _toastTimer = new() { Interval = TimeSpan.FromSeconds(4) }; private Point _dragStart; private bool _dragging; private bool _syncingSelection; private bool _loadingSettings; private string? _categoryDragName; private static AppSettings S => AppSettings.Current; public MainWindow() { InitializeComponent(); RestorePlacement(); StateChanged += (_, _) => UpdateWindowState(); Closing += OnClosing; PreviewKeyDown += OnPreviewKeyDown; _toastTimer.Tick += (_, _) => { _toastTimer.Stop(); AnimateToast(false); }; Theme.Changed += UpdateThemeButton; Undo.Changed += UpdateUndoButton; UpdateThemeButton(); ApplyView(S.View, reload: false); StoragePathText.Text = $"Kategorien liegen in {Storage.Root}"; var asm = typeof(MainWindow).Assembly; var version = asm.GetName().Version; AboutTitle.Text = $"DeskCore {version?.Major}.{version?.Minor}.{version?.Build}"; AboutCopyright.Text = "© M_Viper · Nur für private, nicht-kommerzielle Nutzung"; Refresh(); } private CategoryItem? Current => (DesktopNav.SelectedItem ?? CategoryList.SelectedItem) as CategoryItem; private bool Searching => SearchBox.Text.Trim().Length > 0; private bool InSettings => SettingsView.Visibility == Visibility.Visible; // ===================================================================== // Fenster, Infobereich, Tastenkürzel // ===================================================================== private void Minimize_Click(object sender, RoutedEventArgs e) => WindowState = WindowState.Minimized; private void Maximize_Click(object sender, RoutedEventArgs e) => WindowState = WindowState == WindowState.Maximized ? WindowState.Normal : WindowState.Maximized; private void Close_Click(object sender, RoutedEventArgs e) => Close(); private void UpdateWindowState() { bool max = WindowState == WindowState.Maximized; // Maximierte Fenster ohne Rahmen ragen sonst über den Bildschirmrand RootGrid.Margin = max ? new Thickness(7) : new Thickness(0); MaxButton.Content = max ? "" : ""; MaxButton.ToolTip = max ? "Verkleinern" : "Maximieren"; } private void OnClosing(object? sender, CancelEventArgs e) { if (App.Quitting) return; SavePlacement(); if (S.TrayEnabled && S.CloseToTray) { e.Cancel = true; Hide(); if (!S.TrayHintShown) { S.TrayHintShown = true; S.Save(); App.TrayIcon?.Notify("DeskCore läuft weiter", "Klick auf das Symbol hier unten öffnet die App wieder. Beenden: Rechtsklick → Beenden."); } return; } e.Cancel = true; App.Quit(); } public void SetDimmed(bool dim) => DimOverlay.Visibility = dim ? Visibility.Visible : Visibility.Collapsed; public void ShowFromBackground() { if (!IsVisible) Show(); if (WindowState == WindowState.Minimized) WindowState = WindowState.Normal; Activate(); Topmost = true; // zuverlässig in den Vordergrund holen Topmost = false; Focus(); } /// Globales Tastenkürzel: öffnen bzw. wieder verstecken. public void ToggleFromHotkey() { if (IsVisible && IsActive && WindowState != WindowState.Minimized) { if (S.TrayEnabled) Hide(); else WindowState = WindowState.Minimized; return; } ShowFromBackground(); ShowBrowse(); SearchBox.Focus(); SearchBox.SelectAll(); } public void ShowCategory(string name) { ShowFromBackground(); ShowBrowse(); SearchBox.Text = ""; Select(name); } /// Menü beim Rechtsklick auf das Infobereich-Symbol. public void ShowTrayMenu() { var menu = new ContextMenu { Placement = PlacementMode.MousePoint }; menu.Items.Add(MenuItem("DeskCore öffnen", "", () => { ShowFromBackground(); ShowBrowse(); }, bold: true)); var cats = Storage.Categories(); if (cats.Count > 0) { menu.Items.Add(new Separator()); foreach (var c in cats.Take(15)) menu.Items.Add(MenuItem(c, S.Categories.GetValueOrDefault(c)?.Glyph ?? "", () => ShowCategory(c))); } menu.Items.Add(new Separator()); if (Undo.CanUndo) menu.Items.Add(MenuItem($"Rückgängig: {Undo.NextDescription}", "", DoUndo)); menu.Items.Add(MenuItem("Einstellungen", "", () => { ShowFromBackground(); ShowSettings(); })); menu.Items.Add(MenuItem("Beenden", "", App.Quit)); // Damit das Menü bei Klick daneben zugeht, muss es im Vordergrund sein menu.Opened += (_, _) => { if (PresentationSource.FromVisual(menu) is HwndSource src) SetForegroundWindow(src.Handle); }; menu.IsOpen = true; } [DllImport("user32.dll")] private static extern bool SetForegroundWindow(IntPtr hwnd); // ---------- Design ---------- private void UpdateThemeButton() => ThemeButton.Content = Theme.IsLight ? "" : ""; // Sonne / Mond private void ThemeButton_Click(object sender, RoutedEventArgs e) { ThemeMenu.Items.Clear(); foreach (var (mode, label, glyph) in new[] { (DesignMode.Light, "Hell", ""), (DesignMode.Dark, "Dunkel", ""), (DesignMode.System, "Wie Windows", ""), }) { var mi = MenuItem(label, glyph, () => SetDesign(mode), bold: S.Design == mode); if (S.Design == mode) mi.InputGestureText = "✓"; ThemeMenu.Items.Add(mi); } ThemeMenu.PlacementTarget = ThemeButton; ThemeMenu.IsOpen = true; } private void SetDesign(DesignMode mode) { S.Design = mode; S.Save(); Theme.Apply(); LoadSettingsView(); } // ===================================================================== // Laden & Anzeigen // ===================================================================== /// Vom Watcher: Dateien haben sich geändert. public void OnStorageChanged() { if (!_dragging && !IsBusy) Refresh(); // während großer Kopien nicht ständig neu laden } private void Refresh() { var selected = Current; var desktop = new CategoryItem { Name = DesktopName, IsDesktop = true, Count = Storage.DesktopEntries().Count() }; var cats = Storage.Categories() .Select(n => new CategoryItem { Name = n, Count = Storage.Entries(Storage.CategoryPath(n)).Count() }) .ToList(); _syncingSelection = true; DesktopNav.ItemsSource = new[] { desktop }; CategoryList.ItemsSource = cats; if (!InSettings) { var match = selected is { IsDesktop: false } ? cats.FirstOrDefault(c => c.Name == selected.Name) : null; if (match is not null) { CategoryList.SelectedItem = match; DesktopNav.SelectedItem = null; } else { DesktopNav.SelectedItem = desktop; CategoryList.SelectedItem = null; } } _syncingSelection = false; NoCategoriesText.Visibility = cats.Count == 0 ? Visibility.Visible : Visibility.Collapsed; LoadIcons(); if (InSettings) LoadSettingsView(); } private void Select(string categoryName) { var c = ((List)CategoryList.ItemsSource).FirstOrDefault(x => string.Equals(x.Name, categoryName, StringComparison.OrdinalIgnoreCase)); if (c is not null) CategoryList.SelectedItem = c; } private void LoadIcons() { if (InSettings) return; var cat = Current; var search = SearchBox.Text.Trim(); var selectedPaths = IconList.SelectedItems.Cast().Select(i => i.Path).ToHashSet(StringComparer.OrdinalIgnoreCase); List items; if (search.Length > 0) { items = Storage.DesktopEntries().Select(p => new IconItem { Path = p, Category = DesktopName }) .Concat(Storage.Categories().SelectMany(c => Storage.Entries(Storage.CategoryPath(c)).Select(p => new IconItem { Path = p, Category = c }))) .Where(i => i.Name.Contains(search, StringComparison.CurrentCultureIgnoreCase)) .ToList(); HeaderText.Text = "Suchergebnisse"; SubText.Text = $"{Plural(items.Count, "Treffer", "Treffer")} für „{search}“"; SetEmpty("", "Keine Treffer", $"Für „{search}“ wurde nichts gefunden."); } else if (cat is null) { items = []; } else { var paths = cat.IsDesktop ? Storage.DesktopEntries() : Storage.Entries(cat.Folder); // Welche Desktop-Icons stecken schon (als Kopie) in einer Kategorie? (Name oder gleiches Verknüpfungsziel) var catEntries = cat.IsDesktop ? Storage.Categories().ToDictionary(c => c, c => Storage.Entries(Storage.CategoryPath(c)).ToList()) : []; items = paths.Select(p => new IconItem { Path = p, SortedInto = cat.IsDesktop ? Storage.FindCategoryOf(p, catEntries) : null, }).ToList(); HeaderText.Text = cat.Name; var done = items.Count(i => i.IsSorted); SubText.Text = Plural(items.Count, "Element", "Elemente") + (done > 0 ? $" · {done} davon gesichert" : ""); if (cat.IsDesktop) SetEmpty("", "Alles aufgeräumt", "Auf deinem Desktop liegt nichts mehr."); else SetEmpty(cat.Glyph, "Noch leer", $"Ziehe Icons vom Desktop hierher oder auf „{cat.Name}“ in der Seitenleiste."); } items = items.OrderBy(i => !Directory.Exists(i.Path)) .ThenBy(i => i.Name, StringComparer.CurrentCultureIgnoreCase).ToList(); var px = IconPixels; foreach (var item in items) IconLoader.Load(item, px); IconList.ItemsSource = items; foreach (var i in items.Where(i => selectedPaths.Contains(i.Path))) IconList.SelectedItems.Add(i); EmptyState.Visibility = items.Count == 0 && (cat is not null || search.Length > 0) ? Visibility.Visible : Visibility.Collapsed; CategoryCommands.Visibility = !Searching && cat is { IsDesktop: false } ? Visibility.Visible : Visibility.Collapsed; InfoBar.Visibility = !Searching && cat is { IsDesktop: true } && items.Any(i => !i.IsSorted) ? Visibility.Visible : Visibility.Collapsed; if (cat is { IsDesktop: false }) FenceToggleText.Text = cat.HasFence ? "Box vom Desktop entfernen" : "Als Box auf den Desktop"; } private void SetEmpty(string glyph, string title, string text) { EmptyGlyph.Text = glyph; EmptyTitle.Text = title; EmptyText.Text = text; } private static string Plural(int n, string one, string many) => $"{n} {(n == 1 ? one : many)}"; private void Nav_SelectionChanged(object sender, SelectionChangedEventArgs e) { if (_syncingSelection) return; var list = (ListBox)sender; if (list.SelectedItem is null) return; // Nur ein Eintrag in allen Listen gleichzeitig ausgewählt _syncingSelection = true; if (list != DesktopNav) DesktopNav.SelectedItem = null; if (list != CategoryList) CategoryList.SelectedItem = null; SettingsNav.SelectedItem = null; _syncingSelection = false; SettingsView.Visibility = Visibility.Collapsed; BrowseView.Visibility = Visibility.Visible; IconList.SelectedItems.Clear(); if (Searching) SearchBox.Text = ""; // löst LoadIcons aus else LoadIcons(); } private void SettingsNav_SelectionChanged(object sender, SelectionChangedEventArgs e) { if (_syncingSelection || SettingsNav.SelectedItem is null) return; ShowSettings(); } private void ShowSettings() { _syncingSelection = true; DesktopNav.SelectedItem = null; CategoryList.SelectedItem = null; SettingsNav.SelectedIndex = 0; _syncingSelection = false; BrowseView.Visibility = Visibility.Collapsed; SettingsView.Visibility = Visibility.Visible; LoadSettingsView(); // erst nach dem Layout nach oben scrollen, sonst springt die Seite wieder zurück Dispatcher.BeginInvoke(SettingsView.ScrollToTop, DispatcherPriority.ContextIdle); } private void ShowBrowse() { if (!InSettings) return; _syncingSelection = true; SettingsNav.SelectedItem = null; DesktopNav.SelectedIndex = 0; _syncingSelection = false; SettingsView.Visibility = Visibility.Collapsed; BrowseView.Visibility = Visibility.Visible; LoadIcons(); } private void SearchBox_TextChanged(object sender, TextChangedEventArgs e) { SearchPlaceholder.Visibility = SearchBox.Text.Length == 0 ? Visibility.Visible : Visibility.Collapsed; ClearSearchBtn.Visibility = SearchBox.Text.Length > 0 ? Visibility.Visible : Visibility.Collapsed; LoadIcons(); } private void ClearSearch_Click(object sender, RoutedEventArgs e) { SearchBox.Text = ""; SearchBox.Focus(); } private void Refresh_Click(object sender, RoutedEventArgs e) => Refresh(); private void OpenFolder_Click(object sender, RoutedEventArgs e) { if (Current is { } cat) Process.Start("explorer.exe", $"\"{cat.Folder}\""); } // ---------- Ansicht ---------- private int IconPixels => (int)Math.Round(S.View switch { ViewMode.Small => 32, ViewMode.Large => 72, ViewMode.List => 24, _ => 48, } * VisualTreeHelper.GetDpi(this).DpiScaleX); private void ApplyView(ViewMode mode, bool reload = true) { S.View = mode; var (w, h, icon, top) = mode switch { ViewMode.Small => (88.0, 92.0, 32.0, 10.0), ViewMode.Large => (136.0, 150.0, 72.0, 14.0), _ => (108.0, 116.0, 48.0, 14.0), }; Resources["TileWidth"] = w; Resources["TileHeight"] = h; Resources["TileIcon"] = icon; Resources["TileIconMargin"] = new Thickness(0, top, 0, 0); bool list = mode == ViewMode.List; IconList.ItemTemplate = (DataTemplate)FindResource(list ? "ListTemplate" : "TileTemplate"); IconList.ItemsPanel = (ItemsPanelTemplate)FindResource(list ? "StackPanelTemplate" : "WrapPanelTemplate"); IconList.ItemContainerStyle = (Style)FindResource(list ? "RowItem" : "TileItem"); ListHeader.Visibility = list ? Visibility.Visible : Visibility.Collapsed; ViewGlyph.Text = mode switch { ViewMode.Small => "", ViewMode.List => "", _ => "" }; if (reload) LoadIcons(); } private void ViewButton_Click(object sender, RoutedEventArgs e) { ViewMenu.Items.Clear(); foreach (var (mode, label, glyph) in new[] { (ViewMode.Small, "Kleine Symbole", ""), (ViewMode.Medium, "Mittelgroße Symbole", ""), (ViewMode.Large, "Große Symbole", ""), (ViewMode.List, "Liste", ""), }) { var mi = MenuItem(label, glyph, () => { ApplyView(mode); S.Save(); }, bold: S.View == mode); if (S.View == mode) mi.InputGestureText = "✓"; ViewMenu.Items.Add(mi); } ViewMenu.PlacementTarget = ViewButton; ViewMenu.IsOpen = true; } // ===================================================================== // Kategorien // ===================================================================== private record CategoryForm(string Name, string? Glyph, string? Color, bool Fence); /// Dialog zum Anlegen/Bearbeiten einer Kategorie. private CategoryForm? EditCategoryDialog(string title, string primary, string? current) { var meta = current is null ? null : S.Categories.GetValueOrDefault(current); var res = Application.Current.Resources; var panel = new StackPanel(); TextBlock Label(string t) => new() { Text = t, Margin = new Thickness(0, 16, 0, 8), FontWeight = FontWeights.SemiBold, FontSize = 13, }; var nameBox = new TextBox { Text = current ?? "" }; panel.Children.Add(new TextBlock { Text = "Name", Margin = new Thickness(0, 0, 0, 8), FontWeight = FontWeights.SemiBold, FontSize = 13 }); panel.Children.Add(nameBox); string? color = meta?.Color; var glyphButtons = new List(); void PaintGlyphs() { var b = CategoryColors.Brush(color) ?? (Brush)res["TextBrush"]; foreach (var g in glyphButtons) g.Foreground = b; } // Symbol string? glyph = meta?.Glyph ?? ""; panel.Children.Add(Label("Symbol")); var glyphs = new WrapPanel(); foreach (var (g, name) in CategoryGlyphs.All) { var rb = new RadioButton { Content = g, ToolTip = name, GroupName = "glyph", Style = (Style)res["GlyphRadio"], IsChecked = g == glyph, }; rb.Checked += (_, _) => glyph = g; glyphButtons.Add(rb); glyphs.Children.Add(rb); } panel.Children.Add(glyphs); // Farbe panel.Children.Add(Label("Farbe")); var swatches = new WrapPanel(); var none = new RadioButton { Style = (Style)res["SwatchRadio"], GroupName = "color", ToolTip = "Keine Farbe", Background = Brushes.Transparent, IsChecked = color is null, Content = new TextBlock { Text = "", FontFamily = (FontFamily)res["IconFont"], FontSize = 10, Foreground = (Brush)res["TextSecondaryBrush"] }, }; none.Checked += (_, _) => { color = null; PaintGlyphs(); }; swatches.Children.Add(none); foreach (var (cname, hex) in CategoryColors.All) { var rb = new RadioButton { Style = (Style)res["SwatchRadio"], GroupName = "color", ToolTip = cname, Background = CategoryColors.Brush(hex), IsChecked = string.Equals(color, hex, StringComparison.OrdinalIgnoreCase), }; rb.Checked += (_, _) => { color = hex; PaintGlyphs(); }; swatches.Children.Add(rb); } panel.Children.Add(swatches); PaintGlyphs(); var fence = new CheckBox { Content = "Als Box auf dem Desktop anzeigen", IsChecked = meta?.Fence == true, Margin = new Thickness(0, 14, 0, 0), }; panel.Children.Add(fence); string? Validate() { if (Storage.ValidateName(nameBox.Text) is { } err) return err; var name = nameBox.Text.Trim(); bool same = string.Equals(name, current, StringComparison.OrdinalIgnoreCase); return Directory.Exists(Storage.CategoryPath(name)) && !same ? "Eine Kategorie mit diesem Namen gibt es schon." : null; } if (!Dialog.Show(this, title, panel, primary, "Abbrechen", Validate, nameBox, width: 470)) return null; return new CategoryForm(nameBox.Text.Trim(), glyph == "" ? null : glyph, color, fence.IsChecked == true); } private string? NewCategory() { var form = EditCategoryDialog("Neue Kategorie", "Erstellen", null); if (form is null) return null; var path = Storage.CategoryPath(form.Name); Directory.CreateDirectory(path); var m = S.Meta(form.Name); m.Glyph = form.Glyph; m.Color = form.Color; m.Fence = form.Fence; if (form.Fence) S.FencesEnabled = true; if (!S.CategoryOrder.Contains(form.Name, StringComparer.OrdinalIgnoreCase)) S.CategoryOrder.Add(form.Name); S.Save(); var entry = new UndoEntry($"Kategorie „{form.Name}“ erstellen"); entry.Add(() => { if (Directory.Exists(path) && !Directory.EnumerateFileSystemEntries(path).Any()) Directory.Delete(path); S.RemoveCategory(form.Name); S.Save(); return null; }); Undo.Push(entry); Refresh(); FenceManager.Sync(); return form.Name; } private void NewCategory_Click(object sender, RoutedEventArgs e) { if (NewCategory() is { } name) { ShowBrowse(); Select(name); ShowToast($"Kategorie „{name}“ erstellt", undoable: true); } } private void EditCategory_Click(object sender, RoutedEventArgs e) { if (Current is { IsDesktop: false } cat) EditCategory(cat.Name); } private void EditCategory(string oldName) { var form = EditCategoryDialog("Kategorie bearbeiten", "Speichern", oldName); if (form is null) return; var old = S.Meta(oldName); var before = (old.Glyph, old.Color, old.Fence); var entry = new UndoEntry($"Kategorie „{oldName}“ bearbeiten"); if (form.Name != oldName) { try { Storage.Rename(Storage.CategoryPath(oldName), form.Name); } catch (Exception ex) { Error("Umbenennen fehlgeschlagen", ex.Message); return; } S.RenameCategory(oldName, form.Name); FenceManager.Renamed(oldName, form.Name); var newName = form.Name; entry.Add(() => { Storage.Rename(Storage.CategoryPath(newName), oldName); S.RenameCategory(newName, oldName); FenceManager.Renamed(newName, oldName); return null; }); } var m = S.Meta(form.Name); m.Glyph = form.Glyph; m.Color = form.Color; m.Fence = form.Fence; if (form.Fence) S.FencesEnabled = true; var nameNow = form.Name; entry.Add(() => { var mm = S.Meta(nameNow); (mm.Glyph, mm.Color, mm.Fence) = before; S.Save(); return null; }); S.Save(); Undo.Push(entry); Refresh(); Select(form.Name); FenceManager.Sync(); ShowToast("Kategorie gespeichert", undoable: true); } private void FenceToggle_Click(object sender, RoutedEventArgs e) { if (Current is { IsDesktop: false } cat) ToggleFence(cat.Name); } private void ToggleFence(string name) { var m = S.Meta(name); m.Fence = !m.Fence; if (m.Fence) S.FencesEnabled = true; S.Save(); FenceManager.Sync(); Refresh(); ShowToast(m.Fence ? $"„{name}“ wird jetzt als Box auf dem Desktop angezeigt" : $"Box „{name}“ ausgeblendet", ""); } private void DeleteCategory_Click(object sender, RoutedEventArgs e) { if (Current is { IsDesktop: false } cat) DeleteCategory(cat.Name); } private void DeleteCategory(string name) { var folder = Storage.CategoryPath(name); var entries = Storage.Entries(folder).ToList(); var msg = entries.Count == 0 ? $"Die leere Kategorie „{name}“ wird entfernt." : $"Die {Plural(entries.Count, "Datei", "Dateien")} aus „{name}“ {(entries.Count == 1 ? "wird" : "werden")} zurück auf den Desktop gelegt – " + "außer sie liegen dort schon. Mit Strg+Z kannst du das rückgängig machen."; if (!Dialog.Confirm(this, "Kategorie löschen?", msg, "Löschen")) return; var entry = new UndoEntry($"Kategorie „{name}“ löschen"); var errors = new List(); foreach (var p in entries) { var r = Storage.Transfer(p, Storage.UserDesktop); if (r.Status == TransferStatus.Ok) entry.AddMove(p, r.Dest!); else if (r.Status == TransferStatus.Skipped) { // liegt schon auf dem Desktop → Kopie in der Kategorie wird nicht mehr gebraucht try { entry.AddDelete(p, Storage.Delete(p)); } catch (Exception ex) { errors.Add(ex.Message); } } else errors.Add(r.Message!); } if (errors.Count > 0) { Undo.Push(entry); Refresh(); ShowErrors(errors); return; } var meta = S.Categories.GetValueOrDefault(name); var orderIndex = S.CategoryOrder.FindIndex(o => string.Equals(o, name, StringComparison.OrdinalIgnoreCase)); try { Directory.Delete(folder, recursive: true); } // nur noch versteckte Reste wie desktop.ini catch (Exception ex) { Error("Löschen fehlgeschlagen", ex.Message); } S.RemoveCategory(name); S.Save(); entry.Add(() => { Directory.CreateDirectory(folder); if (meta is not null) S.Categories[name] = meta; if (orderIndex >= 0) S.CategoryOrder.Insert(Math.Min(orderIndex, S.CategoryOrder.Count), name); S.Save(); return null; }); Undo.Push(entry); CategoryList.SelectedItem = null; DesktopNav.SelectedIndex = 0; Refresh(); FenceManager.Sync(); ShowToast($"Kategorie „{name}“ gelöscht", "", undoable: true); } private void CategoryList_ContextMenuOpening(object sender, ContextMenuEventArgs e) { if (FindParent(e.OriginalSource as DependencyObject)?.DataContext is not CategoryItem cat) { e.Handled = true; return; } CategoryList.SelectedItem = cat; var names = Storage.Categories(); int idx = names.FindIndex(n => n == cat.Name); CategoryMenu.Items.Clear(); CategoryMenu.Items.Add(MenuItem("Bearbeiten…", "", () => EditCategory(cat.Name))); CategoryMenu.Items.Add(MenuItem(cat.HasFence ? "Box vom Desktop entfernen" : "Als Box auf den Desktop", "", () => ToggleFence(cat.Name))); CategoryMenu.Items.Add(MenuItem("Im Explorer öffnen", "", () => Process.Start("explorer.exe", $"\"{cat.Folder}\""))); CategoryMenu.Items.Add(new Separator()); var up = MenuItem("Nach oben", "", () => MoveCategory(cat.Name, idx - 1)); up.IsEnabled = idx > 0; var down = MenuItem("Nach unten", "", () => MoveCategory(cat.Name, idx + 1)); down.IsEnabled = idx < names.Count - 1; CategoryMenu.Items.Add(up); CategoryMenu.Items.Add(down); CategoryMenu.Items.Add(new Separator()); CategoryMenu.Items.Add(MenuItem("Löschen", "", () => DeleteCategory(cat.Name))); } /// Kategorie an eine neue Position der Reihenfolge setzen. private void MoveCategory(string name, int newIndex) { var names = Storage.Categories(); names.RemoveAll(n => n == name); newIndex = Math.Clamp(newIndex, 0, names.Count); names.Insert(newIndex, name); S.CategoryOrder = names; S.Save(); Refresh(); Select(name); } private void CategoryList_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) { _dragStart = e.GetPosition(null); _categoryDragName = (FindParent(e.OriginalSource as DependencyObject)?.DataContext as CategoryItem)?.Name; } private void CategoryList_PreviewMouseMove(object sender, MouseEventArgs e) { if (e.LeftButton != MouseButtonState.Pressed || _categoryDragName is null || _dragging) return; var d = e.GetPosition(null) - _dragStart; if (Math.Abs(d.Y) < SystemParameters.MinimumVerticalDragDistance * 2) return; var name = _categoryDragName; _categoryDragName = null; _dragging = true; try { DragDrop.DoDragDrop(CategoryList, new DataObject(CategoryDragFormat, name), DragDropEffects.Move); } finally { _dragging = false; SetDropHighlight(null); } } // ===================================================================== // Icons // ===================================================================== private List SelectedIcons => IconList.SelectedItems.Cast().ToList(); private void IconList_MouseDoubleClick(object sender, MouseButtonEventArgs e) { if (ItemAt(e.OriginalSource) is IconItem item) Open(item.Path); } private void IconList_KeyDown(object sender, KeyEventArgs e) { var sel = SelectedIcons; if (sel.Count == 0) return; switch (e.Key) { case Key.Enter: sel.ForEach(i => Open(i.Path)); e.Handled = true; break; case Key.Delete: DeleteIcons(sel); e.Handled = true; break; case Key.F2 when sel.Count == 1: RenameIcon(sel[0]); e.Handled = true; break; } } private void Open(string path) { try { Process.Start(new ProcessStartInfo(path) { UseShellExecute = true }); } catch (Exception ex) { Error("Öffnen fehlgeschlagen", $"„{Path.GetFileName(path)}“ konnte nicht geöffnet werden.\n\n{ex.Message}"); } } private void IconList_ContextMenuOpening(object sender, ContextMenuEventArgs e) { var sel = SelectedIcons; if (sel.Count == 0) { e.Handled = true; return; } IconMenu.Items.Clear(); IconMenu.Items.Add(MenuItem("Öffnen", "", () => sel.ForEach(i => Open(i.Path)), bold: true, gesture: "Enter")); if (sel.Count == 1) { IconMenu.Items.Add(MenuItem("Dateispeicherort öffnen", "", () => Process.Start("explorer.exe", $"/select,\"{sel[0].Path}\""))); IconMenu.Items.Add(MenuItem("Umbenennen", "", () => RenameIcon(sel[0]), gesture: "F2")); } IconMenu.Items.Add(new Separator()); bool fromApp = sel.All(i => Storage.IsInStorage(i.Path)); var moveTo = MenuItem(fromApp ? "Verschieben nach" : "Sichern in", fromApp ? "" : "", null); foreach (var c in Storage.Categories()) moveTo.Items.Add(MenuItem(c, S.Categories.GetValueOrDefault(c)?.Glyph ?? "", () => TransferAll(sel.Select(i => i.Path), Storage.CategoryPath(c)))); if (moveTo.Items.Count > 0) moveTo.Items.Add(new Separator()); moveTo.Items.Add(MenuItem("Neue Kategorie…", "", () => { if (NewCategory() is { } name) TransferAll(sel.Select(i => i.Path), Storage.CategoryPath(name)); })); IconMenu.Items.Add(moveTo); if (!fromApp && sel.Count == 1) IconMenu.Items.Add(MenuItem("Regel für ähnliche Icons…", "", () => CreateRuleFor(sel[0]))); IconMenu.Items.Add(new Separator()); if (fromApp) IconMenu.Items.Add(MenuItem("Zurück auf den Desktop legen", "", () => TransferAll(sel.Select(i => i.Path), Storage.UserDesktop))); IconMenu.Items.Add(MenuItem(fromApp ? "Aus der App löschen" : "Vom Desktop löschen", "", () => DeleteIcons(sel), gesture: "Entf")); } public void RenameIcon(IconItem item) { var ext = Storage.HiddenExtension(item.Path); var newName = Dialog.Prompt(this, "Umbenennen", "Neuer Name:", item.Name, "Umbenennen", text => { if (Storage.ValidateName(text) is { } err) return err; var target = Path.Combine(Path.GetDirectoryName(item.Path)!, text.Trim() + ext); return !string.Equals(target, item.Path, StringComparison.OrdinalIgnoreCase) && (File.Exists(target) || Directory.Exists(target)) ? "Ein Element mit diesem Namen gibt es dort schon." : null; }); if (newName is null || newName == item.Name) return; try { var dest = Storage.Rename(item.Path, newName + ext); var entry = new UndoEntry($"„{item.Name}“ umbenennen"); entry.Add(() => { Storage.Rename(dest, Path.GetFileName(item.Path)); return null; }); Undo.Push(entry); Refresh(); ShowToast($"Umbenannt in „{newName}“", "", undoable: true); } catch (UnauthorizedAccessException) { Error("Umbenennen fehlgeschlagen", "Keine Berechtigung (öffentlicher Desktop → App als Administrator starten)."); } catch (Exception ex) { Error("Umbenennen fehlgeschlagen", ex.Message); } } public void DeleteIcons(List sel) { // Ungesicherte Desktop-Icons: nachfragen. Alles andere geht direkt (Strg+Z holt es zurück). var unsorted = sel.Count(i => !Storage.IsInStorage(i.Path) && !i.IsSorted); if (unsorted > 0) { var what = unsorted == 1 && sel.Count == 1 ? $"„{sel[0].Name}“ ist" : $"{unsorted} der ausgewählten Icons {(unsorted == 1 ? "ist" : "sind")}"; if (!Dialog.Confirm(this, "Wirklich löschen?", $"{what} noch in keiner Kategorie gesichert. Trotzdem löschen?\n\nSie landen im Papierkorb.", "Löschen")) return; } var entry = new UndoEntry(sel.Count == 1 ? $"„{sel[0].Name}“ löschen" : $"{sel.Count} Elemente löschen"); var errors = new List(); foreach (var i in sel) { try { entry.AddDelete(i.Path, Storage.Delete(i.Path)); } catch (UnauthorizedAccessException) { errors.Add($"„{i.Name}“: Keine Berechtigung (öffentlicher Desktop → App als Administrator starten)."); } catch (Exception ex) { errors.Add($"„{i.Name}“: {ex.Message}"); } } Undo.Push(entry); Refresh(); if (errors.Count > 0) ShowErrors(errors); else ShowToast($"{Plural(sel.Count, "Element", "Elemente")} gelöscht", "", undoable: true); } /// /// Kopiert/verschiebt Dateien in einen Ordner – mit Rückgängig und Rückmeldung. /// Läuft im Hintergrund; bei großen Datenmengen erscheint ein Fortschritt mit Abbrechen. /// public async void TransferAll(IEnumerable paths, string targetDir) { if (IsBusy) { ShowToast("Bitte warten, bis der laufende Vorgang fertig ist", "\uE946", info: true); return; } var list = paths.ToList(); if (list.Count == 0) return; var target = Storage.IsDesktopDir(targetDir) ? DesktopName : Path.GetFileName(targetDir.TrimEnd('\\')); // Große Ordner würden beim Kopieren doppelt Platz belegen → lieber verschieben? var bigFolders = new HashSet(StringComparer.OrdinalIgnoreCase); if (Storage.IsInStorage(targetDir)) { const long Limit = 200L * 1024 * 1024; Mouse.OverrideCursor = Cursors.Wait; List<(string Path, long Size)> big; try { big = await Task.Run(() => list.Where(p => Directory.Exists(p) && !Storage.IsInStorage(p)) .Select(p => (Path: p, Size: Storage.DirectorySize(p, Limit * 50))) .Where(x => x.Size > Limit).ToList()); } finally { Mouse.OverrideCursor = null; } if (big.Count > 0) { var what = big.Count == 1 ? $"Der Ordner „{Path.GetFileName(big[0].Path)}“ ist {(big[0].Size >= Limit * 50 ? "über " : "")}{Backup.FormatSize(big[0].Size)} groß." : $"{big.Count} Ordner sind zusammen {Backup.FormatSize(big.Sum(b => b.Size))} groß."; if (Dialog.Confirm(this, "Großer Ordner", $"{what}\n\nBeim Kopieren würde er doppelt Speicherplatz belegen. Verschieben legt ihn nur in die Kategorie " + "(er verschwindet dann vom Desktop).", "Verschieben", "Trotzdem kopieren")) foreach (var b in big) bigFolders.Add(b.Path); } } bool anyCopy = list.Any(p => !bigFolders.Contains(p) && !Storage.IsInStorage(p) && Storage.IsInStorage(targetDir)); var detail = list.Count == 1 ? $"„{Storage.DisplayName(list[0])}“ → {target}" : $"{list.Count} Elemente → {target}"; List results; try { results = await RunWithProgress(anyCopy ? "Wird kopiert …" : "Wird verschoben …", detail, (sink, ct) => { sink.SetTotal(list.Sum(p => Storage.BytesToCopy(p, targetDir, bigFolders.Contains(p)))); var r = new List(); foreach (var p in list) { if (list.Count > 1) sink.Detail = $"„{Storage.DisplayName(p)}“ → {target}"; r.Add(Storage.Transfer(p, targetDir, bigFolders.Contains(p), sink.Add, ct)); } return Task.FromResult(r); }); } catch (Exception ex) { Refresh(); Error("Übertragen fehlgeschlagen", ex.Message); return; } var entry = new UndoEntry($"{Plural(list.Count, "Element", "Elemente")} nach „{target}“"); foreach (var r in results.Where(r => r.Status == TransferStatus.Ok)) { if (r.Copied) entry.AddCopy(r.Source, r.Dest!); else entry.AddMove(r.Source, r.Dest!); } Undo.Push(entry); Refresh(); FenceManager.RefreshAll(); var ok = results.Where(r => r.Status == TransferStatus.Ok).ToList(); var skipped = results.Where(r => r.Status == TransferStatus.Skipped).ToList(); var cancelled = results.Count(r => r.Status == TransferStatus.Cancelled); var errors = results.Where(r => r.Status == TransferStatus.Error).Select(r => r.Message!).ToList(); if (errors.Count > 0) { ShowErrors(errors); return; } if (cancelled > 0) { ShowToast(ok.Count > 0 ? $"Abgebrochen – {Plural(ok.Count, "Element", "Elemente")} wurden noch übertragen" : "Abgebrochen – es wurde nichts verändert", "\uE711", undoable: ok.Count > 0, info: true); return; } if (ok.Count == 0 && skipped.Count > 0) { ShowToast(skipped.Count == 1 ? skipped[0].Message! : $"Alle {skipped.Count} Elemente sind schon in „{target}“", "\uE946", info: true); return; } var n = Plural(ok.Count, "Element", "Elemente"); var extra = skipped.Count > 0 ? $" · {skipped.Count} waren schon da" : ""; if (Storage.IsDesktopDir(targetDir)) ShowToast($"{n} zurück auf den Desktop gelegt{extra}", "\uE7F4", undoable: true); else if (ok.All(r => r.Copied)) ShowToast($"{n} in „{target}“ gesichert{extra}", undoable: true); else ShowToast($"{n} nach „{target}“ verschoben{extra}", "\uE8DE", undoable: true); } // ---------- Rückgängig ---------- private void Undo_Click(object sender, RoutedEventArgs e) => DoUndo(); private void DoUndo() { if (Undo.Undo() is not { } r) return; Refresh(); FenceManager.Sync(); if (r.Errors.Count > 0) ShowErrors(r.Errors); else ShowToast($"Rückgängig: {r.Description}", ""); } private void UpdateUndoButton() { UndoButton.IsEnabled = Undo.CanUndo; UndoButton.ToolTip = Undo.CanUndo ? $"Rückgängig: {Undo.NextDescription} (Strg+Z)" : "Rückgängig (Strg+Z)"; } // ===================================================================== // Drag & Drop // ===================================================================== private void IconList_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) => _dragStart = e.GetPosition(null); private void IconList_PreviewMouseMove(object sender, MouseEventArgs e) { if (e.LeftButton != MouseButtonState.Pressed || _dragging) return; var d = e.GetPosition(null) - _dragStart; if (Math.Abs(d.X) < SystemParameters.MinimumHorizontalDragDistance && Math.Abs(d.Y) < SystemParameters.MinimumVerticalDragDistance) return; if (ItemAt(e.OriginalSource) is null) return; var paths = SelectedIcons.Select(i => i.Path).ToArray(); if (paths.Length == 0) return; _dragging = true; try { // FileDrop: funktioniert intern und auch zum Rausziehen auf Desktop/Explorer. // Nur "Copy" erlauben, damit der Explorer die Dateien nicht aus der App entfernt. DragDrop.DoDragDrop(IconList, new DataObject(DataFormats.FileDrop, paths), DragDropEffects.Copy); } finally { _dragging = false; } Refresh(); } private static string[]? DroppedFiles(DragEventArgs e) => e.Data.GetDataPresent(DataFormats.FileDrop) ? e.Data.GetData(DataFormats.FileDrop) as string[] : null; private static DragDropEffects DropEffect(DragEventArgs e) => e.AllowedEffects.HasFlag(DragDropEffects.Copy) ? DragDropEffects.Copy : DragDropEffects.Move; private void IconList_DragOver(object sender, DragEventArgs e) { e.Effects = DroppedFiles(e) is not null && Current is not null && !Searching ? DropEffect(e) : DragDropEffects.None; e.Handled = true; } private void IconList_Drop(object sender, DragEventArgs e) { if (DroppedFiles(e) is { } files && Current is { } cat && !Searching) TransferAll(files, cat.Folder); } private ListBoxItem? _dropHighlight; private void Nav_DragOver(object sender, DragEventArgs e) { var lbi = FindParent(e.OriginalSource as DependencyObject); if (e.Data.GetDataPresent(CategoryDragFormat)) { // Kategorie umsortieren: nur innerhalb der Kategorienliste bool ok = sender == CategoryList && lbi is not null; // Linie über oder unter dem Eintrag zeigen, je nachdem wo die Maus ist SetDropHighlight(ok ? lbi : null, ok && e.GetPosition(lbi).Y > lbi!.ActualHeight / 2 ? "after" : "before"); e.Effects = ok ? DragDropEffects.Move : DragDropEffects.None; } else { SetDropHighlight(lbi); e.Effects = DroppedFiles(e) is not null && lbi is not null ? DropEffect(e) : DragDropEffects.None; } e.Handled = true; } private void Nav_DragLeave(object sender, DragEventArgs e) => SetDropHighlight(null); private void Nav_Drop(object sender, DragEventArgs e) { SetDropHighlight(null); var lbi = FindParent(e.OriginalSource as DependencyObject); if (lbi?.DataContext is not CategoryItem target) return; if (e.Data.GetData(CategoryDragFormat) is string dragged) { if (target.IsDesktop || dragged == target.Name) return; var names = Storage.Categories(); int targetIdx = names.IndexOf(target.Name); int fromIdx = names.IndexOf(dragged); // In der unteren Hälfte losgelassen → dahinter einsortieren bool after = e.GetPosition(lbi).Y > lbi.ActualHeight / 2; int idx = targetIdx + (after ? 1 : 0) - (fromIdx < targetIdx ? 1 : 0); MoveCategory(dragged, idx); return; } if (DroppedFiles(e) is { } files) TransferAll(files, target.Folder); } private void SetDropHighlight(ListBoxItem? lbi, string tag = "drop") { if (_dropHighlight is not null && _dropHighlight != lbi) _dropHighlight.Tag = null; _dropHighlight = lbi; if (lbi is not null) lbi.Tag = tag; } // ===================================================================== // Automatisch sortieren // ===================================================================== /// Vom Watcher: neue Elemente auf dem Desktop. public void AutoSortNew(List paths) { if (!S.AutoSort || S.Rules.Count == 0) return; var done = ApplyRules(paths, "Automatisch einsortiert"); if (done.Count == 0) return; var text = done.Count == 1 ? $"„{Storage.DisplayName(done[0].Path)}“ → {done[0].Category}" : $"{done.Count} neue Icons einsortiert"; if (IsVisible && IsActive) ShowToast(text, "", undoable: true); else App.TrayIcon?.Notify("Automatisch einsortiert", text); } private List<(string Path, string Category)> ApplyRules(IEnumerable paths, string description) { var entry = new UndoEntry(description); var done = new List<(string, string)>(); foreach (var p in paths) { if (AutoSorter.Match(p) is not { } cat) continue; var r = Storage.Transfer(p, Storage.CategoryPath(cat)); if (r.Status == TransferStatus.Ok) { entry.AddCopy(r.Source, r.Dest!); done.Add((p, cat)); } if (S.AutoSortRemoveFromDesktop && r.Status is TransferStatus.Ok or TransferStatus.Skipped && !Storage.IsInStorage(p)) { try { entry.AddDelete(p, Storage.Delete(p)); } catch { } } } Undo.Push(entry); Refresh(); return done; } private void ApplyRulesNow_Click(object sender, RoutedEventArgs e) { if (S.Rules.Count == 0) { ShowToast("Lege zuerst eine Regel an", "", info: true); return; } var done = ApplyRules(Storage.DesktopEntries().ToList(), "Regeln anwenden"); ShowToast(done.Count == 0 ? "Keine neuen passenden Icons gefunden" : $"{Plural(done.Count, "Icon", "Icons")} einsortiert", done.Count == 0 ? "" : "", undoable: done.Count > 0, info: done.Count == 0); } private void CreateRuleFor(IconItem item) { var cats = Storage.Categories(); if (cats.Count == 0) { ShowToast("Lege zuerst eine Kategorie an", "", info: true); return; } // Vorschlag: erstes sinnvolles Wort des Namens var suggestion = item.Name.Split(' ', '-', '_').FirstOrDefault(w => w.Length > 2) ?? item.Name; S.Rules.Add(new SortRule { Contains = suggestion, Category = cats[0] }); S.Save(); ShowSettings(); ShowToast("Regel angelegt – jetzt noch die Kategorie auswählen", "", info: true); } // ===================================================================== // Einstellungen // ===================================================================== private void LoadSettingsView() { _loadingSettings = true; DesignLight.IsChecked = S.Design == DesignMode.Light; DesignDark.IsChecked = S.Design == DesignMode.Dark; DesignSystem.IsChecked = S.Design == DesignMode.System; ViewSmall.IsChecked = S.View == ViewMode.Small; ViewMedium.IsChecked = S.View == ViewMode.Medium; ViewLarge.IsChecked = S.View == ViewMode.Large; ViewList.IsChecked = S.View == ViewMode.List; AutostartToggle.IsChecked = Autostart.Enabled; TrayToggle.IsChecked = S.TrayEnabled; CloseToTrayToggle.IsChecked = S.CloseToTray; CloseToTrayToggle.IsEnabled = S.TrayEnabled; HotkeyToggle.IsChecked = S.HotkeyEnabled; HotkeyBox.Text = Hotkey.Describe(S.HotkeyModifiers, S.HotkeyKey); HotkeyBox.IsEnabled = S.HotkeyEnabled; AutoSortToggle.IsChecked = S.AutoSort; AutoUpdateToggle.IsChecked = S.AutoUpdateCheck; AutoRemoveToggle.IsChecked = S.AutoSortRemoveFromDesktop; RulesList.ItemsSource = null; RulesList.ItemsSource = S.Rules; NoRulesText.Visibility = S.Rules.Count == 0 ? Visibility.Visible : Visibility.Collapsed; FencesToggle.IsChecked = S.FencesEnabled; _loadingSettings = false; UpdateBackupHint(); } private long _storageSize = -1; private async void UpdateBackupHint() { var cats = Storage.Categories().Count; string Text() => $"{Plural(cats, "Kategorie", "Kategorien")}" + (_storageSize >= 0 ? $", zusammen {Backup.FormatSize(_storageSize)}" : "") + " – als ZIP-Datei, z. B. für einen neuen PC"; BackupHint.Text = Text(); _storageSize = await Task.Run(Backup.StorageSize); BackupHint.Text = Text(); } private void Design_Checked(object sender, RoutedEventArgs e) { if (_loadingSettings) return; SetDesign(sender == DesignLight ? DesignMode.Light : sender == DesignDark ? DesignMode.Dark : DesignMode.System); } private void View_Checked(object sender, RoutedEventArgs e) { if (_loadingSettings) return; ApplyView(sender == ViewSmall ? ViewMode.Small : sender == ViewLarge ? ViewMode.Large : sender == ViewList ? ViewMode.List : ViewMode.Medium); S.Save(); } private void AutostartToggle_Click(object sender, RoutedEventArgs e) { Autostart.Enabled = AutostartToggle.IsChecked == true; // Autostart startet im Infobereich – dafür muss das Symbol aktiv sein if (Autostart.Enabled && !S.TrayEnabled) { S.TrayEnabled = true; S.Save(); App.ApplyTray(); } LoadSettingsView(); } private void TrayToggle_Click(object sender, RoutedEventArgs e) { S.TrayEnabled = TrayToggle.IsChecked == true; S.Save(); App.ApplyTray(); LoadSettingsView(); } private void CloseToTrayToggle_Click(object sender, RoutedEventArgs e) { S.CloseToTray = CloseToTrayToggle.IsChecked == true; S.Save(); } private void HotkeyToggle_Click(object sender, RoutedEventArgs e) { S.HotkeyEnabled = HotkeyToggle.IsChecked == true; S.Save(); var ok = App.ApplyHotkey(); LoadSettingsView(); UpdateHotkeyHint(ok); } private void HotkeyBox_PreviewKeyDown(object sender, KeyEventArgs e) { var key = e.Key == Key.System ? e.SystemKey : e.Key; if (key is Key.Tab) return; e.Handled = true; if (key is Key.LeftCtrl or Key.RightCtrl or Key.LeftAlt or Key.RightAlt or Key.LeftShift or Key.RightShift or Key.LWin or Key.RWin or Key.Escape) return; int mods = (int)Keyboard.Modifiers; // Alt=1, Strg=2, Umschalt=4, Win=8 – gleiche Bits wie RegisterHotKey if ((mods & (Hotkey.ModCtrl | Hotkey.ModAlt | Hotkey.ModWin)) == 0) { HotkeyHint.Text = "Bitte mit Strg, Alt oder Win kombinieren."; return; } S.HotkeyModifiers = mods; S.HotkeyKey = KeyInterop.VirtualKeyFromKey(key); S.Save(); HotkeyBox.Text = Hotkey.Describe(S.HotkeyModifiers, S.HotkeyKey); UpdateHotkeyHint(App.ApplyHotkey()); } private void UpdateHotkeyHint(bool ok) => HotkeyHint.Text = !S.HotkeyEnabled ? "Ausgeschaltet" : ok ? $"{Hotkey.Describe(S.HotkeyModifiers, S.HotkeyKey)} öffnet die App von überall – ins Feld klicken und neue Kombination drücken" : "⚠ Diese Kombination ist schon von einem anderen Programm belegt – bitte eine andere wählen."; private void AutoSortToggle_Click(object sender, RoutedEventArgs e) { S.AutoSort = AutoSortToggle.IsChecked == true; S.Save(); if (S.AutoSort && S.Rules.Count == 0) ShowToast("Lege unten noch Regeln an", "", info: true); } private void AutoRemoveToggle_Click(object sender, RoutedEventArgs e) { S.AutoSortRemoveFromDesktop = AutoRemoveToggle.IsChecked == true; S.Save(); } private void AddRule_Click(object sender, RoutedEventArgs e) { var cats = Storage.Categories(); if (cats.Count == 0) { ShowToast("Lege zuerst eine Kategorie an", "", info: true); return; } S.Rules.Add(new SortRule { Contains = "", Category = cats[0] }); S.Save(); LoadSettingsView(); } private void RuleText_LostFocus(object sender, RoutedEventArgs e) => S.Save(); private void RuleDelete_Click(object sender, RoutedEventArgs e) { if ((sender as FrameworkElement)?.Tag is SortRule r) { S.Rules.Remove(r); S.Save(); LoadSettingsView(); } } private void RuleCategory_Click(object sender, RoutedEventArgs e) { if (sender is not Button b || b.Tag is not SortRule r) return; var menu = new ContextMenu { PlacementTarget = b, Placement = PlacementMode.Bottom }; foreach (var c in Storage.Categories()) { menu.Items.Add(MenuItem(c, S.Categories.GetValueOrDefault(c)?.Glyph ?? "", () => { r.Category = c; S.Save(); LoadSettingsView(); }, bold: string.Equals(c, r.Category, StringComparison.OrdinalIgnoreCase))); } menu.IsOpen = true; } private void FencesToggle_Click(object sender, RoutedEventArgs e) { S.FencesEnabled = FencesToggle.IsChecked == true; S.Save(); FenceManager.Sync(); if (S.FencesEnabled && !S.Categories.Values.Any(m => m.Fence)) ShowToast("Wähle bei einer Kategorie „Als Box auf den Desktop“", "", info: true); } private async void Export_Click(object sender, RoutedEventArgs e) { var dlg = new Microsoft.Win32.SaveFileDialog { Title = "Kategorien sichern", FileName = $"DeskCore-Sicherung-{DateTime.Now:yyyy-MM-dd}.zip", Filter = "ZIP-Archiv (*.zip)|*.zip", InitialDirectory = Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments), }; if (dlg.ShowDialog(this) != true) return; if (Storage.IsUnder(dlg.FileName, Storage.Root)) { Error("Anderen Ort wählen", "Die Sicherung kann nicht im DeskCore-Ordner selbst gespeichert werden – sie würde sich sonst mitsichern. " + "Am besten z. B. unter Dokumente oder auf einem USB-Stick speichern."); return; } var size = await Task.Run(Backup.StorageSize); if (size > 2L * 1024 * 1024 * 1024 && !Dialog.Confirm(this, "Große Sicherung", $"Deine Kategorien sind zusammen {Backup.FormatSize(size)} groß (vor allem durch Ordner). Das Sichern kann eine Weile dauern " + "und braucht entsprechend Platz.", "Trotzdem sichern")) return; if (IsBusy) return; try { var file = dlg.FileName; var count = await RunWithProgress("Sicherung wird erstellt …", Path.GetFileName(file), (sink, ct) => { sink.SetTotal(size); return Task.FromResult(Backup.Export(file, sink.Add, ct)); }); ShowToast($"{Plural(count, "Datei", "Dateien")} gesichert in {Path.GetFileName(dlg.FileName)}", ""); } catch (OperationCanceledException) { ShowToast("Sicherung abgebrochen – es wurde keine Datei angelegt", "\uE711", info: true); } catch (Exception ex) { Error("Sichern fehlgeschlagen", ex.Message); } } private async void Import_Click(object sender, RoutedEventArgs e) { var dlg = new Microsoft.Win32.OpenFileDialog { Title = "Sicherung wiederherstellen", Filter = "ZIP-Archiv (*.zip)|*.zip", InitialDirectory = Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments), }; if (dlg.ShowDialog(this) != true) return; if (!Dialog.Confirm(this, "Sicherung wiederherstellen?", "Die Kategorien aus der Sicherung werden zu deinen vorhandenen hinzugefügt. Vorhandene Dateien werden nicht überschrieben.", "Wiederherstellen")) return; if (IsBusy) return; try { var file = dlg.FileName; var (added, skipped) = await RunWithProgress("Sicherung wird wiederhergestellt …", Path.GetFileName(file), (sink, ct) => { sink.SetTotal(Backup.ArchiveSize(file)); return Task.FromResult(Backup.Import(file, sink.Add, ct)); }); Refresh(); FenceManager.Sync(); LoadSettingsView(); ShowToast($"{Plural(added, "Datei", "Dateien")} wiederhergestellt" + (skipped > 0 ? $" · {skipped} waren schon da" : ""), ""); } catch (OperationCanceledException) { Refresh(); ShowToast("Wiederherstellen abgebrochen – bereits übernommene Dateien bleiben erhalten", "\uE711", info: true); } catch (Exception ex) { Error("Wiederherstellen fehlgeschlagen", $"Die Datei ist keine gültige Sicherung.\n\n{ex.Message}"); } } private void License_Click(object sender, RoutedEventArgs e) { string text; try { using var r = new StreamReader(Application.GetResourceStream(new Uri("pack://application:,,,/LICENSE.txt"))!.Stream); text = r.ReadToEnd().Trim(); } catch { text = "Lizenztext nicht gefunden."; } var box = new Border { Background = (Brush)FindResource("ControlFillBrush"), BorderBrush = (Brush)FindResource("ControlStrokeBrush"), BorderThickness = new Thickness(1), CornerRadius = new CornerRadius(6), Child = new ScrollViewer { MaxHeight = 380, VerticalScrollBarVisibility = ScrollBarVisibility.Auto, Content = new TextBlock { Text = text, TextWrapping = TextWrapping.Wrap, FontSize = 13, LineHeight = 19, Margin = new Thickness(16, 12, 16, 12), Foreground = (Brush)FindResource("TextBrush"), }, }, }; Dialog.Show(this, "Lizenz- und Nutzungsbedingungen", box, "Schließen", null, null, width: 640); } private void OpenStorage_Click(object sender, RoutedEventArgs e) => Process.Start("explorer.exe", $"\"{Storage.Root}\""); private void Quit_Click(object sender, RoutedEventArgs e) => App.Quit(); // ===================================================================== // Tastatur // ===================================================================== private void OnPreviewKeyDown(object sender, KeyEventArgs e) { bool ctrl = Keyboard.Modifiers == ModifierKeys.Control; bool inText = e.OriginalSource is TextBox; if (e.OriginalSource == HotkeyBox) return; if (Keyboard.Modifiers == ModifierKeys.Alt && e.Key == Key.System && e.SystemKey is Key.Up or Key.Down && !inText && CategoryList.SelectedItem is CategoryItem moving) { // Alt+↑/↓: ausgewählte Kategorie verschieben int idx = CategoryList.SelectedIndex + (e.SystemKey == Key.Up ? -1 : 1); if (idx >= 0 && idx < CategoryList.Items.Count) { MoveCategory(moving.Name, idx); (CategoryList.ItemContainerGenerator.ContainerFromIndex(idx) as ListBoxItem)?.Focus(); } e.Handled = true; } else if (ctrl && e.Key == Key.Z && !inText) { DoUndo(); e.Handled = true; } else if (ctrl && e.Key == Key.F) { ShowBrowse(); SearchBox.Focus(); SearchBox.SelectAll(); e.Handled = true; } else if (ctrl && e.Key == Key.N) { NewCategory_Click(this, e); e.Handled = true; } else if (ctrl && e.Key == Key.A && !inText && !InSettings) { IconList.SelectAll(); IconList.Focus(); e.Handled = true; } else if (e.Key == Key.F5) { Refresh(); e.Handled = true; } else if (e.Key == Key.Escape && e.OriginalSource == SearchBox && SearchBox.Text.Length > 0) { SearchBox.Text = ""; e.Handled = true; } else if (e.Key == Key.Down && e.OriginalSource == SearchBox && IconList.Items.Count > 0) { IconList.SelectedIndex = 0; (IconList.ItemContainerGenerator.ContainerFromIndex(0) as ListBoxItem)?.Focus(); e.Handled = true; } else if (ctrl && e.Key is >= Key.D1 and <= Key.D9) { int n = e.Key - Key.D1; ShowBrowse(); SearchBox.Text = ""; if (n == 0) DesktopNav.SelectedIndex = 0; else if (n - 1 < CategoryList.Items.Count) CategoryList.SelectedIndex = n - 1; e.Handled = true; } } // ===================================================================== // Rückmeldungen // ===================================================================== private void ShowToast(string text, string glyph = "", bool undoable = false, bool info = false) { ToastText.Text = text; ToastGlyph.Text = glyph; ToastBadge.Background = (Brush)FindResource(info ? "AccentBrush" : "SuccessBrush"); ToastGlyph.Foreground = info ? (Brush)FindResource("OnAccentBrush") : Brushes.White; ToastUndo.Visibility = undoable && Undo.CanUndo ? Visibility.Visible : Visibility.Collapsed; Toast.IsHitTestVisible = true; AnimateToast(true); _toastTimer.Stop(); _toastTimer.Interval = TimeSpan.FromSeconds(undoable ? 6 : 3.5); _toastTimer.Start(); } private void AnimateToast(bool show) { if (!show) Toast.IsHitTestVisible = false; var ease = new CubicEase { EasingMode = EasingMode.EaseOut }; var dur = TimeSpan.FromMilliseconds(show ? 220 : 300); Toast.BeginAnimation(OpacityProperty, new DoubleAnimation(show ? 1 : 0, dur) { EasingFunction = ease }); ToastShift.BeginAnimation(TranslateTransform.YProperty, new DoubleAnimation(show ? 0 : 12, dur) { EasingFunction = ease }); } private void Error(string title, string msg) => Dialog.Info(this, title, msg); private void ShowErrors(List errors) => Error("Nicht alles hat geklappt", string.Join("\n\n", errors.Take(8)) + (errors.Count > 8 ? $"\n\n… und {errors.Count - 8} weitere" : "")); // ===================================================================== // Helfer // ===================================================================== public static MenuItem MenuItem(string header, string glyph, Action? action, bool bold = false, string? gesture = null) { var mi = new MenuItem { Header = header, Icon = new TextBlock { Text = glyph }, FontWeight = bold ? FontWeights.SemiBold : FontWeights.Normal, }; if (gesture is not null) mi.InputGestureText = gesture; if (action is not null) mi.Click += (_, _) => action(); return mi; } private object? ItemAt(object source) => FindParent(source as DependencyObject)?.DataContext; public static T? FindParent(DependencyObject? d) where T : DependencyObject { while (d is not null and not T) d = d is Visual or System.Windows.Media.Media3D.Visual3D ? VisualTreeHelper.GetParent(d) : LogicalTreeHelper.GetParent(d); return d as T; } }