8 Commits

Author SHA1 Message Date
Ray Berezowski
6d9bbbab02 Add launcher item tooltips 2026-08-26 22:04:26 -04:00
Ray Berezowski
5421e3dde3 Restore menu keyboard navigation 2026-08-26 21:56:55 -04:00
Ray Berezowski
004a7e7c89 Polish launcher keyboard navigation 2026-08-26 21:49:23 -04:00
Ray Berezowski
f2ccca2ed1 Improve launcher search results 2026-08-26 21:45:57 -04:00
Ray Berezowski
c606573900 Add menu sort action 2026-08-26 21:41:45 -04:00
Ray Berezowski
86f667f595 Add selected item import export 2026-08-26 21:38:57 -04:00
Ray Berezowski
8ec53ff9d6 Move target validation to top of config menu 2026-08-26 21:34:16 -04:00
Ray Berezowski
691cb405fd Add target validation action 2026-08-26 21:30:13 -04:00
7 changed files with 660 additions and 34 deletions

View File

@@ -60,11 +60,15 @@
Margin="0,0,8,0">
<Button.ContextMenu>
<ContextMenu>
<MenuItem Header="Validate Targets" Click="ValidateTargets_Click"/>
<Separator/>
<MenuItem Header="Backup Config" Click="BackupConfig_Click"/>
<MenuItem Header="Restore from Backup" Click="RestoreConfig_Click"/>
<Separator/>
<MenuItem Header="Export Config" Click="ExportConfig_Click"/>
<MenuItem Header="Import Config" Click="ImportConfig_Click"/>
<MenuItem Header="Export Selected Item" Click="ExportSelectedItem_Click"/>
<MenuItem Header="Import Item" Click="ImportItem_Click"/>
<Separator/>
<MenuItem Header="Reset to Defaults" Click="ResetConfig_Click"/>
<MenuItem Header="Reload Config" Click="ReloadConfig_Click"/>

View File

