22 Commits

Author SHA1 Message Date
Ray Berezowski
5d18022cde Remove dev-only notes from public release 2026-09-04 14:53:19 -04:00
Ray Berezowski
2525499c0f Merge dev for v1.0.2 2026-09-04 14:53:08 -04:00
Ray Berezowski
e956760a0d Prepare v1.0.2 release 2026-09-04 14:52:59 -04:00
Ray Berezowski
b96347843c Polish settings item editor layout 2026-08-26 22:48:29 -04:00
Ray Berezowski
0baf87d979 Add launcher item notes 2026-08-26 22:43:58 -04:00
Ray Berezowski
57b2c4b0cc Add working folder and minimized launch options 2026-08-26 22:37:09 -04:00
Ray Berezowski
c5fd18e6d3 Fix admin launch and settings visibility 2026-08-26 22:30:00 -04:00
Ray Berezowski
1601da8155 Add run as administrator option 2026-08-26 22:10:55 -04:00
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
Ray Berezowski
992ac9a7b5 Add open containing folder action 2026-08-26 21:24:25 -04:00
Ray Berezowski
31c62bb89a Add disabled launcher items 2026-08-26 21:06:25 -04:00
Ray Berezowski
fe86573e10 Add popup pin toggle 2026-08-26 21:01:05 -04:00
Ray Berezowski
9f1b012d15 Add pinned launcher items 2026-08-26 20:41:20 -04:00
Ray Berezowski
749a856faa Add recent launcher items 2026-08-26 19:58:03 -04:00
Ray Berezowski
32f08bc0fd Add launcher edit action 2026-08-26 19:49:49 -04:00
15 changed files with 1476 additions and 165 deletions

View File

