Files
DeskCore/QuickSearch.cs
T
2026-10-04 14:48:53 +00:00

398 lines
16 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.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);
}