- 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>
1504 lines
63 KiB
C#
1504 lines
63 KiB
C#
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;
|
||
}
|
||
}
|