@@ -22,6 +22,7 @@ public partial class MainWindow : Window
private List<int>? selectedPath;
private string? loadedAutoIconPath;
private System.Windows.Point dragStartPoint;
private sealed record TargetValidationIssue(string ItemPath, string Problem);
public MainWindow()
{
@@ -106,6 +107,12 @@ public partial class MainWindow : Window
var duplicateMenuItem = new System.Windows.Controls.MenuItem { Header = "Duplicate Item" };
duplicateMenuItem.Click += DuplicateItem_Click;
var sortMenuItem = new System.Windows.Controls.MenuItem
{
Header = item.Type == LauncherItemType.Menu ? "Sort This Menu" : "Sort This Level"
};
sortMenuItem.Click += SortSelected_Click;
var treeItem = new TreeViewItem
{
Header = $"{GetTreeStatusPrefix(item)}{item.Title} ({item.Type})",
@@ -116,6 +123,7 @@ public partial class MainWindow : Window
treeItem.ContextMenu.Items.Add(disableMenuItem);
treeItem.ContextMenu.Items.Add(openContainingFolderMenuItem);
treeItem.ContextMenu.Items.Add(duplicateMenuItem);
treeItem.ContextMenu.Items.Add(sortMenuItem);
treeItem.PreviewMouseRightButtonDown += (_, e) =>
{
treeItem.IsSelected = true;
@@ -1045,6 +1053,46 @@ public partial class MainWindow : Window
RefreshView(selectedItem);
}
private void SortSelected_Click(object sender, RoutedEventArgs e)
{
if (controller is null || selectedItem is null)
{
return;
}
ApplyCurrentItem();
LauncherItem itemToSelect = selectedItem;
if (selectedItem.Type == LauncherItemType.Menu)
{
SortItems(selectedItem.Children);
RefreshView(itemToSelect);
StatusText.Text = $"Sorted {itemToSelect.Title}. Click Save to keep it.";
return;
}
List<LauncherItem>? siblings = FindSiblings(controller.Config.Items, selectedItem);
if (siblings is null)
{
return;
}
SortItems(siblings);
RefreshView(itemToSelect);
StatusText.Text = $"Sorted this level. Click Save to keep it.";
}
private static void SortItems(List<LauncherItem> items)
{
List<LauncherItem> sortedItems = items
.OrderByDescending(item => item.IsFavorite)
.ThenBy(item => item.Title, StringComparer.CurrentCultureIgnoreCase)
.ThenBy(item => item.Type)
.ToList();
items.Clear();
items.AddRange(sortedItems);
}
private static bool RemoveItem(List<LauncherItem> items, LauncherItem item)
{
if (items.Remove(item))
@@ -1259,6 +1307,114 @@ public partial class MainWindow : Window
}
}
private void ExportSelectedItem_Click(object sender, RoutedEventArgs e)
{
if (selectedItem is null)
{
MessageBox.Show("Select an item or menu to export first.", "Taskbar Launcher");
return;
}
ApplyCurrentItem();
string safeTitle = MakeSafeFileName(string.IsNullOrWhiteSpace(selectedItem.Title) ? "launcher-item" : selectedItem.Title);
using SaveFileDialog dialog = new()
{
Title = "Export selected launcher item",
Filter = "JSON item files|*.json|All files|*.*",
FileName = $"{safeTitle}-{DateTime.Now:yyyyMMdd-HHmmss}.json",
InitialDirectory = Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments)
};
if (dialog.ShowDialog() != System.Windows.Forms.DialogResult.OK)
{
return;
}
try
{
string exportPath = ConfigService.ExportItemTo(dialog.FileName, selectedItem);
StatusText.Text = $"Exported {selectedItem.Title} to {exportPath}";
}
catch (Exception ex)
{
MessageBox.Show($"Could not export selected item.\n\n{ex.Message}", "Taskbar Launcher");
}
}
private void ImportItem_Click(object sender, RoutedEventArgs e)
{
if (controller is null)
{
return;
}
using OpenFileDialog dialog = new()
{
Title = "Import launcher item",
Filter = "JSON item files|*.json|All files|*.*",
InitialDirectory = Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments)
};
if (dialog.ShowDialog() != System.Windows.Forms.DialogResult.OK)
{
return;
}
try
{
ApplyCurrentItem();
LauncherItem importedItem = ConfigService.ImportItemFrom(dialog.FileName);
AddImportedItem(importedItem);
selectedPath = FindItemPath(importedItem);
RefreshView(importedItem);
StatusText.Text = $"Imported {importedItem.Title}. Click Save to keep it.";
}
catch (Exception ex)
{
MessageBox.Show($"Could not import item.\n\n{ex.Message}", "Taskbar Launcher");
}
}
private void AddImportedItem(LauncherItem importedItem)
{
if (controller is null)
{
return;
}
if (selectedItem is null)
{
controller.Config.Items.Add(importedItem);
return;
}
if (selectedItem.Type == LauncherItemType.Menu)
{
selectedItem.Children.Add(importedItem);
return;
}
List<LauncherItem>? siblings = FindSiblings(controller.Config.Items, selectedItem);
if (siblings is null)
{
controller.Config.Items.Add(importedItem);
return;
}
int selectedIndex = siblings.IndexOf(selectedItem);
siblings.Insert(selectedIndex + 1, importedItem);
}
private static string MakeSafeFileName(string fileName)
{
foreach (char invalidChar in Path.GetInvalidFileNameChars())
{
fileName = fileName.Replace(invalidChar, '-');
}
return fileName.Trim();
}
private void ResetConfig_Click(object sender, RoutedEventArgs e)
{
MessageBoxResult result = MessageBox.Show(
@@ -1343,6 +1499,159 @@ public partial class MainWindow : Window
ConfigService.OpenConfigFolder();
}
private void ValidateTargets_Click(object sender, RoutedEventArgs e)
{
if (controller is null)
{
return;
}
ApplyConfigFields();
List<TargetValidationIssue> issues = [];
ValidateTargets(controller.Config.Items, [], issues);
if (issues.Count == 0)
{
StatusText.Text = "Validation complete. No broken targets found.";
MessageBox.Show("No broken targets found.", "Taskbar Launcher");
return;
}
string message = $"Found {issues.Count} possible broken target(s):\n\n" +
string.Join("\n", issues.Take(20).Select(issue => $"- {issue.ItemPath}: {issue.Problem}"));
if (issues.Count > 20)
{
message += $"\n\nShowing first 20 of {issues.Count}.";
}
StatusText.Text = $"Validation found {issues.Count} possible broken target(s).";
MessageBox.Show(message, "Taskbar Launcher");
}
private static void ValidateTargets(IEnumerable<LauncherItem> items, List<string> parentNames, List<TargetValidationIssue> issues)
{
foreach (LauncherItem item in items)
{
List<string> itemPath = [.. parentNames, string.IsNullOrWhiteSpace(item.Title) ? "Untitled" : item.Title];
if (!item.IsDisabled && TryGetTargetIssue(item, out string? problem) && problem is not null)
{
issues.Add(new TargetValidationIssue(string.Join(" > ", itemPath), problem));
}
if (item.Children.Count > 0)
{
ValidateTargets(item.Children, itemPath, issues);
}
}
}
private static bool TryGetTargetIssue(LauncherItem item, out string? problem)
{
problem = null;
if (item.Type == LauncherItemType.Menu || item.IsDisabled)
{
return false;
}
if (string.IsNullOrWhiteSpace(item.Target))
{
problem = "Target is blank.";
return true;
}
string target = Environment.ExpandEnvironmentVariables(item.Target.Trim());
switch (item.Type)
{
case LauncherItemType.Folder:
if (!Directory.Exists(target))
{
problem = "Folder does not exist.";
return true;
}
return false;
case LauncherItemType.Website:
if (!Uri.TryCreate(target, UriKind.Absolute, out Uri? uri) ||
(uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps))
{
problem = "Website target is not a valid http or https URL.";
return true;
}
return false;
case LauncherItemType.App:
if (!File.Exists(target) && !CommandExistsOnPath(target))
{
problem = "Application or shortcut was not found.";
return true;
}
return false;
case LauncherItemType.File:
if (!File.Exists(target))
{
problem = "File does not exist.";
return true;
}
return false;
default:
problem = "Item type is not recognized.";
return true;
}
}
private static bool CommandExistsOnPath(string target)
{
if (target.IndexOfAny([Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar]) >= 0)
{
return false;
}
string? pathValue = Environment.GetEnvironmentVariable("PATH");
if (string.IsNullOrWhiteSpace(pathValue))
{
return false;
}
string extension = Path.GetExtension(target);
IEnumerable<string> candidateNames = string.IsNullOrWhiteSpace(extension)
? GetExecutableCandidateNames(target)
: [target];
foreach (string directory in pathValue.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
{
foreach (string candidateName in candidateNames)
{
string candidatePath = Path.Combine(directory, candidateName);
if (File.Exists(candidatePath))
{
return true;
}
}
}
return false;
}
private static IEnumerable<string> GetExecutableCandidateNames(string target)
{
string? pathExtValue = Environment.GetEnvironmentVariable("PATHEXT");
string[] extensions = string.IsNullOrWhiteSpace(pathExtValue)
? [".EXE", ".CMD", ".BAT", ".COM"]
: pathExtValue.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
foreach (string extension in extensions)
{
yield return target + extension;
}
}
private void About_Click(object sender, RoutedEventArgs e)
{
AboutWindow aboutWindow = new()

View File

@@ -12,13 +12,18 @@ Download the compiled Windows release from:
- System tray launcher
- Global hotkey, default `Ctrl+Alt+Space`
- Search/filter in the launcher popup
- Search/filter in the launcher popup with parent menu context
- Keyboard navigation in the launcher popup
- Tooltips for launcher items, menu paths, targets, and arguments
- Recent items section for quickly relaunching entries
- Nested menus, drag/drop ordering, and drag/drop item creation in Settings
- Right-click launcher entries to edit them in Settings
- Right-click launcher entries to pin or unpin them from the top
- Right-click launcher entries or Settings items to temporarily disable them
- Right-click app/file entries to open their containing folder
- Validate missing app, file, and folder targets from Settings
- Export and import selected items or menu sections
- Sort a selected menu or item level alphabetically
- Right-click item duplication in Settings
- Right-click items in Settings to pin them to the top of their menu
- Per-item icon and display mode

View File

@@ -80,6 +80,49 @@ public static class ConfigService
return destinationPath;
}
public static string ExportItemTo(string destinationPath, LauncherItem item)
{
if (string.IsNullOrWhiteSpace(destinationPath))
{
throw new ArgumentException("Choose a destination path for the exported item.", nameof(destinationPath));
}
string? destinationFolder = Path.GetDirectoryName(destinationPath);
if (!string.IsNullOrWhiteSpace(destinationFolder))
{
Directory.CreateDirectory(destinationFolder);
}
NormalizeDisplayModes(item);
string json = JsonSerializer.Serialize(item, JsonOptions);
File.WriteAllText(destinationPath, json);
return destinationPath;
}
public static LauncherItem ImportItemFrom(string sourcePath)
{
if (!File.Exists(sourcePath))
{
throw new FileNotFoundException("The selected item file was not found.", sourcePath);
}
string json = File.ReadAllText(sourcePath);
LauncherItem item;
try
{
item = JsonSerializer.Deserialize<LauncherItem>(json, JsonOptions)
?? throw new InvalidDataException("The selected item file could not be read.");
}
catch (JsonException ex)
{
throw new InvalidDataException("The selected file is not a valid Taskbar Launcher item export.", ex);
}
ValidateItem(item);
NormalizeDisplayModes(item);
return item;
}
public static LauncherConfig ImportFrom(string sourcePath)
{
LauncherConfig config = ReadConfigFile(sourcePath);

View File

@@ -12,8 +12,8 @@
<Authors>Ray Berezowski</Authors>
<ApplicationIcon>Assets\AppIcon.ico</ApplicationIcon>
<Version>1.0.1</Version>
<AssemblyVersion>1.0.1.9</AssemblyVersion>
<FileVersion>1.0.1.9</FileVersion>
<AssemblyVersion>1.0.1.17</AssemblyVersion>
<FileVersion>1.0.1.17</FileVersion>
<InformationalVersion>1.0.1</InformationalVersion>
</PropertyGroup>

View File

@@ -14,6 +14,7 @@ public partial class LauncherPopup : Window
private const double HeaderAndChromeHeight = 118;
private bool isRepositionQueued;
private readonly List<System.Windows.Controls.Button> visibleLaunchButtons = [];
private readonly List<System.Windows.Controls.Control> visibleNavigationControls = [];
private readonly LauncherConfig config;
private readonly Action<LauncherItem> launchItem;
@@ -21,6 +22,7 @@ public partial class LauncherPopup : Window
private readonly Action showSettings;
private readonly Action<LauncherItem> editItem;
private readonly Action<LauncherItem> openContainingFolder;
private sealed record SearchMatch(LauncherItem Item, string ParentPath);
public LauncherPopup(LauncherConfig config, Action<LauncherItem> launchItem, Action<LauncherItem> openAllItems, Action showSettings, Action<LauncherItem> editItem, Action<LauncherItem> openContainingFolder)
{
@@ -95,6 +97,7 @@ public partial class LauncherPopup : Window
{
ItemsPanel.Children.Clear();
visibleLaunchButtons.Clear();
visibleNavigationControls.Clear();
if (config.RecentItems.Any(item => !item.IsDisabled))
{
@@ -106,6 +109,8 @@ public partial class LauncherPopup : Window
{
ItemsPanel.Children.Add(CreateItemControl(item, 0, expandMenusByDefault));
}
RebuildNavigationLists();
}
private Expander CreateRecentItemsControl()
@@ -114,8 +119,12 @@ public partial class LauncherPopup : Window
{
Header = new TextBlock { Text = "Recent" },
IsExpanded = true,
Margin = new Thickness(0, 4, 0, 8)
Margin = new Thickness(0, 4, 0, 8),
Focusable = true
};
expander.KeyDown += NavigationControl_KeyDown;
expander.Expanded += (_, _) => RebuildNavigationAndReposition();
expander.Collapsed += (_, _) => RebuildNavigationAndReposition();
StackPanel recentItems = new();
foreach (LauncherItem item in config.RecentItems.Where(item => !item.IsDisabled))
@@ -135,8 +144,14 @@ public partial class LauncherPopup : Window
{
Header = CreateMenuHeader(item),
IsExpanded = expandMenusByDefault,
Margin = new Thickness(depth * 12, 4, 0, 4)
Margin = new Thickness(depth * 12, 4, 0, 4),
Focusable = true,
Tag = item,
ToolTip = CreateItemToolTip(item)
};
expander.KeyDown += NavigationControl_KeyDown;
expander.Expanded += (_, _) => RebuildNavigationAndReposition();
expander.Collapsed += (_, _) => RebuildNavigationAndReposition();
StackPanel children = new();
foreach (LauncherItem child in GetDisplayItems(item.Children))
@@ -154,7 +169,8 @@ public partial class LauncherPopup : Window
Margin = new Thickness(depth * 12, 2, 0, 2),
MinHeight = DisplayModeMetrics.GetRowHeight(GetEffectiveDisplayMode(item)),
HorizontalContentAlignment = System.Windows.HorizontalAlignment.Left,
Tag = item
Tag = item,
ToolTip = CreateItemToolTip(item)
};
button.Click += (_, _) => launchItem(item);
@@ -163,20 +179,7 @@ public partial class LauncherPopup : Window
button.ContextMenu = CreateItemContextMenu(item);
}
button.KeyDown += (_, e) =>
{
if (e.Key == Key.Enter)
{
launchItem(item);
e.Handled = true;
}
else if (e.Key == Key.Escape)
{
Close();
e.Handled = true;
}
};
visibleLaunchButtons.Add(button);
button.KeyDown += LaunchButton_KeyDown;
return button;
}
@@ -203,7 +206,8 @@ public partial class LauncherPopup : Window
return new TextBlock
{
Text = item.Title,
ContextMenu = contextMenu
ContextMenu = contextMenu,
ToolTip = CreateItemToolTip(item)
};
}
@@ -322,8 +326,8 @@ public partial class LauncherPopup : Window
ItemsPanel.Children.Clear();
visibleLaunchButtons.Clear();
List<LauncherItem> matches = [];
CollectMatches(config.Items, query, matches);
List<SearchMatch> matches = [];
CollectMatches(config.Items, query, [], matches);
if (matches.Count == 0)
{
@@ -336,34 +340,158 @@ public partial class LauncherPopup : Window
return;
}
foreach (LauncherItem item in matches)
foreach (SearchMatch match in matches)
{
ItemsPanel.Children.Add(CreateItemControl(item, 0, expandMenusByDefault: false));
ItemsPanel.Children.Add(CreateSearchResultControl(match));
}
RebuildNavigationLists();
}
private FrameworkElement CreateSearchResultControl(SearchMatch match)
{
System.Windows.Controls.Button button = new()
{
Content = CreateSearchResultContent(match),
Margin = new Thickness(0, 2, 0, 2),
MinHeight = Math.Max(DisplayModeMetrics.GetRowHeight(GetEffectiveDisplayMode(match.Item)), 48),
HorizontalContentAlignment = System.Windows.HorizontalAlignment.Left,
Tag = match.Item,
ContextMenu = CreateItemContextMenu(match.Item),
ToolTip = CreateItemToolTip(match.Item, match.ParentPath)
};
button.Click += (_, _) => launchItem(match.Item);
button.KeyDown += LaunchButton_KeyDown;
return button;
}
private void RebuildNavigationAndReposition()
{
RebuildNavigationLists();
QueueReposition();
}
private void RebuildNavigationLists()
{
visibleLaunchButtons.Clear();
visibleNavigationControls.Clear();
AddVisibleNavigationControls(ItemsPanel.Children);
}
private void AddVisibleNavigationControls(UIElementCollection elements)
{
foreach (UIElement element in elements)
{
switch (element)
{
case Expander expander:
visibleNavigationControls.Add(expander);
if (expander.IsExpanded && expander.Content is System.Windows.Controls.Panel panel)
{
AddVisibleNavigationControls(panel.Children);
}
break;
case System.Windows.Controls.Button button:
visibleNavigationControls.Add(button);
if (button.Tag is LauncherItem)
{
visibleLaunchButtons.Add(button);
}
break;
}
}
}
private static void CollectMatches(IEnumerable<LauncherItem> items, string query, List<LauncherItem> matches)
private object CreateSearchResultContent(SearchMatch match)
{
StackPanel panel = new();
panel.Children.Add((UIElement)CreateButtonContent(match.Item));
if (!string.IsNullOrWhiteSpace(match.ParentPath))
{
panel.Children.Add(new TextBlock
{
Text = match.ParentPath,
FontSize = 11,
Foreground = (System.Windows.Media.Brush)System.Windows.Application.Current.Resources["AppMutedTextBrush"],
Margin = new Thickness(0, 3, 0, 0),
TextTrimming = TextTrimming.CharacterEllipsis
});
}
return panel;
}
private static System.Windows.Controls.ToolTip CreateItemToolTip(LauncherItem item, string? parentPath = null)
{
StackPanel panel = new() { MaxWidth = 520 };
AddToolTipLine(panel, item.Title, bold: true);
AddToolTipLine(panel, $"Type: {item.Type}");
if (!string.IsNullOrWhiteSpace(parentPath))
{
AddToolTipLine(panel, $"Menu: {parentPath}");
}
if (!string.IsNullOrWhiteSpace(item.Target))
{
AddToolTipLine(panel, $"Target: {item.Target}");
}
if (!string.IsNullOrWhiteSpace(item.Arguments))
{
AddToolTipLine(panel, $"Arguments: {item.Arguments}");
}
if (item.IsFavorite)
{
AddToolTipLine(panel, "Pinned to top");
}
return new System.Windows.Controls.ToolTip { Content = panel };
}
private static void AddToolTipLine(StackPanel panel, string text, bool bold = false)
{
panel.Children.Add(new TextBlock
{
Text = text,
FontWeight = bold ? FontWeights.SemiBold : FontWeights.Normal,
TextWrapping = TextWrapping.Wrap,
Margin = panel.Children.Count == 0 ? new Thickness(0) : new Thickness(0, 3, 0, 0)
});
}
private static void CollectMatches(IEnumerable<LauncherItem> items, string query, List<string> parentNames, List<SearchMatch> matches)
{
foreach (LauncherItem item in GetDisplayItems(items))
{
bool isLaunchable = item.Type != LauncherItemType.Menu && !item.IsDisabled;
if (isLaunchable && Matches(item, query))
string parentPath = string.Join(" > ", parentNames);
if (isLaunchable && Matches(item, query, parentPath))
{
matches.Add(item);
matches.Add(new SearchMatch(item, parentPath));
}
if (item.Children.Count > 0)
{
CollectMatches(item.Children, query, matches);
string menuName = string.IsNullOrWhiteSpace(item.Title) ? "Untitled" : item.Title;
CollectMatches(item.Children, query, [.. parentNames, menuName], matches);
}
}
}
private static bool Matches(LauncherItem item, string query)
private static bool Matches(LauncherItem item, string query, string parentPath)
{
return item.Title.Contains(query, StringComparison.OrdinalIgnoreCase) ||
item.Target.Contains(query, StringComparison.OrdinalIgnoreCase) ||
(item.Arguments?.Contains(query, StringComparison.OrdinalIgnoreCase) ?? false);
(item.Arguments?.Contains(query, StringComparison.OrdinalIgnoreCase) ?? false) ||
parentPath.Contains(query, StringComparison.OrdinalIgnoreCase);
}
private void SearchBox_PreviewKeyDown(object sender, System.Windows.Input.KeyEventArgs e)
@@ -382,14 +510,142 @@ public partial class LauncherPopup : Window
return;
}
if (e.Key == Key.Down && visibleLaunchButtons.FirstOrDefault() is System.Windows.Controls.Button firstButton)
if (e.Key == Key.Down)
{
Keyboard.ClearFocus();
firstButton.Focus();
FocusNavigationControl(0);
e.Handled = true;
return;
}
if (e.Key == Key.Up)
{
FocusNavigationControl(visibleNavigationControls.Count - 1);
e.Handled = true;
return;
}
if (e.Key == Key.Home)
{
SearchBox.CaretIndex = 0;
e.Handled = true;
return;
}
if (e.Key == Key.End)
{
SearchBox.CaretIndex = SearchBox.Text.Length;
e.Handled = true;
}
}
private void LaunchButton_KeyDown(object sender, System.Windows.Input.KeyEventArgs e)
{
if (sender is not System.Windows.Controls.Button button)
{
return;
}
if (e.Key == Key.Enter && button.Tag is LauncherItem item)
{
launchItem(item);
e.Handled = true;
return;
}
if (e.Key == Key.Escape)
{
Close();
e.Handled = true;
return;
}
HandleNavigationControlKey(button, e);
}
private void NavigationControl_KeyDown(object sender, System.Windows.Input.KeyEventArgs e)
{
if (sender is not System.Windows.Controls.Control control)
{
return;
}
if (e.Key == Key.Enter && control is Expander expander)
{
expander.IsExpanded = !expander.IsExpanded;
QueueReposition();
e.Handled = true;
return;
}
if (e.Key == Key.Escape)
{
Close();
e.Handled = true;
return;
}
HandleNavigationControlKey(control, e);
}
private void HandleNavigationControlKey(System.Windows.Controls.Control control, System.Windows.Input.KeyEventArgs e)
{
int index = visibleNavigationControls.IndexOf(control);
if (index < 0)
{
return;
}
if (e.Key == Key.Down)
{
FocusNavigationControl(index + 1);
e.Handled = true;
return;
}
if (e.Key == Key.Up)
{
if (index == 0)
{
SearchBox.Focus();
SearchBox.CaretIndex = SearchBox.Text.Length;
}
else
{
FocusNavigationControl(index - 1);
}
e.Handled = true;
return;
}
if (e.Key == Key.Home)
{
FocusNavigationControl(0);
e.Handled = true;
return;
}
if (e.Key == Key.End)
{
FocusNavigationControl(visibleNavigationControls.Count - 1);
e.Handled = true;
}
}
private void FocusNavigationControl(int index)
{
if (visibleNavigationControls.Count == 0)
{
return;
}
int clampedIndex = Math.Clamp(index, 0, visibleNavigationControls.Count - 1);
System.Windows.Controls.Control control = visibleNavigationControls[clampedIndex];
Keyboard.ClearFocus();
control.Focus();
control.BringIntoView();
}
protected override void OnKeyDown(System.Windows.Input.KeyEventArgs e)
{
if (e.Key == Key.Escape)

View File

@@ -26,6 +26,12 @@ Install the **.NET Desktop Runtime** for **Windows x64**.
Recently launched entries appear in the launcher popup under **Recent**.
Search results include the parent menu path when an item is inside a menu.
Use arrow keys, Home, End, Enter, and Escape to navigate the launcher popup from the keyboard.
Hover over launcher items to see details such as type, menu path, target, and arguments.
## Configure
Open the tray menu and choose **Settings**.
@@ -41,6 +47,9 @@ From Settings you can:
- Right-click an item and choose **Pin to Top** to show it first in its menu
- Right-click an item and choose **Disable Item** or **Enable Item** to hide or restore it
- Right-click an app or file item and choose **Open Containing Folder**
- Use **Config... > Validate Targets** to check for missing app, file, and folder targets
- Use **Config... > Export Selected Item** or **Import Item** to share one item or menu section
- Right-click an item and choose **Sort This Menu** or **Sort This Level**
- Set per-item icon sizes
- Back up and restore `config.json`
- Enable Start with Windows