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();
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;
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) Refresh();
}
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"));
}
private 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); }
}
private 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.
public void TransferAll(IEnumerable paths, string targetDir)
{
var list = paths.ToList();
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;
var big = 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();
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);
}
}
var entry = new UndoEntry($"{Plural(list.Count, "Element", "Elemente")} nach „{target}“");
var results = list.Select(p => Storage.Transfer(p, targetDir, forceMove: bigFolders.Contains(p))).ToList();
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();
var ok = results.Where(r => r.Status == TransferStatus.Ok).ToList();
var skipped = results.Where(r => r.Status == TransferStatus.Skipped).ToList();
var errors = results.Where(r => r.Status == TransferStatus.Error).Select(r => r.Message!).ToList();
if (errors.Count > 0) { ShowErrors(errors); return; }
if (ok.Count == 0 && skipped.Count > 0)
{
ShowToast(skipped.Count == 1 ? skipped[0].Message! : $"Alle {skipped.Count} Elemente sind schon in „{target}“", "", 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}", "", undoable: true);
else if (ok.All(r => r.Copied))
ShowToast($"{n} in „{target}“ gesichert{extra}", undoable: true);
else
ShowToast($"{n} nach „{target}“ verschoben{extra}", "", 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;
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;
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;
ShowToast("Sicherung läuft …", "", info: true);
try
{
var count = await Task.Run(() => Backup.Export(dlg.FileName));
ShowToast($"{Plural(count, "Datei", "Dateien")} gesichert in {Path.GetFileName(dlg.FileName)}", "");
}
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;
try
{
var (added, skipped) = await Task.Run(() => Backup.Import(dlg.FileName));
Refresh();
FenceManager.Sync();
LoadSettingsView();
ShowToast($"{Plural(added, "Datei", "Dateien")} wiederhergestellt" + (skipped > 0 ? $" · {skipped} waren schon da" : ""), "");
}
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;
}
}