398 lines
16 KiB
C#
398 lines
16 KiB
C#
using System.Diagnostics;
|
||
using System.IO;
|
||
using System.Runtime.InteropServices;
|
||
using System.Windows;
|
||
using System.Windows.Controls;
|
||
using System.Windows.Input;
|
||
using System.Windows.Interop;
|
||
using System.Windows.Media;
|
||
|
||
namespace DeskCore;
|
||
|
||
/// <summary>
|
||
/// Schnellstart-Suche: Tastenkürzel → Suchleiste mitten auf dem Bildschirm, tippen, Enter, läuft.
|
||
/// Durchsucht Kategorien, Desktop, Startmenü, Start-Gruppen, Kategorien und Desktop-Seiten.
|
||
/// Was du oft startest, steht weiter oben.
|
||
/// </summary>
|
||
public sealed class QuickSearch : Window
|
||
{
|
||
private const int MaxResults = 8;
|
||
|
||
private sealed record Entry(string Name, string Sub, string? Path, string Glyph, string Key, Action Run, Action? Locate = null)
|
||
{
|
||
/// <summary>Für die Ähnlichkeit: Name ohne Endungen wie „- Verknüpfung“.</summary>
|
||
public string SearchName { get; } = Name.Replace(" - Verknüpfung", "", StringComparison.OrdinalIgnoreCase);
|
||
}
|
||
|
||
private static QuickSearch? _open;
|
||
private static List<Entry>? _startMenu;
|
||
private static DateTime _startMenuRead;
|
||
|
||
private readonly TextBox _box;
|
||
private readonly ListBox _list;
|
||
private readonly TextBlock _hint;
|
||
private List<Entry> _all = [];
|
||
private List<Entry> _shown = [];
|
||
private bool _closing;
|
||
|
||
private static AppSettings S => AppSettings.Current;
|
||
|
||
/// <summary>Per Tastenkürzel: öffnen bzw. wieder schließen.</summary>
|
||
public static void Toggle()
|
||
{
|
||
if (_open is not null) { _open.CloseNow(); return; }
|
||
_open = new QuickSearch();
|
||
_open.Show();
|
||
}
|
||
|
||
private QuickSearch()
|
||
{
|
||
var res = Application.Current.Resources;
|
||
WindowStyle = WindowStyle.None;
|
||
AllowsTransparency = true;
|
||
Background = Brushes.Transparent;
|
||
ResizeMode = ResizeMode.NoResize;
|
||
ShowInTaskbar = false;
|
||
Topmost = true;
|
||
Width = 680;
|
||
SizeToContent = SizeToContent.Height;
|
||
Title = "DeskCore Suche";
|
||
FontFamily = (FontFamily)res["UiFont"];
|
||
Foreground = (Brush)res["TextBrush"];
|
||
|
||
// Suchzeile
|
||
_box = new TextBox
|
||
{
|
||
FontSize = 20, BorderThickness = new Thickness(0), Background = Brushes.Transparent, Padding = new Thickness(0),
|
||
VerticalContentAlignment = VerticalAlignment.Center, Foreground = (Brush)res["TextBrush"],
|
||
CaretBrush = (Brush)res["TextBrush"],
|
||
};
|
||
var placeholder = new TextBlock
|
||
{
|
||
Text = "Programm, Datei, Start-Gruppe oder Kategorie suchen …", FontSize = 20, IsHitTestVisible = false,
|
||
Foreground = (Brush)res["TextTertiaryBrush"], VerticalAlignment = VerticalAlignment.Center,
|
||
};
|
||
_box.TextChanged += (_, _) =>
|
||
{
|
||
placeholder.Visibility = _box.Text.Length == 0 ? Visibility.Visible : Visibility.Collapsed;
|
||
Filter();
|
||
};
|
||
var searchRow = new DockPanel { Margin = new Thickness(18, 14, 18, 14) };
|
||
var glass = new TextBlock
|
||
{
|
||
Text = "", FontFamily = (FontFamily)res["IconFont"], FontSize = 20, Margin = new Thickness(0, 0, 14, 0),
|
||
VerticalAlignment = VerticalAlignment.Center, Foreground = (Brush)res["AccentBrush"],
|
||
};
|
||
DockPanel.SetDock(glass, Dock.Left);
|
||
searchRow.Children.Add(glass);
|
||
searchRow.Children.Add(new Grid { Children = { placeholder, _box } });
|
||
|
||
_list = new ListBox
|
||
{
|
||
BorderThickness = new Thickness(0), Background = Brushes.Transparent, Margin = new Thickness(6, 0, 6, 6),
|
||
Focusable = false, Visibility = Visibility.Collapsed,
|
||
};
|
||
ScrollViewer.SetHorizontalScrollBarVisibility(_list, ScrollBarVisibility.Disabled);
|
||
_list.MouseDoubleClick += (_, _) => RunSelected(locate: false);
|
||
_list.PreviewMouseLeftButtonUp += (_, e) =>
|
||
{
|
||
// Ein Klick genügt – wie im Startmenü
|
||
if (MainWindow.FindParent<ListBoxItem>(e.OriginalSource as DependencyObject) is not null) RunSelected(locate: false);
|
||
};
|
||
|
||
_hint = new TextBlock
|
||
{
|
||
FontSize = 12, Margin = new Thickness(18, 8, 18, 10), Foreground = (Brush)res["TextTertiaryBrush"],
|
||
Text = "Enter Starten · Strg + Enter Speicherort öffnen · Esc Schließen",
|
||
};
|
||
|
||
var divider = new Border { Height = 1, Background = (Brush)res["ControlStrokeBrush"] };
|
||
var footer = new Border { Child = _hint, BorderBrush = (Brush)res["ControlStrokeBrush"], BorderThickness = new Thickness(0, 1, 0, 0) };
|
||
var stack = new StackPanel { Children = { searchRow, divider, _list, footer } };
|
||
|
||
Content = new Border
|
||
{
|
||
Margin = new Thickness(24), CornerRadius = new CornerRadius(10),
|
||
Background = (Brush)res["FlyoutBrush"], BorderBrush = (Brush)res["FlyoutStrokeBrush"], BorderThickness = new Thickness(1),
|
||
Effect = (System.Windows.Media.Effects.Effect)res["DialogShadow"], Child = stack,
|
||
};
|
||
|
||
PreviewKeyDown += OnKey;
|
||
Deactivated += (_, _) => CloseNow();
|
||
Closed += (_, _) => { if (_open == this) _open = null; };
|
||
Loaded += (_, _) =>
|
||
{
|
||
Position();
|
||
BringToFront();
|
||
_box.Focus();
|
||
BuildIndex();
|
||
};
|
||
}
|
||
|
||
// =====================================================================
|
||
// Position & Fokus
|
||
// =====================================================================
|
||
|
||
private void Position()
|
||
{
|
||
var mouse = GetCursorPos(out var p) ? new Point(p.X / Screens.Scale, p.Y / Screens.Scale) : new Point(0, 0);
|
||
var wa = Screens.WorkAreaAt(mouse);
|
||
Left = wa.Left + (wa.Width - Width) / 2;
|
||
Top = wa.Top + wa.Height * 0.22;
|
||
}
|
||
|
||
private void BringToFront()
|
||
{
|
||
// Das Tastenkürzel erlaubt DeskCore, nach vorne zu kommen
|
||
var hwnd = new WindowInteropHelper(this).Handle;
|
||
SetForegroundWindow(hwnd);
|
||
Activate();
|
||
}
|
||
|
||
private void CloseNow()
|
||
{
|
||
if (_closing) return;
|
||
_closing = true;
|
||
Close();
|
||
}
|
||
|
||
// =====================================================================
|
||
// Was durchsucht wird
|
||
// =====================================================================
|
||
|
||
private async void BuildIndex()
|
||
{
|
||
var list = new List<Entry>();
|
||
|
||
// Start-Gruppen
|
||
foreach (var g in S.LaunchGroups)
|
||
{
|
||
var group = g;
|
||
list.Add(new Entry(g.Name, $"Start-Gruppe · {g.Items.Count} {(g.Items.Count == 1 ? "Programm" : "Programme")}", null, "",
|
||
Usage.GroupPrefix + g.Name, () => App.Win?.LaunchGroupNow(group)));
|
||
}
|
||
// Kategorien selbst (öffnet DeskCore dort) und Desktop-Seiten
|
||
foreach (var c in Storage.Categories())
|
||
{
|
||
var cat = c;
|
||
list.Add(new Entry(c, "Kategorie in DeskCore öffnen", null, S.Categories.GetValueOrDefault(c)?.Glyph ?? "",
|
||
"kategorie:" + c, () => { App.Win?.ShowFromBackground(); App.Win?.ShowCategory(cat); }));
|
||
}
|
||
if (S.Pages.Count > 1)
|
||
for (int i = 0; i < S.Pages.Count; i++)
|
||
{
|
||
int page = i;
|
||
list.Add(new Entry(S.Pages[i], "Desktop-Seite anzeigen", null, "", "seite:" + S.Pages[i], () => FenceManager.SwitchPage(page)));
|
||
}
|
||
|
||
_all = list;
|
||
Filter();
|
||
|
||
// Dateien aus Kategorien, Desktop und Startmenü im Hintergrund sammeln (Startmenü 10 Minuten zwischengespeichert)
|
||
bool withStartMenu = S.QuickSearchStartMenu;
|
||
var categories = Storage.Categories();
|
||
var files = await Task.Run(() =>
|
||
{
|
||
var l = new List<Entry>();
|
||
foreach (var c in categories)
|
||
foreach (var p in Storage.Entries(Storage.CategoryPath(c)))
|
||
l.Add(FileEntry(p, c));
|
||
foreach (var p in Storage.DesktopEntries())
|
||
l.Add(FileEntry(p, "Desktop"));
|
||
if (withStartMenu)
|
||
{
|
||
if (_startMenu is null || DateTime.Now - _startMenuRead > TimeSpan.FromMinutes(10))
|
||
{
|
||
_startMenu = ReadStartMenu();
|
||
_startMenuRead = DateTime.Now;
|
||
}
|
||
l.AddRange(_startMenu);
|
||
}
|
||
return Dedupe(l);
|
||
});
|
||
if (_closing) return;
|
||
_all = list.Concat(files).ToList();
|
||
Filter();
|
||
}
|
||
|
||
private static Entry FileEntry(string path, string where) =>
|
||
new(Storage.DisplayName(path), where, path, "", path, () => Usage.Start(path),
|
||
() => Process.Start("explorer.exe", $"/select,\"{path}\""));
|
||
|
||
private static List<Entry> ReadStartMenu()
|
||
{
|
||
var result = new List<Entry>();
|
||
foreach (var root in new[]
|
||
{
|
||
Environment.GetFolderPath(Environment.SpecialFolder.StartMenu),
|
||
Environment.GetFolderPath(Environment.SpecialFolder.CommonStartMenu),
|
||
})
|
||
{
|
||
if (!Directory.Exists(root)) continue;
|
||
IEnumerable<string> files;
|
||
try { files = Directory.EnumerateFiles(root, "*.*", SearchOption.AllDirectories).ToList(); }
|
||
catch { continue; }
|
||
foreach (var f in files)
|
||
{
|
||
var ext = Path.GetExtension(f).ToLowerInvariant();
|
||
if (ext is not (".lnk" or ".url" or ".appref-ms")) continue;
|
||
var name = Path.GetFileNameWithoutExtension(f);
|
||
// Deinstallations- und Hilfe-Verknüpfungen will niemand per Suche starten
|
||
if (name.Contains("uninstall", StringComparison.OrdinalIgnoreCase) || name.Contains("deinstall", StringComparison.OrdinalIgnoreCase))
|
||
continue;
|
||
result.Add(new Entry(name, "Startmenü", f, "", f, () => Usage.Start(f),
|
||
() => Process.Start("explorer.exe", $"/select,\"{f}\"")));
|
||
}
|
||
}
|
||
return result;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Gleiches Programm aus Kategorie, Desktop und Startmenü nur einmal zeigen – die Reihenfolge der Quellen
|
||
/// entscheidet (Kategorie vor Desktop vor Startmenü). Gleicher Name gilt als gleiches Programm.
|
||
/// </summary>
|
||
private static List<Entry> Dedupe(IEnumerable<Entry> entries)
|
||
{
|
||
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||
return entries.Where(e => e.Path is null || seen.Add(e.SearchName.Trim())).ToList();
|
||
}
|
||
|
||
// =====================================================================
|
||
// Suchen & Anzeigen
|
||
// =====================================================================
|
||
|
||
/// <summary>Wie gut passt der Name zur Eingabe? -1 = gar nicht.</summary>
|
||
private static int Match(string name, string q)
|
||
{
|
||
if (q.Length == 0) return 0;
|
||
var n = name.ToLowerInvariant();
|
||
if (n == q) return 1000;
|
||
if (n.StartsWith(q)) return 800;
|
||
var words = n.Split([' ', '-', '_', '.', '(', ')', '&'], StringSplitOptions.RemoveEmptyEntries);
|
||
if (words.Any(w => w.StartsWith(q))) return 650;
|
||
if (q.Length >= 2 && string.Concat(words.Select(w => w[0])).StartsWith(q)) return 600; // "vsc" → Visual Studio Code
|
||
if (n.Contains(q)) return 450;
|
||
// Buchstaben in der richtigen Reihenfolge ("fzla" → FileZilla) – aber nur ab einem Wortanfang,
|
||
// sonst passt fast alles irgendwie ("ets" → Malwarebytes)
|
||
if (q.Length < 3) return -1;
|
||
for (int start = 0; start < n.Length; start++)
|
||
{
|
||
if (n[start] != q[0] || (start > 0 && char.IsLetterOrDigit(n[start - 1]))) continue;
|
||
int k = 0;
|
||
for (int j = start; j < n.Length && k < q.Length; j++) if (n[j] == q[k]) k++;
|
||
if (k == q.Length) return 200;
|
||
}
|
||
return -1;
|
||
}
|
||
|
||
private void Filter()
|
||
{
|
||
var q = _box.Text.Trim().ToLowerInvariant();
|
||
if (q.Length == 0)
|
||
{
|
||
// Ohne Eingabe: zuletzt/oft benutzt
|
||
_shown = _all.Where(e => Usage.Score(e.Key) > 0)
|
||
.OrderByDescending(e => Usage.Score(e.Key)).Take(MaxResults).ToList();
|
||
}
|
||
else
|
||
{
|
||
_shown = _all.Select(e => (e, m: Match(e.SearchName, q)))
|
||
.Where(x => x.m >= 0)
|
||
.OrderByDescending(x => x.m + Math.Min(300, Usage.Score(x.e.Key) * 40))
|
||
.ThenBy(x => x.e.Name.Length)
|
||
.Take(MaxResults).Select(x => x.e).ToList();
|
||
}
|
||
ShowResults(q.Length > 0);
|
||
}
|
||
|
||
private void ShowResults(bool searching)
|
||
{
|
||
var res = Application.Current.Resources;
|
||
_list.Items.Clear();
|
||
int px = (int)Math.Round(32 * VisualTreeHelper.GetDpi(this).DpiScaleX);
|
||
foreach (var e in _shown)
|
||
{
|
||
FrameworkElement icon;
|
||
if (e.Path is not null)
|
||
{
|
||
var img = new Image { Width = 32, Height = 32 };
|
||
var item = new IconItem { Path = e.Path };
|
||
item.PropertyChanged += (_, _) => img.Source = item.Icon;
|
||
IconLoader.Load(item, px);
|
||
img.Source = item.Icon;
|
||
icon = img;
|
||
}
|
||
else
|
||
{
|
||
var meta = e.Key.StartsWith("kategorie:") ? S.Categories.GetValueOrDefault(e.Name) : null;
|
||
icon = new TextBlock
|
||
{
|
||
Text = e.Glyph, FontFamily = (FontFamily)res["IconFont"], FontSize = 22, Width = 32, TextAlignment = TextAlignment.Center,
|
||
VerticalAlignment = VerticalAlignment.Center,
|
||
Foreground = CategoryColors.Brush(meta?.Color) ?? (Brush)res["AccentBrush"],
|
||
};
|
||
}
|
||
icon.Margin = new Thickness(0, 0, 14, 0);
|
||
DockPanel.SetDock(icon, Dock.Left);
|
||
var texts = new StackPanel { VerticalAlignment = VerticalAlignment.Center };
|
||
texts.Children.Add(new TextBlock { Text = e.Name, FontSize = 15, TextTrimming = TextTrimming.CharacterEllipsis });
|
||
texts.Children.Add(new TextBlock { Text = e.Sub, FontSize = 12, Foreground = (Brush)res["TextSecondaryBrush"] });
|
||
_list.Items.Add(new DockPanel { Margin = new Thickness(8, 6, 8, 6), Children = { icon, texts } });
|
||
}
|
||
if (_shown.Count == 0 && searching)
|
||
_list.Items.Add(new TextBlock
|
||
{
|
||
Text = "Nichts gefunden", Margin = new Thickness(12, 10, 12, 10), Foreground = (Brush)res["TextSecondaryBrush"],
|
||
IsHitTestVisible = false,
|
||
});
|
||
_list.Visibility = _list.Items.Count > 0 ? Visibility.Visible : Visibility.Collapsed;
|
||
if (_shown.Count > 0) _list.SelectedIndex = 0;
|
||
}
|
||
|
||
private void OnKey(object sender, KeyEventArgs e)
|
||
{
|
||
switch (e.Key)
|
||
{
|
||
case Key.Escape:
|
||
CloseNow();
|
||
e.Handled = true;
|
||
break;
|
||
case Key.Down or Key.Up when _shown.Count > 0:
|
||
int i = _list.SelectedIndex + (e.Key == Key.Down ? 1 : -1);
|
||
_list.SelectedIndex = (i + _shown.Count) % _shown.Count;
|
||
_list.ScrollIntoView(_list.SelectedItem);
|
||
e.Handled = true;
|
||
break;
|
||
case Key.Enter:
|
||
RunSelected(locate: Keyboard.Modifiers.HasFlag(ModifierKeys.Control));
|
||
e.Handled = true;
|
||
break;
|
||
}
|
||
}
|
||
|
||
private void RunSelected(bool locate)
|
||
{
|
||
if (_list.SelectedIndex < 0 || _list.SelectedIndex >= _shown.Count) return;
|
||
var entry = _shown[_list.SelectedIndex];
|
||
CloseNow(); // zuerst schließen, damit das gestartete Programm nach vorne kommt
|
||
try
|
||
{
|
||
if (locate && entry.Locate is not null) entry.Locate();
|
||
else
|
||
{
|
||
entry.Run();
|
||
// Kategorien/Seiten mitzählen (Dateien zählt Usage.Start, Gruppen LaunchGroupNow selbst)
|
||
if (entry.Path is null && !entry.Key.StartsWith(Usage.GroupPrefix)) Usage.Record(entry.Key);
|
||
}
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
App.TrayIcon?.Notify("Starten fehlgeschlagen", $"„{entry.Name}“ konnte nicht geöffnet werden.\n{ex.Message}");
|
||
}
|
||
}
|
||
|
||
[StructLayout(LayoutKind.Sequential)] private struct POINT { public int X, Y; }
|
||
[DllImport("user32.dll")] private static extern bool GetCursorPos(out POINT p);
|
||
[DllImport("user32.dll")] private static extern bool SetForegroundWindow(IntPtr hwnd);
|
||
}
|