@@ -8,6 +8,26 @@
MinHeight="660" MinWidth="860" MinHeight="660" MinWidth="860"
Background="{DynamicResource AppWindowBrush}" Background="{DynamicResource AppWindowBrush}"
Foreground="{DynamicResource AppTextBrush}"> Foreground="{DynamicResource AppTextBrush}">
<Window.Resources>
<Style x:Key="SettingsSectionBorder" TargetType="Border">
<Setter Property="Background" Value="{DynamicResource AppPanelBrush}"/>
<Setter Property="BorderBrush" Value="{DynamicResource AppBorderBrush}"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="CornerRadius" Value="12"/>
<Setter Property="Padding" Value="12"/>
<Setter Property="Margin" Value="0,0,0,12"/>
</Style>
<Style x:Key="SettingsSectionTitle" TargetType="TextBlock">
<Setter Property="FontWeight" Value="SemiBold"/>
<Setter Property="FontSize" Value="14"/>
<Setter Property="Margin" Value="0,0,0,10"/>
</Style>
<Style x:Key="SettingsFieldLabel" TargetType="TextBlock">
<Setter Property="VerticalAlignment" Value="Center"/>
<Setter Property="Foreground" Value="{DynamicResource AppMutedTextBrush}"/>
<Setter Property="Margin" Value="0,0,8,8"/>
</Style>
</Window.Resources>
<DockPanel Margin="18"> <DockPanel Margin="18">
<Border DockPanel.Dock="Top" <Border DockPanel.Dock="Top"
Margin="0,0,0,16" Margin="0,0,0,16"
@@ -60,11 +80,15 @@
Margin="0,0,8,0"> Margin="0,0,8,0">
<Button.ContextMenu> <Button.ContextMenu>
<ContextMenu> <ContextMenu>
<MenuItem Header="Validate Targets" Click="ValidateTargets_Click"/>
<Separator/>
<MenuItem Header="Backup Config" Click="BackupConfig_Click"/> <MenuItem Header="Backup Config" Click="BackupConfig_Click"/>
<MenuItem Header="Restore from Backup" Click="RestoreConfig_Click"/> <MenuItem Header="Restore from Backup" Click="RestoreConfig_Click"/>
<Separator/> <Separator/>
<MenuItem Header="Export Config" Click="ExportConfig_Click"/> <MenuItem Header="Export Config" Click="ExportConfig_Click"/>
<MenuItem Header="Import Config" Click="ImportConfig_Click"/> <MenuItem Header="Import Config" Click="ImportConfig_Click"/>
<MenuItem Header="Export Selected Item" Click="ExportSelectedItem_Click"/>
<MenuItem Header="Import Item" Click="ImportItem_Click"/>
<Separator/> <Separator/>
<MenuItem Header="Reset to Defaults" Click="ResetConfig_Click"/> <MenuItem Header="Reset to Defaults" Click="ResetConfig_Click"/>
<MenuItem Header="Reload Config" Click="ReloadConfig_Click"/> <MenuItem Header="Reload Config" Click="ReloadConfig_Click"/>
@@ -111,8 +135,8 @@
<Grid.RowDefinitions> <Grid.RowDefinitions>
<RowDefinition Height="Auto"/> <RowDefinition Height="Auto"/>
<RowDefinition Height="16"/> <RowDefinition Height="16"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/> <RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions> </Grid.RowDefinitions>
<Border Background="{DynamicResource AppPanelBrush}" <Border Background="{DynamicResource AppPanelBrush}"
@@ -130,6 +154,7 @@
<RowDefinition Height="Auto"/> <RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/> <RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/> <RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions> </Grid.RowDefinitions>
<TextBlock Text="Hotkey" VerticalAlignment="Center" Margin="0,0,8,8"/> <TextBlock Text="Hotkey" VerticalAlignment="Center" Margin="0,0,8,8"/>
@@ -154,76 +179,148 @@
<Separator Grid.Row="1" VerticalAlignment="Center"/> <Separator Grid.Row="1" VerticalAlignment="Center"/>
<Border Grid.Row="2" <ScrollViewer Grid.Row="2"
Background="{DynamicResource AppPanelBrush}" VerticalScrollBarVisibility="Auto"
BorderBrush="{DynamicResource AppBorderBrush}" HorizontalScrollBarVisibility="Disabled">
BorderThickness="1" <StackPanel>
CornerRadius="12" <Border Style="{StaticResource SettingsSectionBorder}">
Padding="12"> <Grid>
<Grid> <Grid.ColumnDefinitions>
<Grid.ColumnDefinitions> <ColumnDefinition Width="110"/>
<ColumnDefinition Width="110"/> <ColumnDefinition Width="*"/>
<ColumnDefinition Width="*"/> <ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/> </Grid.ColumnDefinitions>
</Grid.ColumnDefinitions> <Grid.RowDefinitions>
<Grid.RowDefinitions> <RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/> <RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/> <RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/> <RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/> <RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/> </Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<TextBlock Text="Title" VerticalAlignment="Center" Margin="0,0,8,8"/> <TextBlock Text="Item" Grid.ColumnSpan="3" Style="{StaticResource SettingsSectionTitle}"/>
<TextBox x:Name="TitleBox" Grid.Column="1" Grid.ColumnSpan="2" Margin="0,0,0,8"
TextChanged="PreviewField_Changed"/>
<TextBlock Text="Type" Grid.Row="1" VerticalAlignment="Center" Margin="0,0,8,8"/> <TextBlock Text="Title" Grid.Row="1" Style="{StaticResource SettingsFieldLabel}"/>
<ComboBox x:Name="TypeBox" Grid.Row="1" Grid.Column="1" Grid.ColumnSpan="2" Margin="0,0,0,8" <TextBox x:Name="TitleBox" Grid.Row="1" Grid.Column="1" Grid.ColumnSpan="2" Margin="0,0,0,8"
SelectionChanged="TypeBox_SelectionChanged"/> TextChanged="PreviewField_Changed"/>
<TextBlock Text="Target" Grid.Row="2" VerticalAlignment="Center" Margin="0,0,8,8"/> <TextBlock Text="Type" Grid.Row="2" Style="{StaticResource SettingsFieldLabel}"/>
<TextBox x:Name="TargetBox" Grid.Row="2" Grid.Column="1" Margin="0,0,8,8" <ComboBox x:Name="TypeBox" Grid.Row="2" Grid.Column="1" Grid.ColumnSpan="2" Margin="0,0,0,8"
TextChanged="PreviewField_Changed"/> SelectionChanged="TypeBox_SelectionChanged"/>
<StackPanel Grid.Row="2" Grid.Column="2" Orientation="Horizontal" Margin="0,0,0,8">
<Button Content="File" Click="BrowseFile_Click" Margin="0,0,6,0" Padding="8,4"/> <TextBlock Text="Target" Grid.Row="3" Style="{StaticResource SettingsFieldLabel}"/>
<Button Content="Folder" Click="BrowseFolder_Click" Padding="8,4"/> <TextBox x:Name="TargetBox" Grid.Row="3" Grid.Column="1" Margin="0,0,8,8"
TextChanged="PreviewField_Changed"/>
<StackPanel Grid.Row="3" Grid.Column="2" Orientation="Horizontal" Margin="0,0,0,8">
<Button Content="File" Click="BrowseFile_Click" Margin="0,0,6,0" Padding="8,4"/>
<Button Content="Folder" Click="BrowseFolder_Click" Padding="8,4"/>
</StackPanel>
<TextBlock Text="Notes" Grid.Row="4" VerticalAlignment="Top" Foreground="{DynamicResource AppMutedTextBrush}" Margin="0,4,8,0"/>
<TextBox x:Name="NotesBox"
Grid.Row="4"
Grid.Column="1"
Grid.ColumnSpan="2"
Margin="0"
AcceptsReturn="True"
MinHeight="58"
TextWrapping="Wrap"
VerticalScrollBarVisibility="Auto"
TextChanged="PreviewField_Changed"/>
</Grid>
</Border>
<Border Style="{StaticResource SettingsSectionBorder}">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="110"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<TextBlock Text="Launch" Grid.ColumnSpan="3" Style="{StaticResource SettingsSectionTitle}"/>
<TextBlock Text="Arguments" Grid.Row="1" Style="{StaticResource SettingsFieldLabel}"/>
<TextBox x:Name="ArgumentsBox" Grid.Row="1" Grid.Column="1" Grid.ColumnSpan="2" Margin="0,0,0,8"/>
<TextBlock Text="Working Folder" Grid.Row="2" Style="{StaticResource SettingsFieldLabel}"/>
<TextBox x:Name="WorkingFolderBox" Grid.Row="2" Grid.Column="1" Margin="0,0,8,8"/>
<Button x:Name="WorkingFolderBrowseButton"
Grid.Row="2" Grid.Column="2"
Content="Folder"
Click="BrowseWorkingFolder_Click"
Padding="8,4"
Margin="0,0,0,8"/>
<TextBlock Text="Options" Grid.Row="3" VerticalAlignment="Top" Foreground="{DynamicResource AppMutedTextBrush}" Margin="0,2,8,0"/>
<StackPanel Grid.Row="3" Grid.Column="1" Grid.ColumnSpan="2">
<CheckBox x:Name="RunAsAdminBox"
Content="Run as administrator"
Margin="0,0,0,6"
Checked="LaunchOptionBox_Changed"
Unchecked="LaunchOptionBox_Changed"/>
<CheckBox x:Name="StartMinimizedBox"
Content="Start minimized"
Checked="LaunchOptionBox_Changed"
Unchecked="LaunchOptionBox_Changed"/>
</StackPanel>
</Grid>
</Border>
<Border Style="{StaticResource SettingsSectionBorder}">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="110"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<TextBlock Text="Appearance" Grid.ColumnSpan="3" Style="{StaticResource SettingsSectionTitle}"/>
<TextBlock Text="Icon Path" Grid.Row="1" Style="{StaticResource SettingsFieldLabel}"/>
<TextBox x:Name="IconPathBox" Grid.Row="1" Grid.Column="1" Margin="0,0,8,8"
TextChanged="PreviewField_Changed"/>
<StackPanel Grid.Row="1" Grid.Column="2" Orientation="Horizontal" Margin="0,0,0,8">
<Button Content="Icon" Click="BrowseIcon_Click" Padding="8,4" Margin="0,0,6,0"/>
<Button Content="Reset" Click="ResetIcon_Click" Padding="8,4"/>
</StackPanel>
<TextBlock Text="Display" Grid.Row="2" Style="{StaticResource SettingsFieldLabel}"/>
<ComboBox x:Name="ItemDisplayModeBox" Grid.Row="2" Grid.Column="1" Grid.ColumnSpan="2"
Margin="0" SelectionChanged="ItemDisplayModeBox_SelectionChanged"/>
</Grid>
</Border>
<Border Style="{StaticResource SettingsSectionBorder}" Margin="0">
<StackPanel>
<TextBlock Text="Preview" Style="{StaticResource SettingsSectionTitle}"/>
<Border BorderThickness="1"
BorderBrush="{DynamicResource AppBorderBrush}"
Background="{DynamicResource AppSurfaceBrush}"
CornerRadius="12"
Padding="10" MinHeight="76">
<StackPanel>
<ContentControl x:Name="PreviewHost"/>
<TextBlock x:Name="PreviewNote" Margin="0,8,0,0"
Foreground="{DynamicResource AppMutedTextBrush}"
TextWrapping="Wrap"/>
</StackPanel>
</Border>
</StackPanel>
</Border>
</StackPanel> </StackPanel>
</ScrollViewer>
<TextBlock Text="Arguments" Grid.Row="3" VerticalAlignment="Center" Margin="0,0,8,8"/>
<TextBox x:Name="ArgumentsBox" Grid.Row="3" Grid.Column="1" Grid.ColumnSpan="2" Margin="0,0,0,8"/>
<TextBlock Text="Icon Path" Grid.Row="4" VerticalAlignment="Center" Margin="0,0,8,0"/>
<TextBox x:Name="IconPathBox" Grid.Row="4" Grid.Column="1" Margin="0,0,8,8"
TextChanged="PreviewField_Changed"/>
<StackPanel Grid.Row="4" Grid.Column="2" Orientation="Horizontal" Margin="0,0,0,8">
<Button Content="Icon" Click="BrowseIcon_Click" Padding="8,4" Margin="0,0,6,0"/>
<Button Content="Reset" Click="ResetIcon_Click" Padding="8,4"/>
</StackPanel>
<TextBlock Text="Selected Item Display" Grid.Row="5" VerticalAlignment="Center" Margin="0,0,8,8"/>
<ComboBox x:Name="ItemDisplayModeBox" Grid.Row="5" Grid.Column="1" Grid.ColumnSpan="2"
Margin="0,0,0,8" SelectionChanged="ItemDisplayModeBox_SelectionChanged"/>
<TextBlock Text="Preview" Grid.Row="6" VerticalAlignment="Top" Margin="0,4,8,0"/>
<Border Grid.Row="6" Grid.Column="1" Grid.ColumnSpan="2"
BorderThickness="1"
BorderBrush="{DynamicResource AppBorderBrush}"
Background="{DynamicResource AppSurfaceBrush}"
CornerRadius="12"
Padding="10" MinHeight="76">
<StackPanel>
<ContentControl x:Name="PreviewHost"/>
<TextBlock x:Name="PreviewNote" Margin="0,8,0,0"
Foreground="{DynamicResource AppMutedTextBrush}"
TextWrapping="Wrap"/>
</StackPanel>
</Border>
</Grid>
</Border>
<TextBlock x:Name="StatusText" Grid.Row="3" Margin="4,14,4,0" <TextBlock x:Name="StatusText" Grid.Row="3" Margin="4,14,4,0"
Foreground="{DynamicResource AppMutedTextBrush}" Foreground="{DynamicResource AppMutedTextBrush}"

View File

