Files
DeskCore/MainWindow.xaml.cs
M_ViperandClaude Opus 5.5 1ae6e1abc2 Fortschritt, .NET-Prüfung, Updates, Fenster merken, Box-Funktionen
- Große Kopien laufen im Hintergrund mit Fortschritt und Abbrechen;
  abgebrochene Kopien werden aufgeräumt, das Original bleibt unverändert
- Sichern/Wiederherstellen mit Fortschritt, keine halben ZIP-Dateien
- Installer auf .NET Framework 4.8: erkennt fehlende .NET 9 Desktop Runtime
  und installiert sie auf Wunsch (Microsoft-Signatur wird geprüft)
- Update-Hinweis über Gitea-Releases, Update mit automatischem Neustart
- Fenstergröße und -position werden gemerkt
- Desktop-Boxen: umbenennen, löschen, verschieben, herausziehen
- Fenster und Boxen kehren auf sichtbare Bildschirme zurück
- README ergänzt

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 20:46:48 +02:00

1504 lines
63 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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();
}
/// <summary>Globales Tastenkürzel: öffnen bzw. wieder verstecken.</summary>
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);
}
/// <summary>Menü beim Rechtsklick auf das Infobereich-Symbol.</summary>
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
// =====================================================================
/// <summary>Vom Watcher: Dateien haben sich geändert.</summary>
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<CategoryItem>)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<IconItem>().Select(i => i.Path).ToHashSet(StringComparer.OrdinalIgnoreCase);
List<IconItem> 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);
/// <summary>Dialog zum Anlegen/Bearbeiten einer Kategorie.</summary>
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<RadioButton>();
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<string>();
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<ListBoxItem>(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)));
}
/// <summary>Kategorie an eine neue Position der Reihenfolge setzen.</summary>
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<ListBoxItem>(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<IconItem> SelectedIcons => IconList.SelectedItems.Cast<IconItem>().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<IconItem> 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<string>();
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);
}
/// <summary>
/// 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.
/// </summary>
public async void TransferAll(IEnumerable<string> 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<string>(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<TransferResult> 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<TransferResult>();
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<ListBoxItem>(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<ListBoxItem>(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
// =====================================================================
/// <summary>Vom Watcher: neue Elemente auf dem Desktop.</summary>
public void AutoSortNew(List<string> 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<string> 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<string> 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<ListBoxItem>(source as DependencyObject)?.DataContext;
public static T? FindParent<T>(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;
}
}