@@ -22,6 +22,7 @@ public partial class MainWindow : Window
private List<int>? selectedPath; private List<int>? selectedPath;
private string? loadedAutoIconPath; private string? loadedAutoIconPath;
private System.Windows.Point dragStartPoint; private System.Windows.Point dragStartPoint;
private sealed record TargetValidationIssue(string ItemPath, string Problem);
public MainWindow() public MainWindow()
{ {
@@ -40,6 +41,18 @@ public partial class MainWindow : Window
RefreshView(); RefreshView();
} }
public void SelectItemForEdit(LauncherItem item)
{
if (controller is null)
{
return;
}
RefreshView(item);
Show();
Activate();
}
private void RefreshView(LauncherItem? itemToSelect = null) private void RefreshView(LauncherItem? itemToSelect = null)
{ {
if (controller is null) if (controller is null)
@@ -72,16 +85,45 @@ public partial class MainWindow : Window
private TreeViewItem CreateTreeItem(LauncherItem item) private TreeViewItem CreateTreeItem(LauncherItem item)
{ {
var pinMenuItem = new System.Windows.Controls.MenuItem
{
Header = item.IsFavorite ? "Unpin from Top" : "Pin to Top"
};
pinMenuItem.Click += ToggleFavorite_Click;
var disableMenuItem = new System.Windows.Controls.MenuItem
{
Header = item.IsDisabled ? "Enable Item" : "Disable Item"
};
disableMenuItem.Click += ToggleDisabled_Click;
var openContainingFolderMenuItem = new System.Windows.Controls.MenuItem
{
Header = "Open Containing Folder",
IsEnabled = CanOpenContainingFolder(item)
};
openContainingFolderMenuItem.Click += OpenContainingFolder_Click;
var duplicateMenuItem = new System.Windows.Controls.MenuItem { Header = "Duplicate Item" }; var duplicateMenuItem = new System.Windows.Controls.MenuItem { Header = "Duplicate Item" };
duplicateMenuItem.Click += DuplicateItem_Click; 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 var treeItem = new TreeViewItem
{ {
Header = $"{item.Title} ({item.Type})", Header = $"{GetTreeStatusPrefix(item)}{item.Title} ({item.Type})",
Tag = item, Tag = item,
ContextMenu = new System.Windows.Controls.ContextMenu() ContextMenu = new System.Windows.Controls.ContextMenu()
}; };
treeItem.ContextMenu.Items.Add(pinMenuItem);
treeItem.ContextMenu.Items.Add(disableMenuItem);
treeItem.ContextMenu.Items.Add(openContainingFolderMenuItem);
treeItem.ContextMenu.Items.Add(duplicateMenuItem); treeItem.ContextMenu.Items.Add(duplicateMenuItem);
treeItem.ContextMenu.Items.Add(sortMenuItem);
treeItem.PreviewMouseRightButtonDown += (_, e) => treeItem.PreviewMouseRightButtonDown += (_, e) =>
{ {
treeItem.IsSelected = true; treeItem.IsSelected = true;
@@ -96,6 +138,22 @@ public partial class MainWindow : Window
return treeItem; return treeItem;
} }
private static string GetTreeStatusPrefix(LauncherItem item)
{
List<string> parts = [];
if (item.IsFavorite)
{
parts.Add("Pinned");
}
if (item.IsDisabled)
{
parts.Add("Disabled");
}
return parts.Count == 0 ? "" : $"[{string.Join(", ", parts)}] ";
}
private bool SelectTreeItem(LauncherItem item) private bool SelectTreeItem(LauncherItem item)
{ {
foreach (object root in ItemsTree.Items) foreach (object root in ItemsTree.Items)
@@ -555,9 +613,13 @@ public partial class MainWindow : Window
TypeBox.SelectedItem = item.Type; TypeBox.SelectedItem = item.Type;
TargetBox.Text = item.Target; TargetBox.Text = item.Target;
ArgumentsBox.Text = item.Arguments ?? ""; ArgumentsBox.Text = item.Arguments ?? "";
WorkingFolderBox.Text = item.WorkingFolder ?? "";
NotesBox.Text = item.Notes ?? "";
IconPathBox.Text = item.IconPath ?? ""; IconPathBox.Text = item.IconPath ?? "";
loadedAutoIconPath = GetAutomaticIconPath(item); loadedAutoIconPath = GetAutomaticIconPath(item);
ItemDisplayModeBox.SelectedItem = item.DisplayMode; ItemDisplayModeBox.SelectedItem = item.DisplayMode;
RunAsAdminBox.IsChecked = item.RunAsAdmin;
StartMinimizedBox.IsChecked = item.StartMinimized;
UpdateTargetAvailability(); UpdateTargetAvailability();
isLoadingSelection = false; isLoadingSelection = false;
UpdatePreview(); UpdatePreview();
@@ -572,9 +634,13 @@ public partial class MainWindow : Window
TypeBox.SelectedIndex = -1; TypeBox.SelectedIndex = -1;
TargetBox.Text = ""; TargetBox.Text = "";
ArgumentsBox.Text = ""; ArgumentsBox.Text = "";
WorkingFolderBox.Text = "";
NotesBox.Text = "";
IconPathBox.Text = ""; IconPathBox.Text = "";
loadedAutoIconPath = null; loadedAutoIconPath = null;
ItemDisplayModeBox.SelectedItem = controller?.Config.DisplayMode; ItemDisplayModeBox.SelectedItem = controller?.Config.DisplayMode;
RunAsAdminBox.IsChecked = false;
StartMinimizedBox.IsChecked = false;
UpdateTargetAvailability(); UpdateTargetAvailability();
isLoadingSelection = false; isLoadingSelection = false;
UpdatePreview(); UpdatePreview();
@@ -593,12 +659,18 @@ public partial class MainWindow : Window
selectedItem.Arguments = selectedItem.Type == LauncherItemType.Menu || string.IsNullOrWhiteSpace(ArgumentsBox.Text) selectedItem.Arguments = selectedItem.Type == LauncherItemType.Menu || string.IsNullOrWhiteSpace(ArgumentsBox.Text)
? null ? null
: ArgumentsBox.Text.Trim(); : ArgumentsBox.Text.Trim();
selectedItem.WorkingFolder = LauncherController.CanUseLaunchOptions(selectedItem.Type) && !string.IsNullOrWhiteSpace(WorkingFolderBox.Text)
? WorkingFolderBox.Text.Trim()
: null;
selectedItem.Notes = string.IsNullOrWhiteSpace(NotesBox.Text) ? null : NotesBox.Text.Trim();
EnsureAutomaticIconPath(); EnsureAutomaticIconPath();
selectedItem.IconPath = string.IsNullOrWhiteSpace(IconPathBox.Text) ? null : IconPathBox.Text.Trim(); selectedItem.IconPath = string.IsNullOrWhiteSpace(IconPathBox.Text) ? null : IconPathBox.Text.Trim();
if (ItemDisplayModeBox.SelectedItem is LauncherDisplayMode itemMode) if (ItemDisplayModeBox.SelectedItem is LauncherDisplayMode itemMode)
{ {
selectedItem.DisplayMode = itemMode; selectedItem.DisplayMode = itemMode;
} }
selectedItem.RunAsAdmin = LauncherController.CanRunAsAdmin(selectedItem.Type) && RunAsAdminBox.IsChecked == true;
selectedItem.StartMinimized = LauncherController.CanUseLaunchOptions(selectedItem.Type) && StartMinimizedBox.IsChecked == true;
if (selectedItem.Type != LauncherItemType.Menu) if (selectedItem.Type != LauncherItemType.Menu)
{ {
@@ -791,6 +863,59 @@ public partial class MainWindow : Window
StatusText.Text = "Added submenu. Rename it, then Save."; StatusText.Text = "Added submenu. Rename it, then Save.";
} }
private void ToggleFavorite_Click(object sender, RoutedEventArgs e)
{
if (controller is null || selectedItem is null)
{
return;
}
ApplyCurrentItem();
selectedItem.IsFavorite = !selectedItem.IsFavorite;
LauncherItem itemToSelect = selectedItem;
selectedPath = FindItemPath(itemToSelect);
RefreshView(itemToSelect);
StatusText.Text = itemToSelect.IsFavorite
? $"Pinned {itemToSelect.Title}. Click Save to keep it."
: $"Unpinned {itemToSelect.Title}. Click Save to keep it.";
}
private void ToggleDisabled_Click(object sender, RoutedEventArgs e)
{
if (controller is null || selectedItem is null)
{
return;
}
ApplyCurrentItem();
selectedItem.IsDisabled = !selectedItem.IsDisabled;
LauncherItem itemToSelect = selectedItem;
selectedPath = FindItemPath(itemToSelect);
RefreshView(itemToSelect);
StatusText.Text = itemToSelect.IsDisabled
? $"Disabled {itemToSelect.Title}. Click Save to keep it."
: $"Enabled {itemToSelect.Title}. Click Save to keep it.";
}
private void OpenContainingFolder_Click(object sender, RoutedEventArgs e)
{
if (selectedItem is null)
{
return;
}
ApplyCurrentItem();
if (!LauncherController.OpenContainingFolder(selectedItem, out string? error) && !string.IsNullOrWhiteSpace(error))
{
MessageBox.Show(error, "Taskbar Launcher");
}
}
private static bool CanOpenContainingFolder(LauncherItem item)
{
return item.Type is LauncherItemType.App or LauncherItemType.File && !string.IsNullOrWhiteSpace(item.Target);
}
private void DuplicateItem_Click(object sender, RoutedEventArgs e) private void DuplicateItem_Click(object sender, RoutedEventArgs e)
{ {
if (controller is null || selectedItem is null) if (controller is null || selectedItem is null)
@@ -852,7 +977,13 @@ public partial class MainWindow : Window
Type = item.Type, Type = item.Type,
Target = item.Target, Target = item.Target,
Arguments = item.Arguments, Arguments = item.Arguments,
WorkingFolder = item.WorkingFolder,
Notes = item.Notes,
IconPath = item.IconPath, IconPath = item.IconPath,
IsFavorite = item.IsFavorite,
IsDisabled = item.IsDisabled,
RunAsAdmin = item.RunAsAdmin,
StartMinimized = item.StartMinimized,
DisplayMode = item.DisplayMode, DisplayMode = item.DisplayMode,
Children = item.Children.Select(child => DuplicateItem(child, renameRoot: false)).ToList() Children = item.Children.Select(child => DuplicateItem(child, renameRoot: false)).ToList()
}; };
@@ -940,6 +1071,46 @@ public partial class MainWindow : Window
RefreshView(selectedItem); 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) private static bool RemoveItem(List<LauncherItem> items, LauncherItem item)
{ {
if (items.Remove(item)) if (items.Remove(item))
@@ -1154,6 +1325,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) private void ResetConfig_Click(object sender, RoutedEventArgs e)
{ {
MessageBoxResult result = MessageBox.Show( MessageBoxResult result = MessageBox.Show(
@@ -1238,6 +1517,183 @@ public partial class MainWindow : Window
ConfigService.OpenConfigFolder(); 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.IsDisabled && TryGetWorkingFolderIssue(item, out string? workingFolderProblem) && workingFolderProblem is not null)
{
issues.Add(new TargetValidationIssue(string.Join(" > ", itemPath), workingFolderProblem));
}
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 TryGetWorkingFolderIssue(LauncherItem item, out string? problem)
{
problem = null;
if (!LauncherController.CanUseLaunchOptions(item.Type) || string.IsNullOrWhiteSpace(item.WorkingFolder))
{
return false;
}
string workingFolder = Environment.ExpandEnvironmentVariables(item.WorkingFolder.Trim());
if (Directory.Exists(workingFolder))
{
return false;
}
problem = "Working folder does not exist.";
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) private void About_Click(object sender, RoutedEventArgs e)
{ {
AboutWindow aboutWindow = new() AboutWindow aboutWindow = new()
@@ -1367,6 +1823,42 @@ public partial class MainWindow : Window
ThemeService.Apply(scheme); ThemeService.Apply(scheme);
} }
private void BrowseWorkingFolder_Click(object sender, RoutedEventArgs e)
{
using FolderBrowserDialog dialog = new()
{
Description = "Choose a working folder"
};
if (!string.IsNullOrWhiteSpace(WorkingFolderBox.Text))
{
string currentFolder = Environment.ExpandEnvironmentVariables(WorkingFolderBox.Text.Trim());
if (Directory.Exists(currentFolder))
{
dialog.SelectedPath = currentFolder;
}
}
if (dialog.ShowDialog() == System.Windows.Forms.DialogResult.OK)
{
WorkingFolderBox.Text = dialog.SelectedPath;
}
}
private void LaunchOptionBox_Changed(object sender, RoutedEventArgs e)
{
if (isLoadingSelection)
{
return;
}
UpdatePreview();
if (selectedItem is not null)
{
StatusText.Text = "Click Save to keep the selected item launch options.";
}
}
private void EnsureAutomaticIconPath() private void EnsureAutomaticIconPath()
{ {
if (TypeBox.SelectedItem is LauncherItemType.Menu) if (TypeBox.SelectedItem is LauncherItemType.Menu)
@@ -1443,7 +1935,18 @@ public partial class MainWindow : Window
Type = TypeBox.SelectedItem is LauncherItemType type ? type : LauncherItemType.App, Type = TypeBox.SelectedItem is LauncherItemType type ? type : LauncherItemType.App,
Target = TargetBox.Text.Trim(), Target = TargetBox.Text.Trim(),
Arguments = string.IsNullOrWhiteSpace(ArgumentsBox.Text) ? null : ArgumentsBox.Text.Trim(), Arguments = string.IsNullOrWhiteSpace(ArgumentsBox.Text) ? null : ArgumentsBox.Text.Trim(),
WorkingFolder = LauncherController.CanUseLaunchOptions(TypeBox.SelectedItem is LauncherItemType itemType ? itemType : LauncherItemType.App) &&
!string.IsNullOrWhiteSpace(WorkingFolderBox.Text)
? WorkingFolderBox.Text.Trim()
: null,
Notes = string.IsNullOrWhiteSpace(NotesBox.Text) ? null : NotesBox.Text.Trim(),
IconPath = string.IsNullOrWhiteSpace(IconPathBox.Text) ? null : IconPathBox.Text.Trim(), IconPath = string.IsNullOrWhiteSpace(IconPathBox.Text) ? null : IconPathBox.Text.Trim(),
IsFavorite = selectedItem.IsFavorite,
IsDisabled = selectedItem.IsDisabled,
RunAsAdmin = LauncherController.CanRunAsAdmin(TypeBox.SelectedItem is LauncherItemType adminType ? adminType : LauncherItemType.App) &&
RunAsAdminBox.IsChecked == true,
StartMinimized = LauncherController.CanUseLaunchOptions(TypeBox.SelectedItem is LauncherItemType launchType ? launchType : LauncherItemType.App) &&
StartMinimizedBox.IsChecked == true,
DisplayMode = ItemDisplayModeBox.SelectedItem is LauncherDisplayMode mode DisplayMode = ItemDisplayModeBox.SelectedItem is LauncherDisplayMode mode
? mode ? mode
: selectedItem.DisplayMode : selectedItem.DisplayMode
@@ -1510,12 +2013,30 @@ public partial class MainWindow : Window
private void UpdateTargetAvailability() private void UpdateTargetAvailability()
{ {
bool isMenu = TypeBox.SelectedItem is LauncherItemType.Menu; bool isMenu = TypeBox.SelectedItem is LauncherItemType.Menu;
bool canUseLaunchOptions = TypeBox.SelectedItem is LauncherItemType launchType && LauncherController.CanUseLaunchOptions(launchType);
bool canRunAsAdmin = TypeBox.SelectedItem is LauncherItemType itemType && LauncherController.CanRunAsAdmin(itemType);
TargetBox.IsEnabled = !isMenu; TargetBox.IsEnabled = !isMenu;
ArgumentsBox.IsEnabled = !isMenu; ArgumentsBox.IsEnabled = !isMenu;
WorkingFolderBox.IsEnabled = canUseLaunchOptions;
WorkingFolderBrowseButton.IsEnabled = canUseLaunchOptions;
RunAsAdminBox.IsEnabled = canRunAsAdmin;
StartMinimizedBox.IsEnabled = canUseLaunchOptions;
if (isMenu && !isLoadingSelection) if (isMenu && !isLoadingSelection)
{ {
TargetBox.Text = ""; TargetBox.Text = "";
ArgumentsBox.Text = ""; ArgumentsBox.Text = "";
WorkingFolderBox.Text = "";
}
if (!canRunAsAdmin && !isLoadingSelection)
{
RunAsAdminBox.IsChecked = false;
}
if (!canUseLaunchOptions && !isLoadingSelection)
{
WorkingFolderBox.Text = "";
StartMinimizedBox.IsChecked = false;
} }
} }

View File

@@ -8,6 +8,8 @@ public sealed class LauncherConfig
public LauncherThemeScheme ThemeScheme { get; set; } = LauncherThemeScheme.MarkHaven; public LauncherThemeScheme ThemeScheme { get; set; } = LauncherThemeScheme.MarkHaven;
public List<LauncherItem> RecentItems { get; set; } = [];
public List<LauncherItem> Items { get; set; } = []; public List<LauncherItem> Items { get; set; } = [];
} }
@@ -21,8 +23,20 @@ public sealed class LauncherItem
public string? Arguments { get; set; } public string? Arguments { get; set; }
public string? WorkingFolder { get; set; }
public string? Notes { get; set; }
public string? IconPath { get; set; } public string? IconPath { get; set; }
public bool IsFavorite { get; set; }
public bool IsDisabled { get; set; }
public bool RunAsAdmin { get; set; }
public bool StartMinimized { get; set; }
public LauncherDisplayMode DisplayMode { get; set; } = LauncherDisplayMode.CompactList; public LauncherDisplayMode DisplayMode { get; set; } = LauncherDisplayMode.CompactList;
public List<LauncherItem> Children { get; set; } = []; public List<LauncherItem> Children { get; set; } = [];

View File

@@ -8,13 +8,29 @@ Download the compiled Windows release from:
[Taskbar Launcher Releases](https://git.mget.ca/MGET/Taskbar-Launcher/releases) [Taskbar Launcher Releases](https://git.mget.ca/MGET/Taskbar-Launcher/releases)
Current stable release: `v1.0.2`
## Features ## Features
- System tray launcher - System tray launcher
- Global hotkey, default `Ctrl+Alt+Space` - 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 - 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
- Optional run-as-administrator mode for app and file entries
- Optional working folder and start-minimized launch options for app and file entries
- Per-item notes, visible in launcher tooltips and searchable from the popup
- 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 item duplication in Settings
- Right-click items in Settings to pin them to the top of their menu
- Per-item icon and display mode - Per-item icon and display mode
- Automatic icon selection from app/folder targets - Automatic icon selection from app/folder targets
- Portable `config.json` - Portable `config.json`
@@ -22,6 +38,10 @@ Download the compiled Windows release from:
- Optional Start with Windows shortcut - Optional Start with Windows shortcut
- Windows light/dark theme support - Windows light/dark theme support
## Latest Stable
`v1.0.2` adds the newer launcher management and polish work from the development builds, including recent items, pinned items, drag/drop item creation, item duplication, item notes, target validation, selected item import/export, run-as-administrator support, working folders, start-minimized launch options, launcher tooltips, and the refreshed Settings layout.
## Screenshots ## Screenshots
### Launcher Popup ### Launcher Popup
@@ -74,5 +94,5 @@ Recommended release flow for maintainers:
1. Update `<Version>` in `TaskbarLauncher.csproj`. 1. Update `<Version>` in `TaskbarLauncher.csproj`.
2. Commit the change. 2. Commit the change.
3. Build a release package locally. 3. Build a release package locally.
4. Tag the release, for example `v1.0.1`. 4. Tag the release, for example `v1.0.2`.
5. Upload the release zip to Gitea Releases. 5. Upload the release zip to Gitea Releases.

View File

@@ -80,6 +80,49 @@ public static class ConfigService
return destinationPath; 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) public static LauncherConfig ImportFrom(string sourcePath)
{ {
LauncherConfig config = ReadConfigFile(sourcePath); LauncherConfig config = ReadConfigFile(sourcePath);
@@ -190,10 +233,20 @@ public static class ConfigService
throw new InvalidDataException("The selected config is missing launcher items."); throw new InvalidDataException("The selected config is missing launcher items.");
} }
if (config.RecentItems is null)
{
config.RecentItems = [];
}
foreach (LauncherItem item in config.Items) foreach (LauncherItem item in config.Items)
{ {
ValidateItem(item); ValidateItem(item);
} }
foreach (LauncherItem item in config.RecentItems)
{
ValidateItem(item);
}
} }
private static void ValidateItem(LauncherItem item) private static void ValidateItem(LauncherItem item)
@@ -226,6 +279,16 @@ public static class ConfigService
config.ThemeScheme = LauncherThemeScheme.MarkHaven; config.ThemeScheme = LauncherThemeScheme.MarkHaven;
} }
config.RecentItems ??= [];
config.RecentItems.RemoveAll(item => item.Type == LauncherItemType.Menu || item.IsDisabled || string.IsNullOrWhiteSpace(item.Target));
foreach (LauncherItem item in config.RecentItems)
{
item.IsFavorite = false;
item.IsDisabled = false;
item.Children = [];
NormalizeDisplayModes(item);
}
foreach (LauncherItem item in config.Items) foreach (LauncherItem item in config.Items)
{ {
NormalizeDisplayModes(item); NormalizeDisplayModes(item);

View File

@@ -9,6 +9,8 @@ namespace TaskbarLauncher.Services;
public sealed class LauncherController : IDisposable public sealed class LauncherController : IDisposable
{ {
private const int MaxRecentItems = 8;
private readonly HotkeyService hotkeyService = new(); private readonly HotkeyService hotkeyService = new();
private NotifyIcon? notifyIcon; private NotifyIcon? notifyIcon;
private LauncherPopup? popup; private LauncherPopup? popup;
@@ -41,6 +43,12 @@ public sealed class LauncherController : IDisposable
settingsWindow.Activate(); settingsWindow.Activate();
} }
public void ShowSettings(LauncherItem itemToEdit)
{
settingsWindow ??= new MainWindow(this);
settingsWindow.SelectItemForEdit(itemToEdit);
}
public void ShowAbout() public void ShowAbout()
{ {
if (aboutWindow?.IsVisible == true) if (aboutWindow?.IsVisible == true)
@@ -64,7 +72,7 @@ public sealed class LauncherController : IDisposable
return; return;
} }
popup = new LauncherPopup(Config, LaunchItem, OpenAllItems, ShowSettings); popup = new LauncherPopup(Config, LaunchItem, OpenAllItems, ShowSettings, ShowSettings, OpenContainingFolderFromPopup);
popup.Closed += (_, _) => popup = null; popup.Closed += (_, _) => popup = null;
popup.ShowNearTaskbar(); popup.ShowNearTaskbar();
} }
@@ -130,6 +138,7 @@ public sealed class LauncherController : IDisposable
{ {
if (TryLaunchItem(item, out string? error)) if (TryLaunchItem(item, out string? error))
{ {
RecordRecentItem(item);
popup?.Close(); popup?.Close();
return; return;
} }
@@ -140,6 +149,49 @@ public sealed class LauncherController : IDisposable
} }
} }
private void RecordRecentItem(LauncherItem item)
{
if (item.Type == LauncherItemType.Menu || item.IsDisabled || string.IsNullOrWhiteSpace(item.Target))
{
return;
}
Config.RecentItems.RemoveAll(recent => IsSameRecentItem(recent, item));
Config.RecentItems.Insert(0, CreateRecentItem(item));
if (Config.RecentItems.Count > MaxRecentItems)
{
Config.RecentItems.RemoveRange(MaxRecentItems, Config.RecentItems.Count - MaxRecentItems);
}
ConfigService.Save(Config);
}
private static bool IsSameRecentItem(LauncherItem recent, LauncherItem item)
{
return string.Equals(recent.Target, item.Target, StringComparison.OrdinalIgnoreCase) &&
string.Equals(recent.Arguments ?? "", item.Arguments ?? "", StringComparison.OrdinalIgnoreCase);
}
private static LauncherItem CreateRecentItem(LauncherItem item)
{
return new LauncherItem
{
Title = item.Title,
Type = item.Type,
Target = item.Target,
Arguments = item.Arguments,
WorkingFolder = item.WorkingFolder,
Notes = item.Notes,
IconPath = item.IconPath,
IsFavorite = false,
IsDisabled = false,
RunAsAdmin = item.RunAsAdmin,
StartMinimized = item.StartMinimized,
DisplayMode = item.DisplayMode,
Children = []
};
}
private void OpenAllItems(LauncherItem menuItem) private void OpenAllItems(LauncherItem menuItem)
{ {
List<string> failures = []; List<string> failures = [];
@@ -147,7 +199,7 @@ public sealed class LauncherController : IDisposable
foreach (LauncherItem child in menuItem.Children) foreach (LauncherItem child in menuItem.Children)
{ {
if (child.Type == LauncherItemType.Menu || string.IsNullOrWhiteSpace(child.Target)) if (child.Type == LauncherItemType.Menu || child.IsDisabled || string.IsNullOrWhiteSpace(child.Target))
{ {
continue; continue;
} }
@@ -155,6 +207,7 @@ public sealed class LauncherController : IDisposable
if (TryLaunchItem(child, out string? error)) if (TryLaunchItem(child, out string? error))
{ {
launchedCount++; launchedCount++;
RecordRecentItem(child);
} }
else if (!string.IsNullOrWhiteSpace(error)) else if (!string.IsNullOrWhiteSpace(error))
{ {
@@ -172,7 +225,15 @@ public sealed class LauncherController : IDisposable
} }
} }
private static bool TryLaunchItem(LauncherItem item, out string? error) private void OpenContainingFolderFromPopup(LauncherItem item)
{
if (!OpenContainingFolder(item, out string? error) && !string.IsNullOrWhiteSpace(error))
{
System.Windows.MessageBox.Show(error, "Taskbar Launcher");
}
}
public static bool OpenContainingFolder(LauncherItem item, out string? error)
{ {
error = null; error = null;
@@ -182,17 +243,66 @@ public sealed class LauncherController : IDisposable
} }
string target = Environment.ExpandEnvironmentVariables(item.Target); string target = Environment.ExpandEnvironmentVariables(item.Target);
string? folderPath = Directory.Exists(target)
? target
: Path.GetDirectoryName(target);
if (string.IsNullOrWhiteSpace(folderPath) || !Directory.Exists(folderPath))
{
error = $"{item.Title}: Containing folder was not found.";
return false;
}
try
{
Process.Start(new ProcessStartInfo
{
FileName = folderPath,
UseShellExecute = true
});
return true;
}
catch (Exception ex)
{
error = $"{item.Title}: {ex.Message}";
return false;
}
}
private static bool TryLaunchItem(LauncherItem item, out string? error)
{
error = null;
if (item.Type == LauncherItemType.Menu || item.IsDisabled || string.IsNullOrWhiteSpace(item.Target))
{
return false;
}
string target = Environment.ExpandEnvironmentVariables(item.Target);
bool runAsAdmin = item.RunAsAdmin && CanRunAsAdmin(item.Type);
string launchTarget = runAsAdmin ? ResolveExecutableTarget(target) : target;
string workingDirectory = GetWorkingDirectory(item, target);
try try
{ {
ProcessStartInfo startInfo = new() ProcessStartInfo startInfo = new()
{ {
FileName = target, FileName = launchTarget,
Arguments = item.Arguments ?? "", Arguments = item.Arguments ?? "",
UseShellExecute = true, UseShellExecute = true,
WorkingDirectory = Directory.Exists(target) ? target : ConfigService.AppFolder WorkingDirectory = workingDirectory
}; };
if (runAsAdmin)
{
startInfo.Verb = "runas";
}
if (item.StartMinimized && CanUseLaunchOptions(item.Type))
{
startInfo.WindowStyle = ProcessWindowStyle.Minimized;
}
Process.Start(startInfo); Process.Start(startInfo);
return true; return true;
} }
@@ -202,4 +312,56 @@ public sealed class LauncherController : IDisposable
return false; return false;
} }
} }
public static bool CanRunAsAdmin(LauncherItemType itemType)
{
return itemType is LauncherItemType.App or LauncherItemType.File;
}
public static bool CanUseLaunchOptions(LauncherItemType itemType)
{
return itemType is LauncherItemType.App or LauncherItemType.File;
}
private static string GetWorkingDirectory(LauncherItem item, string target)
{
if (CanUseLaunchOptions(item.Type) && !string.IsNullOrWhiteSpace(item.WorkingFolder))
{
string configuredFolder = Environment.ExpandEnvironmentVariables(item.WorkingFolder.Trim());
if (Directory.Exists(configuredFolder))
{
return configuredFolder;
}
}
return Directory.Exists(target) ? target : ConfigService.AppFolder;
}
private static string ResolveExecutableTarget(string target)
{
if (Path.IsPathRooted(target) || Path.GetDirectoryName(target) is { Length: > 0 })
{
return target;
}
string[] searchFolders =
[
Environment.CurrentDirectory,
Environment.GetFolderPath(Environment.SpecialFolder.Windows),
Environment.GetFolderPath(Environment.SpecialFolder.System),
.. (Environment.GetEnvironmentVariable("PATH") ?? "")
.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
];
foreach (string folder in searchFolders.Where(Directory.Exists).Distinct(StringComparer.OrdinalIgnoreCase))
{
string candidate = Path.Combine(folder, target);
if (File.Exists(candidate))
{
return candidate;
}
}
return target;
}
} }

View File

@@ -11,10 +11,10 @@
<IncludeSourceRevisionInInformationalVersion>false</IncludeSourceRevisionInInformationalVersion> <IncludeSourceRevisionInInformationalVersion>false</IncludeSourceRevisionInInformationalVersion>
<Authors>Ray Berezowski</Authors> <Authors>Ray Berezowski</Authors>
<ApplicationIcon>Assets\AppIcon.ico</ApplicationIcon> <ApplicationIcon>Assets\AppIcon.ico</ApplicationIcon>
<Version>1.0.1</Version> <Version>1.0.2</Version>
<AssemblyVersion>1.0.1.3</AssemblyVersion> <AssemblyVersion>1.0.2.0</AssemblyVersion>
<FileVersion>1.0.1.3</FileVersion> <FileVersion>1.0.2.0</FileVersion>
<InformationalVersion>1.0.1</InformationalVersion> <InformationalVersion>1.0.2</InformationalVersion>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>

View File

@@ -14,19 +14,25 @@ public partial class LauncherPopup : Window
private const double HeaderAndChromeHeight = 118; private const double HeaderAndChromeHeight = 118;
private bool isRepositionQueued; private bool isRepositionQueued;
private readonly List<System.Windows.Controls.Button> visibleLaunchButtons = []; private readonly List<System.Windows.Controls.Button> visibleLaunchButtons = [];
private readonly List<System.Windows.Controls.Control> visibleNavigationControls = [];
private readonly LauncherConfig config; private readonly LauncherConfig config;
private readonly Action<LauncherItem> launchItem; private readonly Action<LauncherItem> launchItem;
private readonly Action<LauncherItem> openAllItems; private readonly Action<LauncherItem> openAllItems;
private readonly Action showSettings; 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) public LauncherPopup(LauncherConfig config, Action<LauncherItem> launchItem, Action<LauncherItem> openAllItems, Action showSettings, Action<LauncherItem> editItem, Action<LauncherItem> openContainingFolder)
{ {
InitializeComponent(); InitializeComponent();
this.config = config; this.config = config;
this.launchItem = launchItem; this.launchItem = launchItem;
this.openAllItems = openAllItems; this.openAllItems = openAllItems;
this.showSettings = showSettings; this.showSettings = showSettings;
this.editItem = editItem;
this.openContainingFolder = openContainingFolder;
BuildItems(); BuildItems();
SizeChanged += (_, _) => QueueReposition(); SizeChanged += (_, _) => QueueReposition();
} }
@@ -91,14 +97,46 @@ public partial class LauncherPopup : Window
{ {
ItemsPanel.Children.Clear(); ItemsPanel.Children.Clear();
visibleLaunchButtons.Clear(); visibleLaunchButtons.Clear();
visibleNavigationControls.Clear();
if (config.RecentItems.Any(item => !item.IsDisabled))
{
ItemsPanel.Children.Add(CreateRecentItemsControl());
}
bool expandMenusByDefault = config.Items.Count <= 2; bool expandMenusByDefault = config.Items.Count <= 2;
foreach (LauncherItem item in config.Items) foreach (LauncherItem item in GetDisplayItems(config.Items))
{ {
ItemsPanel.Children.Add(CreateItemControl(item, 0, expandMenusByDefault)); ItemsPanel.Children.Add(CreateItemControl(item, 0, expandMenusByDefault));
} }
RebuildNavigationLists();
} }
private FrameworkElement CreateItemControl(LauncherItem item, int depth, bool expandMenusByDefault) private Expander CreateRecentItemsControl()
{
Expander expander = new()
{
Header = new TextBlock { Text = "Recent" },
IsExpanded = true,
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))
{
recentItems.Children.Add(CreateItemControl(item, 1, expandMenusByDefault: false, allowEdit: false));
}
expander.Content = recentItems;
return expander;
}
private FrameworkElement CreateItemControl(LauncherItem item, int depth, bool expandMenusByDefault, bool allowEdit = true)
{ {
if (item.Type == LauncherItemType.Menu) if (item.Type == LauncherItemType.Menu)
{ {
@@ -106,13 +144,19 @@ public partial class LauncherPopup : Window
{ {
Header = CreateMenuHeader(item), Header = CreateMenuHeader(item),
IsExpanded = expandMenusByDefault, 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(); StackPanel children = new();
foreach (LauncherItem child in item.Children) foreach (LauncherItem child in GetDisplayItems(item.Children))
{ {
children.Children.Add(CreateItemControl(child, depth + 1, expandMenusByDefault)); children.Children.Add(CreateItemControl(child, depth + 1, expandMenusByDefault, allowEdit));
} }
expander.Content = children; expander.Content = children;
@@ -125,24 +169,17 @@ public partial class LauncherPopup : Window
Margin = new Thickness(depth * 12, 2, 0, 2), Margin = new Thickness(depth * 12, 2, 0, 2),
MinHeight = DisplayModeMetrics.GetRowHeight(GetEffectiveDisplayMode(item)), MinHeight = DisplayModeMetrics.GetRowHeight(GetEffectiveDisplayMode(item)),
HorizontalContentAlignment = System.Windows.HorizontalAlignment.Left, HorizontalContentAlignment = System.Windows.HorizontalAlignment.Left,
Tag = item Tag = item,
ToolTip = CreateItemToolTip(item)
}; };
button.Click += (_, _) => launchItem(item); button.Click += (_, _) => launchItem(item);
button.KeyDown += (_, e) => if (allowEdit)
{ {
if (e.Key == Key.Enter) button.ContextMenu = CreateItemContextMenu(item);
{ }
launchItem(item);
e.Handled = true; button.KeyDown += LaunchButton_KeyDown;
}
else if (e.Key == Key.Escape)
{
Close();
e.Handled = true;
}
};
visibleLaunchButtons.Add(button);
return button; return button;
} }
@@ -161,17 +198,132 @@ public partial class LauncherPopup : Window
System.Windows.Controls.ContextMenu contextMenu = new(); System.Windows.Controls.ContextMenu contextMenu = new();
contextMenu.Items.Add(openAllMenuItem); contextMenu.Items.Add(openAllMenuItem);
contextMenu.Items.Add(new Separator());
contextMenu.Items.Add(CreatePinMenuItem(item));
contextMenu.Items.Add(CreateDisableMenuItem(item));
contextMenu.Items.Add(CreateEditMenuItem(item));
return new TextBlock return new TextBlock
{ {
Text = item.Title, Text = item.Title,
ContextMenu = contextMenu ContextMenu = contextMenu,
ToolTip = CreateItemToolTip(item)
}; };
} }
private System.Windows.Controls.ContextMenu CreateItemContextMenu(LauncherItem item)
{
System.Windows.Controls.ContextMenu contextMenu = new();
contextMenu.Items.Add(CreatePinMenuItem(item));
contextMenu.Items.Add(CreateDisableMenuItem(item));
contextMenu.Items.Add(CreateRunAsAdminMenuItem(item));
contextMenu.Items.Add(CreateOpenContainingFolderMenuItem(item));
contextMenu.Items.Add(CreateEditMenuItem(item));
return contextMenu;
}
private System.Windows.Controls.MenuItem CreatePinMenuItem(LauncherItem item)
{
System.Windows.Controls.MenuItem pinMenuItem = new()
{
Header = item.IsFavorite ? "Unpin from Top" : "Pin to Top"
};
pinMenuItem.Click += (_, e) =>
{
item.IsFavorite = !item.IsFavorite;
ConfigService.Save(config);
BuildItems();
QueueReposition();
e.Handled = true;
};
return pinMenuItem;
}
private System.Windows.Controls.MenuItem CreateDisableMenuItem(LauncherItem item)
{
System.Windows.Controls.MenuItem disableMenuItem = new()
{
Header = item.IsDisabled ? "Enable Item" : "Disable Item"
};
disableMenuItem.Click += (_, e) =>
{
item.IsDisabled = !item.IsDisabled;
ConfigService.Save(config);
BuildItems();
QueueReposition();
e.Handled = true;
};
return disableMenuItem;
}
private System.Windows.Controls.MenuItem CreateRunAsAdminMenuItem(LauncherItem item)
{
System.Windows.Controls.MenuItem runAsAdminMenuItem = new()
{
Header = item.RunAsAdmin ? "Run as Admin: On" : "Run as Admin: Off",
IsEnabled = LauncherController.CanRunAsAdmin(item.Type)
};
runAsAdminMenuItem.Click += (_, e) =>
{
item.RunAsAdmin = !item.RunAsAdmin;
ConfigService.Save(config);
BuildItems();
QueueReposition();
e.Handled = true;
};
return runAsAdminMenuItem;
}
private System.Windows.Controls.MenuItem CreateOpenContainingFolderMenuItem(LauncherItem item)
{
System.Windows.Controls.MenuItem openFolderMenuItem = new()
{
Header = "Open Containing Folder",
IsEnabled = CanOpenContainingFolder(item)
};
openFolderMenuItem.Click += (_, e) =>
{
openContainingFolder(item);
e.Handled = true;
};
return openFolderMenuItem;
}
private System.Windows.Controls.MenuItem CreateEditMenuItem(LauncherItem item)
{
System.Windows.Controls.MenuItem editMenuItem = new()
{
Header = "Edit in Settings"
};
editMenuItem.Click += (_, e) =>
{
editItem(item);
Close();
e.Handled = true;
};
return editMenuItem;
}
private static bool IsLaunchable(LauncherItem item) private static bool IsLaunchable(LauncherItem item)
{ {
return item.Type != LauncherItemType.Menu && !string.IsNullOrWhiteSpace(item.Target); return item.Type != LauncherItemType.Menu && !item.IsDisabled && !string.IsNullOrWhiteSpace(item.Target);
}
private static bool CanOpenContainingFolder(LauncherItem item)
{
return item.Type is LauncherItemType.App or LauncherItemType.File && !string.IsNullOrWhiteSpace(item.Target);
}
private static IEnumerable<LauncherItem> GetDisplayItems(IEnumerable<LauncherItem> items)
{
return items
.Where(item => !item.IsDisabled)
.OrderByDescending(item => item.IsFavorite);
} }
private void SearchBox_TextChanged(object sender, TextChangedEventArgs e) private void SearchBox_TextChanged(object sender, TextChangedEventArgs e)
@@ -194,8 +346,8 @@ public partial class LauncherPopup : Window
ItemsPanel.Children.Clear(); ItemsPanel.Children.Clear();
visibleLaunchButtons.Clear(); visibleLaunchButtons.Clear();
List<LauncherItem> matches = []; List<SearchMatch> matches = [];
CollectMatches(config.Items, query, matches); CollectMatches(config.Items, query, [], matches);
if (matches.Count == 0) if (matches.Count == 0)
{ {
@@ -208,34 +360,179 @@ public partial class LauncherPopup : Window
return; 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)
{ {
foreach (LauncherItem item in items) StackPanel panel = new();
panel.Children.Add((UIElement)CreateButtonContent(match.Item));
if (!string.IsNullOrWhiteSpace(match.ParentPath))
{ {
bool isLaunchable = item.Type != LauncherItemType.Menu; panel.Children.Add(new TextBlock
if (isLaunchable && Matches(item, query))
{ {
matches.Add(item); 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 (!string.IsNullOrWhiteSpace(item.WorkingFolder))
{
AddToolTipLine(panel, $"Working folder: {item.WorkingFolder}");
}
if (!string.IsNullOrWhiteSpace(item.Notes))
{
AddToolTipLine(panel, $"Notes: {item.Notes}");
}
if (item.IsFavorite)
{
AddToolTipLine(panel, "Pinned to top");
}
if (item.RunAsAdmin)
{
AddToolTipLine(panel, "Runs as administrator");
}
if (item.StartMinimized)
{
AddToolTipLine(panel, "Starts minimized");
}
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;
string parentPath = string.Join(" > ", parentNames);
if (isLaunchable && Matches(item, query, parentPath))
{
matches.Add(new SearchMatch(item, parentPath));
} }
if (item.Children.Count > 0) 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) || return item.Title.Contains(query, StringComparison.OrdinalIgnoreCase) ||
item.Target.Contains(query, StringComparison.OrdinalIgnoreCase) || item.Target.Contains(query, StringComparison.OrdinalIgnoreCase) ||
(item.Arguments?.Contains(query, StringComparison.OrdinalIgnoreCase) ?? false); (item.Arguments?.Contains(query, StringComparison.OrdinalIgnoreCase) ?? false) ||
(item.Notes?.Contains(query, StringComparison.OrdinalIgnoreCase) ?? false) ||
parentPath.Contains(query, StringComparison.OrdinalIgnoreCase);
} }
private void SearchBox_PreviewKeyDown(object sender, System.Windows.Input.KeyEventArgs e) private void SearchBox_PreviewKeyDown(object sender, System.Windows.Input.KeyEventArgs e)
@@ -254,14 +551,142 @@ public partial class LauncherPopup : Window
return; return;
} }
if (e.Key == Key.Down && visibleLaunchButtons.FirstOrDefault() is System.Windows.Controls.Button firstButton) if (e.Key == Key.Down)
{ {
Keyboard.ClearFocus(); FocusNavigationControl(0);
firstButton.Focus(); 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; 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) protected override void OnKeyDown(System.Windows.Input.KeyEventArgs e)
{ {
if (e.Key == Key.Escape) if (e.Key == Key.Escape)

View File

@@ -1,13 +1,21 @@
{ {
"Hotkey": "Ctrl+Alt+Space", "Hotkey": "Ctrl+Alt+Space",
"DisplayMode": "CompactList", "DisplayMode": "CompactList",
"ThemeScheme": "MarkHaven",
"RecentItems": [],
"Items": [ "Items": [
{ {
"Title": "Folders", "Title": "Folders",
"Type": "Menu", "Type": "Menu",
"Target": "", "Target": "",
"Arguments": null, "Arguments": null,
"WorkingFolder": null,
"Notes": null,
"IconPath": null, "IconPath": null,
"IsFavorite": false,
"IsDisabled": false,
"RunAsAdmin": false,
"StartMinimized": false,
"DisplayMode": "CompactList", "DisplayMode": "CompactList",
"Children": [ "Children": [
{ {
@@ -15,7 +23,13 @@
"Type": "Folder", "Type": "Folder",
"Target": "%USERPROFILE%\\Documents", "Target": "%USERPROFILE%\\Documents",
"Arguments": null, "Arguments": null,
"WorkingFolder": null,
"Notes": null,
"IconPath": "%USERPROFILE%\\Documents", "IconPath": "%USERPROFILE%\\Documents",
"IsFavorite": false,
"IsDisabled": false,
"RunAsAdmin": false,
"StartMinimized": false,
"DisplayMode": "CompactList", "DisplayMode": "CompactList",
"Children": [] "Children": []
}, },
@@ -24,7 +38,13 @@
"Type": "Folder", "Type": "Folder",
"Target": "%USERPROFILE%\\Downloads", "Target": "%USERPROFILE%\\Downloads",
"Arguments": null, "Arguments": null,
"WorkingFolder": null,
"Notes": null,
"IconPath": "%USERPROFILE%\\Downloads", "IconPath": "%USERPROFILE%\\Downloads",
"IsFavorite": false,
"IsDisabled": false,
"RunAsAdmin": false,
"StartMinimized": false,
"DisplayMode": "CompactList", "DisplayMode": "CompactList",
"Children": [] "Children": []
} }
@@ -35,7 +55,13 @@
"Type": "Menu", "Type": "Menu",
"Target": "", "Target": "",
"Arguments": null, "Arguments": null,
"WorkingFolder": null,
"Notes": null,
"IconPath": null, "IconPath": null,
"IsFavorite": false,
"IsDisabled": false,
"RunAsAdmin": false,
"StartMinimized": false,
"DisplayMode": "CompactList", "DisplayMode": "CompactList",
"Children": [ "Children": [
{ {
@@ -43,7 +69,13 @@
"Type": "App", "Type": "App",
"Target": "notepad.exe", "Target": "notepad.exe",
"Arguments": null, "Arguments": null,
"WorkingFolder": null,
"Notes": "Simple starter app entry.",
"IconPath": "notepad.exe", "IconPath": "notepad.exe",
"IsFavorite": false,
"IsDisabled": false,
"RunAsAdmin": false,
"StartMinimized": false,
"DisplayMode": "CompactList", "DisplayMode": "CompactList",
"Children": [] "Children": []
} }

View File

@@ -1,47 +0,0 @@
# Developer Notes
This document is for the `dev` branch only. Do not publish it on `main`.
## Screenshot and Testing Startup Switches
Taskbar Launcher supports a few command-line switches to open specific UI surfaces for documentation screenshots and manual testing:
```powershell
TaskbarLauncher.exe --show-settings
TaskbarLauncher.exe --show-about
TaskbarLauncher.exe --show-launcher
```
- `--show-settings` opens the Settings window after startup.
- `--show-about` opens the About window after startup.
- `--show-launcher` opens the launcher popup after startup.
These switches are not part of the normal user workflow. They exist so development and documentation captures can be repeated cleanly.
## Future Ideas
These ideas are intentionally tracked on `dev` only. Do not publish them on `main` until a feature is built, tested, and ready for users.
### Open All for Menu Sections
Add a simple right-click action on menu and submenu headers:
- `Open All`
Expected behavior:
- Right-click a top-level menu or submenu title.
- Click `Open All`.
- Launch each direct child item in that section.
- For nested submenus, decide later whether `Open All` should include only direct items or recursively include nested children.
- Skip unsupported items safely and show/log a clear result if something cannot be opened.
This keeps Taskbar Launcher focused as a launcher. It gives project-style behavior without adding a full project dashboard.
### Project Launcher Concept
Parked for later as a possible personal/custom feature:
- Project item type with actions for folder, VS Code, terminal, docs, Git/Gitea page, local server, Docker Compose, and helper scripts.
- Could become useful, but it is larger than the current public app direction.
- Do not start this until the simpler `Open All` behavior is tested and proves insufficient.

View File

@@ -1,5 +1,7 @@
# Install and Use Taskbar Launcher # Install and Use Taskbar Launcher
Current stable release: `v1.0.2`
## Small Package ## Small Package
The small package requires: The small package requires:
@@ -19,6 +21,18 @@ Install the **.NET Desktop Runtime** for **Windows x64**.
2. Run `TaskbarLauncher.exe`. 2. Run `TaskbarLauncher.exe`.
3. The app appears in the system tray. 3. The app appears in the system tray.
4. Press `Ctrl+Alt+Space` to open the launcher. 4. Press `Ctrl+Alt+Space` to open the launcher.
5. Right-click a launcher entry and choose **Edit in Settings** to jump to that item.
6. Right-click a launcher entry and choose **Pin to Top** or **Unpin from Top**.
7. Right-click a launcher entry and choose **Disable Item** to hide it until it is enabled again from Settings.
8. Right-click an app or file entry and choose **Open Containing Folder**.
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 ## Configure
@@ -32,6 +46,15 @@ From Settings you can:
- Drag/drop items to reorder - Drag/drop items to reorder
- Drag/drop files, folders, shortcuts, or web links into the launcher item list - Drag/drop files, folders, shortcuts, or web links into the launcher item list
- Right-click an item and choose **Duplicate Item** to copy it - Right-click an item and choose **Duplicate Item** to copy it
- 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**
- Enable **Run as administrator** for app and file items that need elevation
- Set a working folder or start apps minimized for app and file items
- Add notes to items for reminders, context, or search keywords
- 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 - Set per-item icon sizes
- Back up and restore `config.json` - Back up and restore `config.json`
- Enable Start with Windows - Enable Start with Windows

View File

@@ -21,6 +21,7 @@ Examples:
- `1.0.0`: first stable release - `1.0.0`: first stable release
- `1.0.1`: bug fix - `1.0.1`: bug fix
- `1.0.2`: tested feature and polish release
- `1.1.0`: new feature - `1.1.0`: new feature
Update version fields in: Update version fields in:
@@ -37,9 +38,9 @@ TaskbarLauncher.csproj
4. Tag the release: 4. Tag the release:
```powershell ```powershell
git tag v1.0.0 git tag v1.0.2
git push origin main git push origin main
git push origin v1.0.0 git push origin v1.0.2
``` ```
5. In Gitea, create a release from the tag. 5. In Gitea, create a release from the tag.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.3 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 55 KiB