Initial TaskbarLauncher release baseline

This commit is contained in:
Ray Berezowski
2026-06-02 17:28:25 -04:00
commit e8c0721c39
18 changed files with 2566 additions and 0 deletions

16
.gitignore vendored Normal file
View File

@@ -0,0 +1,16 @@
bin/
obj/
*.user
*.suo
*.pdb
# Portable runtime data
config.json
backups/
# Local/editor noise
.vs/
.vscode/
# Generated release output kept outside the repo when possible
*.zip

182
App.xaml Normal file
View File

@@ -0,0 +1,182 @@
<Application x:Class="TaskbarLauncher.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
ShutdownMode="OnExplicitShutdown">
<Application.Resources>
<Style TargetType="Button">
<Setter Property="Margin" Value="0,2"/>
<Setter Property="Padding" Value="10,8"/>
<Setter Property="HorizontalContentAlignment" Value="Left"/>
<Setter Property="Background" Value="{DynamicResource AppControlBrush}"/>
<Setter Property="Foreground" Value="{DynamicResource AppTextBrush}"/>
<Setter Property="BorderBrush" Value="{DynamicResource AppBorderBrush}"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border x:Name="ButtonBorder"
Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="1"
Padding="{TemplateBinding Padding}">
<ContentPresenter HorizontalAlignment="{TemplateBinding HorizontalContentAlignment}"
VerticalAlignment="{TemplateBinding VerticalContentAlignment}"
RecognizesAccessKey="True"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="ButtonBorder" Property="Background" Value="{DynamicResource AppControlBrush}"/>
<Setter TargetName="ButtonBorder" Property="BorderBrush" Value="{DynamicResource AppAccentBrush}"/>
</Trigger>
<Trigger Property="IsPressed" Value="True">
<Setter TargetName="ButtonBorder" Property="Background" Value="{DynamicResource AppSurfaceBrush}"/>
<Setter TargetName="ButtonBorder" Property="BorderBrush" Value="{DynamicResource AppAccentBrush}"/>
</Trigger>
<Trigger Property="IsEnabled" Value="False">
<Setter Property="Opacity" Value="0.55"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style TargetType="TextBox">
<Setter Property="Background" Value="{DynamicResource AppSurfaceBrush}"/>
<Setter Property="Foreground" Value="{DynamicResource AppTextBrush}"/>
<Setter Property="BorderBrush" Value="{DynamicResource AppBorderBrush}"/>
<Setter Property="CaretBrush" Value="{DynamicResource AppAccentBrush}"/>
</Style>
<Style TargetType="ComboBox">
<Setter Property="Background" Value="{DynamicResource AppSurfaceBrush}"/>
<Setter Property="Foreground" Value="{DynamicResource AppTextBrush}"/>
<Setter Property="BorderBrush" Value="{DynamicResource AppBorderBrush}"/>
<Setter Property="Padding" Value="6,4"/>
<Setter Property="MinHeight" Value="28"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ComboBox">
<Grid>
<ToggleButton x:Name="ToggleButton"
Focusable="False"
IsChecked="{Binding IsDropDownOpen, Mode=TwoWay, RelativeSource={RelativeSource TemplatedParent}}"
ClickMode="Press"
Background="{DynamicResource AppSurfaceBrush}"
BorderBrush="{DynamicResource AppBorderBrush}">
<ToggleButton.Template>
<ControlTemplate TargetType="ToggleButton">
<Border x:Name="ComboToggleBorder"
Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="1">
<ContentPresenter/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="ComboToggleBorder" Property="Background" Value="{DynamicResource AppSurfaceBrush}"/>
<Setter TargetName="ComboToggleBorder" Property="BorderBrush" Value="{DynamicResource AppAccentBrush}"/>
</Trigger>
<Trigger Property="IsPressed" Value="True">
<Setter TargetName="ComboToggleBorder" Property="Background" Value="{DynamicResource AppSurfaceBrush}"/>
<Setter TargetName="ComboToggleBorder" Property="BorderBrush" Value="{DynamicResource AppAccentBrush}"/>
</Trigger>
<Trigger Property="IsChecked" Value="True">
<Setter TargetName="ComboToggleBorder" Property="Background" Value="{DynamicResource AppSurfaceBrush}"/>
<Setter TargetName="ComboToggleBorder" Property="BorderBrush" Value="{DynamicResource AppAccentBrush}"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</ToggleButton.Template>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="28"/>
</Grid.ColumnDefinitions>
<ContentPresenter Margin="8,3,4,3"
HorizontalAlignment="Left"
VerticalAlignment="Center"
Content="{TemplateBinding SelectionBoxItem}"
ContentTemplate="{TemplateBinding SelectionBoxItemTemplate}"
ContentStringFormat="{TemplateBinding SelectionBoxItemStringFormat}"/>
<Path Grid.Column="1"
Width="9"
Height="5"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Fill="{DynamicResource AppTextBrush}"
Data="M 0 0 L 4.5 5 L 9 0 Z"/>
</Grid>
</ToggleButton>
<Popup x:Name="Popup"
Placement="Bottom"
AllowsTransparency="True"
Focusable="False"
IsOpen="{TemplateBinding IsDropDownOpen}"
PopupAnimation="Slide">
<Border Background="{DynamicResource AppSurfaceBrush}"
BorderBrush="{DynamicResource AppBorderBrush}"
BorderThickness="1"
MinWidth="{TemplateBinding ActualWidth}"
MaxHeight="{TemplateBinding MaxDropDownHeight}">
<ScrollViewer>
<ItemsPresenter/>
</ScrollViewer>
</Border>
</Popup>
</Grid>
<ControlTemplate.Triggers>
<Trigger Property="IsEnabled" Value="False">
<Setter Property="Opacity" Value="0.55"/>
</Trigger>
<Trigger SourceName="ToggleButton" Property="IsMouseOver" Value="True">
<Setter TargetName="ToggleButton" Property="Background" Value="{DynamicResource AppSurfaceBrush}"/>
<Setter TargetName="ToggleButton" Property="BorderBrush" Value="{DynamicResource AppAccentBrush}"/>
</Trigger>
<Trigger SourceName="ToggleButton" Property="IsPressed" Value="True">
<Setter TargetName="ToggleButton" Property="Background" Value="{DynamicResource AppSurfaceBrush}"/>
<Setter TargetName="ToggleButton" Property="BorderBrush" Value="{DynamicResource AppAccentBrush}"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style TargetType="ComboBoxItem">
<Setter Property="Background" Value="{DynamicResource AppSurfaceBrush}"/>
<Setter Property="Foreground" Value="{DynamicResource AppTextBrush}"/>
<Setter Property="Padding" Value="6,4"/>
<Style.Triggers>
<Trigger Property="IsHighlighted" Value="True">
<Setter Property="Background" Value="{DynamicResource AppAccentBrush}"/>
<Setter Property="Foreground" Value="{DynamicResource AppSelectedTextBrush}"/>
</Trigger>
<Trigger Property="IsSelected" Value="True">
<Setter Property="Background" Value="{DynamicResource AppAccentBrush}"/>
<Setter Property="Foreground" Value="{DynamicResource AppSelectedTextBrush}"/>
</Trigger>
</Style.Triggers>
</Style>
<Style TargetType="TreeView">
<Setter Property="Background" Value="{DynamicResource AppSurfaceBrush}"/>
<Setter Property="Foreground" Value="{DynamicResource AppTextBrush}"/>
</Style>
<Style TargetType="TreeViewItem">
<Setter Property="Foreground" Value="{DynamicResource AppTextBrush}"/>
<Setter Property="Background" Value="{DynamicResource AppSurfaceBrush}"/>
<Style.Triggers>
<Trigger Property="IsSelected" Value="True">
<Setter Property="Background" Value="{DynamicResource AppAccentBrush}"/>
<Setter Property="Foreground" Value="{DynamicResource AppSelectedTextBrush}"/>
</Trigger>
</Style.Triggers>
</Style>
<Style TargetType="GroupBox">
<Setter Property="Foreground" Value="{DynamicResource AppTextBrush}"/>
<Setter Property="BorderBrush" Value="{DynamicResource AppBorderBrush}"/>
</Style>
<Style TargetType="CheckBox">
<Setter Property="Foreground" Value="{DynamicResource AppTextBrush}"/>
</Style>
<Style TargetType="Expander">
<Setter Property="Foreground" Value="{DynamicResource AppTextBrush}"/>
</Style>
</Application.Resources>
</Application>

23
App.xaml.cs Normal file
View File

@@ -0,0 +1,23 @@
using TaskbarLauncher.Services;
namespace TaskbarLauncher;
public partial class App : System.Windows.Application
{
private LauncherController? controller;
protected override void OnStartup(System.Windows.StartupEventArgs e)
{
base.OnStartup(e);
ThemeService.Apply();
controller = new LauncherController();
controller.Start();
}
protected override void OnExit(System.Windows.ExitEventArgs e)
{
controller?.Dispose();
base.OnExit(e);
}
}

10
AssemblyInfo.cs Normal file
View File

@@ -0,0 +1,10 @@
using System.Windows;
[assembly:ThemeInfo(
ResourceDictionaryLocation.None, //where theme specific resource dictionaries are located
//(used if a resource is not found in the page,
// or application resource dictionaries)
ResourceDictionaryLocation.SourceAssembly //where the generic resource dictionary is located
//(used if a resource is not found in the page,
// app, or any theme specific resource dictionaries)
)]

166
MainWindow.xaml Normal file
View File

@@ -0,0 +1,166 @@
<Window x:Class="TaskbarLauncher.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d"
Title="Taskbar Launcher Settings" Height="700" Width="940"
MinHeight="620" MinWidth="820"
Background="{DynamicResource AppWindowBrush}"
Foreground="{DynamicResource AppTextBrush}">
<DockPanel Margin="18">
<StackPanel DockPanel.Dock="Top" Margin="0,0,0,14">
<TextBlock Text="Taskbar Launcher" FontSize="22" FontWeight="SemiBold"/>
<TextBlock x:Name="VersionText"
Foreground="{DynamicResource AppMutedTextBrush}"/>
<TextBlock Text="Portable launcher settings are stored beside the app in config.json."
Foreground="{DynamicResource AppMutedTextBrush}"/>
</StackPanel>
<Border DockPanel.Dock="Bottom" BorderThickness="1,0,0,0" Padding="0,12,0,0"
BorderBrush="{DynamicResource AppBorderBrush}">
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right">
<Button Content="Save" Click="Save_Click" Margin="0,0,8,0"/>
<Button Content="Backup Config" Click="BackupConfig_Click" Margin="0,0,8,0"/>
<Button Content="Restore Config" Click="RestoreConfig_Click" Margin="0,0,8,0"/>
<Button Content="Reload Config" Click="ReloadConfig_Click" Margin="0,0,8,0"/>
<Button Content="Open Config Folder" Click="OpenConfigFolder_Click" Margin="0,0,8,0"/>
<Button Content="Close" Click="Close_Click"/>
</StackPanel>
</Border>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="2*"/>
<ColumnDefinition Width="3*"/>
</Grid.ColumnDefinitions>
<GroupBox Header="Launcher Items" Margin="0,0,12,0">
<DockPanel Margin="8">
<UniformGrid DockPanel.Dock="Bottom" Columns="2" Margin="0,8,0,0">
<Button Content="Add Root" Click="AddRoot_Click"/>
<Button Content="Add Top Menu" Click="AddTopMenu_Click"/>
<Button Content="Add Child" Click="AddChild_Click"/>
<Button Content="Add Submenu" Click="AddSubmenu_Click"/>
<Button Content="Move Up" Click="MoveUp_Click"/>
<Button Content="Move Down" Click="MoveDown_Click"/>
<Button Content="Promote To Top" Click="PromoteToTop_Click"/>
<Button Content="Delete" Click="Delete_Click"/>
<Button Content="Apply Item" Click="ApplyItem_Click"/>
</UniformGrid>
<TreeView x:Name="ItemsTree"
AllowDrop="True"
SelectedItemChanged="ItemsTree_SelectedItemChanged"
PreviewMouseMove="ItemsTree_PreviewMouseMove"
DragOver="ItemsTree_DragOver"
Drop="ItemsTree_Drop"/>
</DockPanel>
</GroupBox>
<GroupBox Header="Settings" Grid.Column="1">
<Grid Margin="12">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="16"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="110"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<TextBlock Text="Hotkey" VerticalAlignment="Center" Margin="0,0,8,8"/>
<TextBox x:Name="HotkeyBox" Grid.Column="1" Margin="0,0,0,8"/>
<TextBlock Text="Default for New Items" Grid.Row="1" VerticalAlignment="Center" Margin="0,0,8,0"/>
<ComboBox x:Name="DisplayModeBox" Grid.Row="1" Grid.Column="1"/>
<TextBlock Text="Startup" Grid.Row="2" VerticalAlignment="Center" Margin="0,8,8,0"/>
<CheckBox x:Name="StartWithWindowsBox" Grid.Row="2" Grid.Column="1"
Content="Start with Windows"
Margin="0,8,0,0"
Checked="StartWithWindowsBox_Changed"
Unchecked="StartWithWindowsBox_Changed"/>
</Grid>
<Separator Grid.Row="1" VerticalAlignment="Center"/>
<Grid Grid.Row="2">
<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"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<TextBlock Text="Title" VerticalAlignment="Center" Margin="0,0,8,8"/>
<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"/>
<ComboBox x:Name="TypeBox" Grid.Row="1" Grid.Column="1" Grid.ColumnSpan="2" Margin="0,0,0,8"
SelectionChanged="TypeBox_SelectionChanged"/>
<TextBlock Text="Target" Grid.Row="2" VerticalAlignment="Center" Margin="0,0,8,8"/>
<TextBox x:Name="TargetBox" Grid.Row="2" Grid.Column="1" Margin="0,0,8,8"
TextChanged="PreviewField_Changed"/>
<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"/>
<Button Content="Folder" Click="BrowseFolder_Click" Padding="8,4"/>
</StackPanel>
<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="PreviewField_Changed"/>
<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 AppControlBrush}"
Padding="8" MinHeight="72">
<StackPanel>
<ContentControl x:Name="PreviewHost"/>
<TextBlock x:Name="PreviewNote" Margin="0,8,0,0"
Foreground="{DynamicResource AppMutedTextBrush}"
TextWrapping="Wrap"/>
</StackPanel>
</Border>
</Grid>
<TextBlock x:Name="StatusText" Grid.Row="3" Margin="0,14,0,0"
Foreground="{DynamicResource AppMutedTextBrush}"
TextWrapping="Wrap"/>
</Grid>
</GroupBox>
</Grid>
</DockPanel>
</Window>

951
MainWindow.xaml.cs Normal file
View File

@@ -0,0 +1,951 @@
using System.Windows;
using System.Windows.Controls;
using System.Windows.Forms;
using System.Windows.Media;
using System.IO;
using System.Windows.Input;
using System.Reflection;
using TaskbarLauncher.Models;
using TaskbarLauncher.Services;
using MessageBox = System.Windows.MessageBox;
using TreeView = System.Windows.Controls.TreeView;
using TreeViewItem = System.Windows.Controls.TreeViewItem;
namespace TaskbarLauncher;
public partial class MainWindow : Window
{
private readonly LauncherController? controller;
private LauncherItem? selectedItem;
private bool isLoadingSelection;
private bool isLoadingStartupState;
private List<int>? selectedPath;
private string? loadedAutoIconPath;
private System.Windows.Point dragStartPoint;
public MainWindow()
{
InitializeComponent();
}
public MainWindow(LauncherController controller)
{
InitializeComponent();
this.controller = controller;
DisplayModeBox.ItemsSource = Enum.GetValues<LauncherDisplayMode>();
ItemDisplayModeBox.ItemsSource = Enum.GetValues<LauncherDisplayMode>();
TypeBox.ItemsSource = Enum.GetValues<LauncherItemType>();
VersionText.Text = $"Version {GetAppVersion()}";
RefreshView();
}
private void RefreshView(LauncherItem? itemToSelect = null)
{
if (controller is null)
{
return;
}
ItemsTree.Items.Clear();
foreach (LauncherItem item in controller.Config.Items)
{
ItemsTree.Items.Add(CreateTreeItem(item));
}
HotkeyBox.Text = controller.Config.Hotkey;
DisplayModeBox.SelectedItem = controller.Config.DisplayMode;
LoadStartupState();
StatusText.Text = $"Config file: {ConfigService.ConfigPath}";
if (itemToSelect is not null && SelectTreeItem(itemToSelect))
{
selectedItem = itemToSelect;
selectedPath = FindItemPath(itemToSelect);
LoadItemFields(itemToSelect);
}
else
{
ClearItemFields();
}
}
private static string GetAppVersion()
{
return Assembly.GetExecutingAssembly()
.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?
.InformationalVersion ?? "development";
}
private static TreeViewItem CreateTreeItem(LauncherItem item)
{
var treeItem = new TreeViewItem { Header = $"{item.Title} ({item.Type})", Tag = item };
foreach (LauncherItem child in item.Children)
{
treeItem.Items.Add(CreateTreeItem(child));
}
return treeItem;
}
private bool SelectTreeItem(LauncherItem item)
{
foreach (object root in ItemsTree.Items)
{
if (root is TreeViewItem treeItem && SelectTreeItem(treeItem, item))
{
return true;
}
}
return false;
}
private static bool SelectTreeItem(TreeViewItem treeItem, LauncherItem item)
{
if (ReferenceEquals(treeItem.Tag, item))
{
treeItem.IsSelected = true;
treeItem.BringIntoView();
return true;
}
foreach (object child in treeItem.Items)
{
if (child is TreeViewItem childTreeItem && SelectTreeItem(childTreeItem, item))
{
treeItem.IsExpanded = true;
return true;
}
}
return false;
}
private void ItemsTree_SelectedItemChanged(object sender, RoutedPropertyChangedEventArgs<object> e)
{
if (e.NewValue is not TreeViewItem treeItem || treeItem.Tag is not LauncherItem item)
{
selectedItem = null;
ClearItemFields();
return;
}
selectedItem = item;
selectedPath = FindItemPath(item);
LoadItemFields(item);
}
private void ItemsTree_PreviewMouseMove(object sender, System.Windows.Input.MouseEventArgs e)
{
if (e.LeftButton != MouseButtonState.Pressed)
{
dragStartPoint = e.GetPosition(ItemsTree);
return;
}
System.Windows.Point currentPosition = e.GetPosition(ItemsTree);
if (Math.Abs(currentPosition.X - dragStartPoint.X) < SystemParameters.MinimumHorizontalDragDistance &&
Math.Abs(currentPosition.Y - dragStartPoint.Y) < SystemParameters.MinimumVerticalDragDistance)
{
return;
}
if (ItemsTree.SelectedItem is TreeViewItem { Tag: LauncherItem item })
{
DragDrop.DoDragDrop(ItemsTree, item, System.Windows.DragDropEffects.Move);
}
}
private void ItemsTree_DragOver(object sender, System.Windows.DragEventArgs e)
{
e.Effects = CanDrop(e) ? System.Windows.DragDropEffects.Move : System.Windows.DragDropEffects.None;
e.Handled = true;
}
private void ItemsTree_Drop(object sender, System.Windows.DragEventArgs e)
{
if (controller is null ||
!e.Data.GetDataPresent(typeof(LauncherItem)) ||
e.Data.GetData(typeof(LauncherItem)) is not LauncherItem draggedItem)
{
return;
}
TreeViewItem? targetTreeItem = FindAncestor<TreeViewItem>(e.OriginalSource as DependencyObject);
LauncherItem? targetItem = targetTreeItem?.Tag as LauncherItem;
if (targetItem is not null && (ReferenceEquals(draggedItem, targetItem) || ContainsItem(draggedItem, targetItem)))
{
return;
}
ApplyCurrentItem();
List<LauncherItem>? sourceSiblings = FindSiblings(controller.Config.Items, draggedItem);
if (sourceSiblings is null)
{
return;
}
sourceSiblings.Remove(draggedItem);
if (targetItem is null)
{
controller.Config.Items.Add(draggedItem);
}
else if (targetItem.Type == LauncherItemType.Menu)
{
targetItem.Children.Add(draggedItem);
}
else
{
List<LauncherItem>? targetSiblings = FindSiblings(controller.Config.Items, targetItem);
if (targetSiblings is null)
{
controller.Config.Items.Add(draggedItem);
}
else
{
int targetIndex = targetSiblings.IndexOf(targetItem);
targetSiblings.Insert(targetIndex + 1, draggedItem);
}
}
selectedPath = FindItemPath(draggedItem);
RefreshView(draggedItem);
StatusText.Text = $"Moved {draggedItem.Title}. Click Save to keep the new order.";
e.Handled = true;
}
private bool CanDrop(System.Windows.DragEventArgs e)
{
if (!e.Data.GetDataPresent(typeof(LauncherItem)) ||
e.Data.GetData(typeof(LauncherItem)) is not LauncherItem draggedItem)
{
return false;
}
TreeViewItem? targetTreeItem = FindAncestor<TreeViewItem>(e.OriginalSource as DependencyObject);
LauncherItem? targetItem = targetTreeItem?.Tag as LauncherItem;
return targetItem is null || (!ReferenceEquals(draggedItem, targetItem) && !ContainsItem(draggedItem, targetItem));
}
private static bool ContainsItem(LauncherItem parent, LauncherItem possibleChild)
{
foreach (LauncherItem child in parent.Children)
{
if (ReferenceEquals(child, possibleChild) || ContainsItem(child, possibleChild))
{
return true;
}
}
return false;
}
private static T? FindAncestor<T>(DependencyObject? current)
where T : DependencyObject
{
while (current is not null)
{
if (current is T match)
{
return match;
}
current = VisualTreeHelper.GetParent(current);
}
return null;
}
private void LoadItemFields(LauncherItem item)
{
isLoadingSelection = true;
TitleBox.Text = item.Title;
TypeBox.SelectedItem = item.Type;
TargetBox.Text = item.Target;
ArgumentsBox.Text = item.Arguments ?? "";
IconPathBox.Text = item.IconPath ?? "";
loadedAutoIconPath = GetAutomaticIconPath(item);
ItemDisplayModeBox.SelectedItem = item.DisplayMode;
UpdateTargetAvailability();
isLoadingSelection = false;
UpdatePreview();
}
private void ClearItemFields()
{
selectedItem = null;
selectedPath = null;
isLoadingSelection = true;
TitleBox.Text = "";
TypeBox.SelectedIndex = -1;
TargetBox.Text = "";
ArgumentsBox.Text = "";
IconPathBox.Text = "";
loadedAutoIconPath = null;
ItemDisplayModeBox.SelectedItem = controller?.Config.DisplayMode;
UpdateTargetAvailability();
isLoadingSelection = false;
UpdatePreview();
}
private void ApplyCurrentItem()
{
if (controller is null || selectedItem is null)
{
return;
}
selectedItem.Title = string.IsNullOrWhiteSpace(TitleBox.Text) ? "Untitled" : TitleBox.Text.Trim();
selectedItem.Type = TypeBox.SelectedItem is LauncherItemType type ? type : LauncherItemType.App;
selectedItem.Target = selectedItem.Type == LauncherItemType.Menu ? "" : TargetBox.Text.Trim();
selectedItem.Arguments = selectedItem.Type == LauncherItemType.Menu || string.IsNullOrWhiteSpace(ArgumentsBox.Text)
? null
: ArgumentsBox.Text.Trim();
EnsureAutomaticIconPath();
selectedItem.IconPath = string.IsNullOrWhiteSpace(IconPathBox.Text) ? null : IconPathBox.Text.Trim();
selectedItem.DisplayMode = ItemDisplayModeBox.SelectedItem is LauncherDisplayMode itemMode
? itemMode
: controller?.Config.DisplayMode ?? LauncherDisplayMode.LargeIconWithText;
if (selectedItem.Type != LauncherItemType.Menu)
{
selectedItem.Children.Clear();
}
}
private void ApplyConfigFields()
{
if (controller is null)
{
return;
}
controller.Config.Hotkey = string.IsNullOrWhiteSpace(HotkeyBox.Text) ? "Ctrl+Alt+Space" : HotkeyBox.Text.Trim();
controller.Config.DisplayMode = DisplayModeBox.SelectedItem is LauncherDisplayMode mode
? mode
: LauncherDisplayMode.LargeIconWithText;
ApplyCurrentItem();
}
private void LoadStartupState()
{
isLoadingStartupState = true;
StartWithWindowsBox.IsChecked = StartupService.IsEnabled();
isLoadingStartupState = false;
}
private void StartWithWindowsBox_Changed(object sender, RoutedEventArgs e)
{
if (isLoadingStartupState)
{
return;
}
try
{
bool enabled = StartWithWindowsBox.IsChecked == true;
StartupService.SetEnabled(enabled);
StatusText.Text = enabled
? $"Startup enabled: {StartupService.ShortcutPath}"
: "Startup disabled.";
}
catch (Exception ex)
{
MessageBox.Show($"Could not update startup setting.\n\n{ex.Message}", "Taskbar Launcher");
LoadStartupState();
}
}
private void AddRoot_Click(object sender, RoutedEventArgs e)
{
if (controller is null)
{
return;
}
ApplyCurrentItem();
LauncherItem item = CreateNewItem(controller.Config.DisplayMode);
controller.Config.Items.Add(item);
selectedPath = [controller.Config.Items.Count - 1];
RefreshView(item);
}
private void AddTopMenu_Click(object sender, RoutedEventArgs e)
{
if (controller is null)
{
return;
}
ApplyCurrentItem();
LauncherItem menu = CreateNewMenu(controller.Config.DisplayMode);
controller.Config.Items.Add(menu);
selectedPath = [controller.Config.Items.Count - 1];
RefreshView(menu);
StatusText.Text = "Added top-level menu. Rename it, then Save.";
}
private void AddChild_Click(object sender, RoutedEventArgs e)
{
if (controller is null || selectedItem is null)
{
MessageBox.Show("Select a menu item first, then add a child item.", "Taskbar Launcher");
return;
}
LauncherItem parent = selectedItem;
ApplyCurrentItem();
if (parent.Type != LauncherItemType.Menu)
{
MessageBox.Show("Child items can only be added under a Menu item.", "Taskbar Launcher");
return;
}
LauncherItem child = CreateNewItem(controller.Config.DisplayMode);
parent.Children.Add(child);
selectedPath = FindItemPath(child);
RefreshView(child);
}
private void AddSubmenu_Click(object sender, RoutedEventArgs e)
{
if (controller is null || selectedItem is null)
{
MessageBox.Show("Select a menu first, then add a submenu.", "Taskbar Launcher");
return;
}
LauncherItem parent = selectedItem;
ApplyCurrentItem();
if (parent.Type != LauncherItemType.Menu)
{
MessageBox.Show("Submenus can only be added under a Menu item.", "Taskbar Launcher");
return;
}
LauncherItem submenu = CreateNewMenu(controller.Config.DisplayMode);
parent.Children.Add(submenu);
selectedPath = FindItemPath(submenu);
RefreshView(submenu);
StatusText.Text = "Added submenu. Rename it, then Save.";
}
private static LauncherItem CreateNewItem(LauncherDisplayMode displayMode)
{
return new LauncherItem
{
Title = "New Item",
Type = LauncherItemType.App,
Target = "",
DisplayMode = displayMode
};
}
private static LauncherItem CreateNewMenu(LauncherDisplayMode displayMode)
{
return new LauncherItem
{
Title = "New Menu",
Type = LauncherItemType.Menu,
Target = "",
DisplayMode = displayMode
};
}
private void Delete_Click(object sender, RoutedEventArgs e)
{
if (controller is null || selectedItem is null)
{
return;
}
LauncherItem deletedItem = selectedItem;
RemoveItem(controller.Config.Items, selectedItem);
RefreshView();
StatusText.Text = $"Deleted: {deletedItem.Title}";
}
private void MoveUp_Click(object sender, RoutedEventArgs e)
{
MoveSelected(-1);
}
private void MoveDown_Click(object sender, RoutedEventArgs e)
{
MoveSelected(1);
}
private void PromoteToTop_Click(object sender, RoutedEventArgs e)
{
if (controller is null || selectedItem is null)
{
return;
}
ApplyCurrentItem();
if (controller.Config.Items.Contains(selectedItem))
{
StatusText.Text = $"{selectedItem.Title} is already a top-level item.";
return;
}
LauncherItem itemToPromote = selectedItem;
if (!RemoveItem(controller.Config.Items, itemToPromote))
{
return;
}
controller.Config.Items.Add(itemToPromote);
selectedPath = [controller.Config.Items.Count - 1];
RefreshView(itemToPromote);
StatusText.Text = $"Promoted {itemToPromote.Title} to top level. Click Save to keep it.";
}
private void MoveSelected(int direction)
{
if (controller is null || selectedItem is null)
{
return;
}
ApplyCurrentItem();
List<LauncherItem>? siblings = FindSiblings(controller.Config.Items, selectedItem);
if (siblings is null)
{
return;
}
int index = siblings.IndexOf(selectedItem);
int newIndex = index + direction;
if (index < 0 || newIndex < 0 || newIndex >= siblings.Count)
{
return;
}
siblings.RemoveAt(index);
siblings.Insert(newIndex, selectedItem);
RefreshView(selectedItem);
}
private static bool RemoveItem(List<LauncherItem> items, LauncherItem item)
{
if (items.Remove(item))
{
return true;
}
foreach (LauncherItem child in items)
{
if (RemoveItem(child.Children, item))
{
return true;
}
}
return false;
}
private static List<LauncherItem>? FindSiblings(List<LauncherItem> items, LauncherItem item)
{
if (items.Contains(item))
{
return items;
}
foreach (LauncherItem child in items)
{
List<LauncherItem>? found = FindSiblings(child.Children, item);
if (found is not null)
{
return found;
}
}
return null;
}
private void ApplyItem_Click(object sender, RoutedEventArgs e)
{
ApplyCurrentItem();
RefreshView(selectedItem);
}
private void Save_Click(object sender, RoutedEventArgs e)
{
if (controller is null)
{
return;
}
ApplyConfigFields();
ConfigService.Save(controller.Config);
controller.ReloadConfig();
LauncherItem? reloadedSelection = selectedPath is null ? null : FindItemByPath(controller.Config.Items, selectedPath);
RefreshView(reloadedSelection);
StatusText.Text = reloadedSelection is null
? $"Saved: {ConfigService.ConfigPath}"
: $"Saved {reloadedSelection.Title} ({reloadedSelection.Type}) to {ConfigService.ConfigPath}";
}
private void BackupConfig_Click(object sender, RoutedEventArgs e)
{
try
{
if (controller is not null)
{
ApplyConfigFields();
ConfigService.Save(controller.Config);
}
string backupPath = ConfigService.Backup();
StatusText.Text = $"Backed up config to {backupPath}";
}
catch (Exception ex)
{
MessageBox.Show($"Could not back up config.\n\n{ex.Message}", "Taskbar Launcher");
}
}
private void RestoreConfig_Click(object sender, RoutedEventArgs e)
{
using OpenFileDialog dialog = new()
{
Title = "Choose a config backup to restore",
Filter = "JSON config files|*.json|All files|*.*",
InitialDirectory = Directory.Exists(ConfigService.BackupFolder) ? ConfigService.BackupFolder : ConfigService.AppFolder
};
if (dialog.ShowDialog() != System.Windows.Forms.DialogResult.OK)
{
return;
}
MessageBoxResult result = MessageBox.Show(
"Restore this config file and replace the current launcher config?",
"Taskbar Launcher",
MessageBoxButton.YesNo,
MessageBoxImage.Question);
if (result != MessageBoxResult.Yes)
{
return;
}
try
{
ConfigService.Restore(dialog.FileName);
controller?.ReloadConfig();
RefreshView();
StatusText.Text = $"Restored config from {dialog.FileName}";
}
catch (Exception ex)
{
MessageBox.Show($"Could not restore config.\n\n{ex.Message}", "Taskbar Launcher");
}
}
private List<int>? FindItemPath(LauncherItem item)
{
if (controller is null)
{
return null;
}
List<int> path = [];
return FindItemPath(controller.Config.Items, item, path) ? path : null;
}
private static bool FindItemPath(List<LauncherItem> items, LauncherItem item, List<int> path)
{
for (int index = 0; index < items.Count; index++)
{
path.Add(index);
if (ReferenceEquals(items[index], item) || FindItemPath(items[index].Children, item, path))
{
return true;
}
path.RemoveAt(path.Count - 1);
}
return false;
}
private static LauncherItem? FindItemByPath(List<LauncherItem> items, List<int> path)
{
List<LauncherItem> currentItems = items;
LauncherItem? currentItem = null;
foreach (int index in path)
{
if (index < 0 || index >= currentItems.Count)
{
return null;
}
currentItem = currentItems[index];
currentItems = currentItem.Children;
}
return currentItem;
}
private void ReloadConfig_Click(object sender, RoutedEventArgs e)
{
controller?.ReloadConfig();
RefreshView();
}
private void OpenConfigFolder_Click(object sender, RoutedEventArgs e)
{
ConfigService.OpenConfigFolder();
}
private void Close_Click(object sender, RoutedEventArgs e)
{
Hide();
}
private void BrowseFile_Click(object sender, RoutedEventArgs e)
{
using OpenFileDialog dialog = new()
{
Title = "Choose an application, document, or shortcut",
Filter = "Programs and shortcuts|*.exe;*.lnk;*.bat;*.cmd;*.ps1|All files|*.*"
};
if (dialog.ShowDialog() == System.Windows.Forms.DialogResult.OK)
{
TargetBox.Text = dialog.FileName;
SetIconPathFromTargetIfAutomatic(dialog.FileName);
}
}
private void BrowseFolder_Click(object sender, RoutedEventArgs e)
{
using FolderBrowserDialog dialog = new()
{
Description = "Choose a folder to launch"
};
if (dialog.ShowDialog() == System.Windows.Forms.DialogResult.OK)
{
TargetBox.Text = dialog.SelectedPath;
TypeBox.SelectedItem = LauncherItemType.Folder;
SetIconPathFromTargetIfAutomatic(dialog.SelectedPath);
}
}
private void BrowseIcon_Click(object sender, RoutedEventArgs e)
{
using OpenFileDialog dialog = new()
{
Title = "Choose an icon file",
Filter = "Icon files|*.ico;*.exe;*.dll|All files|*.*"
};
if (dialog.ShowDialog() == System.Windows.Forms.DialogResult.OK)
{
IconPathBox.Text = dialog.FileName;
loadedAutoIconPath = null;
}
}
private void ResetIcon_Click(object sender, RoutedEventArgs e)
{
if (TypeBox.SelectedItem is LauncherItemType.Menu)
{
IconPathBox.Text = "";
loadedAutoIconPath = null;
UpdatePreview();
return;
}
string target = TargetBox.Text.Trim();
if (string.IsNullOrWhiteSpace(target))
{
IconPathBox.Text = "";
loadedAutoIconPath = null;
}
else
{
IconPathBox.Text = target;
loadedAutoIconPath = target;
}
UpdatePreview();
}
private void TypeBox_SelectionChanged(object sender, SelectionChangedEventArgs e)
{
if (!isLoadingSelection)
{
UpdateTargetAvailability();
UpdatePreview();
}
}
private void PreviewField_Changed(object sender, EventArgs e)
{
if (!isLoadingSelection)
{
UpdatePreview();
}
}
private void EnsureAutomaticIconPath()
{
if (TypeBox.SelectedItem is LauncherItemType.Menu)
{
return;
}
string target = TargetBox.Text.Trim();
if (!string.IsNullOrWhiteSpace(target))
{
SetIconPathFromTargetIfAutomatic(target);
}
}
private void SetIconPathFromTargetIfAutomatic(string target)
{
string currentIconPath = IconPathBox.Text.Trim();
if (string.IsNullOrWhiteSpace(currentIconPath) ||
string.Equals(currentIconPath, loadedAutoIconPath, StringComparison.OrdinalIgnoreCase))
{
IconPathBox.Text = target;
loadedAutoIconPath = target;
}
}
private static string? GetAutomaticIconPath(LauncherItem item)
{
if (item.Type == LauncherItemType.Menu || string.IsNullOrWhiteSpace(item.Target))
{
return null;
}
return string.Equals(item.IconPath, item.Target, StringComparison.OrdinalIgnoreCase)
? item.Target
: null;
}
private void UpdatePreview()
{
if (PreviewHost is null)
{
return;
}
LauncherItem? previewItem = CreatePreviewItem();
if (previewItem is null)
{
PreviewHost.Content = new TextBlock
{
Text = "Select an item to preview it.",
Foreground = (System.Windows.Media.Brush)System.Windows.Application.Current.Resources["AppMutedTextBrush"]
};
PreviewNote.Text = "";
return;
}
LauncherDisplayMode displayMode = previewItem.DisplayMode;
PreviewHost.Content = CreatePreviewControl(previewItem, displayMode);
PreviewNote.Text = previewItem.Type == LauncherItemType.Menu
? "Menu headers are shown as expandable section headers in the launcher. Display size applies to app, folder, file, and website items."
: $"Icon {DisplayModeMetrics.GetIconSize(displayMode):0}px, row {DisplayModeMetrics.GetRowHeight(displayMode):0}px.";
}
private LauncherItem? CreatePreviewItem()
{
if (selectedItem is null)
{
return null;
}
return new LauncherItem
{
Title = string.IsNullOrWhiteSpace(TitleBox.Text) ? "Untitled" : TitleBox.Text.Trim(),
Type = TypeBox.SelectedItem is LauncherItemType type ? type : LauncherItemType.App,
Target = TargetBox.Text.Trim(),
Arguments = string.IsNullOrWhiteSpace(ArgumentsBox.Text) ? null : ArgumentsBox.Text.Trim(),
IconPath = string.IsNullOrWhiteSpace(IconPathBox.Text) ? null : IconPathBox.Text.Trim(),
DisplayMode = ItemDisplayModeBox.SelectedItem is LauncherDisplayMode mode ? mode : LauncherDisplayMode.CompactList
};
}
private static FrameworkElement CreatePreviewControl(LauncherItem item, LauncherDisplayMode displayMode)
{
if (item.Type == LauncherItemType.Menu)
{
return new TextBlock
{
Text = item.Title,
FontWeight = FontWeights.SemiBold,
VerticalAlignment = VerticalAlignment.Center
};
}
if (displayMode == LauncherDisplayMode.LargeIconOnly)
{
ImageSource? iconOnly = IconService.GetIcon(item, large: true);
return iconOnly is null
? new TextBlock { Text = item.Title, HorizontalAlignment = System.Windows.HorizontalAlignment.Center }
: new System.Windows.Controls.Image
{
Source = iconOnly,
Width = DisplayModeMetrics.GetIconSize(displayMode),
Height = DisplayModeMetrics.GetIconSize(displayMode),
HorizontalAlignment = System.Windows.HorizontalAlignment.Center
};
}
StackPanel panel = new() { Orientation = System.Windows.Controls.Orientation.Horizontal };
bool large = displayMode == LauncherDisplayMode.LargeIconWithText;
ImageSource? icon = IconService.GetIcon(item, large);
if (icon is not null)
{
double size = DisplayModeMetrics.GetIconSize(displayMode);
panel.Children.Add(new System.Windows.Controls.Image
{
Source = icon,
Width = size,
Height = size,
Margin = new Thickness(0, 0, 10, 0),
VerticalAlignment = VerticalAlignment.Center
});
}
panel.Children.Add(new TextBlock
{
Text = item.Title,
FontSize = displayMode == LauncherDisplayMode.CompactList ? 12 : 14,
VerticalAlignment = VerticalAlignment.Center,
TextTrimming = TextTrimming.CharacterEllipsis
});
return new Border
{
MinHeight = DisplayModeMetrics.GetRowHeight(displayMode),
Child = panel
};
}
private void UpdateTargetAvailability()
{
bool isMenu = TypeBox.SelectedItem is LauncherItemType.Menu;
TargetBox.IsEnabled = !isMenu;
ArgumentsBox.IsEnabled = !isMenu;
if (isMenu && !isLoadingSelection)
{
TargetBox.Text = "";
ArgumentsBox.Text = "";
}
}
protected override void OnClosing(System.ComponentModel.CancelEventArgs e)
{
e.Cancel = true;
Hide();
}
}

44
Models/LauncherConfig.cs Normal file
View File

@@ -0,0 +1,44 @@
namespace TaskbarLauncher.Models;
public sealed class LauncherConfig
{
public string Hotkey { get; set; } = "Ctrl+Alt+Space";
public LauncherDisplayMode DisplayMode { get; set; } = LauncherDisplayMode.CompactList;
public List<LauncherItem> Items { get; set; } = [];
}
public sealed class LauncherItem
{
public string Title { get; set; } = "";
public LauncherItemType Type { get; set; } = LauncherItemType.App;
public string Target { get; set; } = "";
public string? Arguments { get; set; }
public string? IconPath { get; set; }
public LauncherDisplayMode DisplayMode { get; set; } = LauncherDisplayMode.CompactList;
public List<LauncherItem> Children { get; set; } = [];
}
public enum LauncherItemType
{
App,
Folder,
File,
Website,
Menu
}
public enum LauncherDisplayMode
{
LargeIconOnly,
SmallIconWithText,
LargeIconWithText,
CompactList
}

158
Services/ConfigService.cs Normal file
View File

@@ -0,0 +1,158 @@
using System.Diagnostics;
using System.IO;
using System.Text.Json;
using System.Text.Json.Serialization;
using TaskbarLauncher.Models;
namespace TaskbarLauncher.Services;
public static class ConfigService
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
WriteIndented = true,
PropertyNameCaseInsensitive = true
};
static ConfigService()
{
JsonOptions.Converters.Add(new JsonStringEnumConverter());
}
public static string AppFolder => AppContext.BaseDirectory;
public static string ConfigPath => Path.Combine(AppFolder, "config.json");
public static string BackupFolder => Path.Combine(AppFolder, "backups");
public static LauncherConfig Load()
{
if (!File.Exists(ConfigPath))
{
LauncherConfig defaults = CreateDefaultConfig();
Save(defaults);
return defaults;
}
string json = File.ReadAllText(ConfigPath);
LauncherConfig config = JsonSerializer.Deserialize<LauncherConfig>(json, JsonOptions) ?? CreateDefaultConfig();
NormalizeDisplayModes(config);
Save(config);
return config;
}
public static void Save(LauncherConfig config)
{
string json = JsonSerializer.Serialize(config, JsonOptions);
File.WriteAllText(ConfigPath, json);
}
public static string Backup()
{
Directory.CreateDirectory(BackupFolder);
if (!File.Exists(ConfigPath))
{
Save(CreateDefaultConfig());
}
string timestamp = DateTime.Now.ToString("yyyyMMdd-HHmmss");
string backupPath = Path.Combine(BackupFolder, $"config-{timestamp}.json");
File.Copy(ConfigPath, backupPath, overwrite: false);
return backupPath;
}
public static LauncherConfig Restore(string sourcePath)
{
if (!File.Exists(sourcePath))
{
throw new FileNotFoundException("The selected config file was not found.", sourcePath);
}
string json = File.ReadAllText(sourcePath);
LauncherConfig config = JsonSerializer.Deserialize<LauncherConfig>(json, JsonOptions)
?? throw new InvalidDataException("The selected config file could not be read.");
NormalizeDisplayModes(config);
Save(config);
return config;
}
public static void OpenConfigFolder()
{
Directory.CreateDirectory(AppFolder);
Process.Start(new ProcessStartInfo
{
FileName = AppFolder,
UseShellExecute = true
});
}
private static LauncherConfig CreateDefaultConfig()
{
LauncherConfig config = new()
{
Items =
[
new LauncherItem
{
Title = "Folders",
Type = LauncherItemType.Menu,
Children =
[
new LauncherItem
{
Title = "Documents",
Type = LauncherItemType.Folder,
Target = Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments)
},
new LauncherItem
{
Title = "Downloads",
Type = LauncherItemType.Folder,
Target = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Downloads")
}
]
},
new LauncherItem
{
Title = "Apps",
Type = LauncherItemType.Menu,
Children =
[
new LauncherItem
{
Title = "Notepad",
Type = LauncherItemType.App,
Target = "notepad.exe",
IconPath = "notepad.exe"
}
]
}
]
};
NormalizeDisplayModes(config);
return config;
}
private static void NormalizeDisplayModes(LauncherConfig config)
{
foreach (LauncherItem item in config.Items)
{
NormalizeDisplayModes(item);
}
}
private static void NormalizeDisplayModes(LauncherItem item)
{
if (!Enum.IsDefined(item.DisplayMode))
{
item.DisplayMode = LauncherDisplayMode.CompactList;
}
foreach (LauncherItem child in item.Children)
{
NormalizeDisplayModes(child);
}
}
}

View File

@@ -0,0 +1,30 @@
using TaskbarLauncher.Models;
namespace TaskbarLauncher.Services;
public static class DisplayModeMetrics
{
public static double GetIconSize(LauncherDisplayMode displayMode)
{
return displayMode switch
{
LauncherDisplayMode.CompactList => 14,
LauncherDisplayMode.SmallIconWithText => 20,
LauncherDisplayMode.LargeIconWithText => 32,
LauncherDisplayMode.LargeIconOnly => 40,
_ => 14
};
}
public static double GetRowHeight(LauncherDisplayMode displayMode)
{
return displayMode switch
{
LauncherDisplayMode.CompactList => 28,
LauncherDisplayMode.SmallIconWithText => 34,
LauncherDisplayMode.LargeIconWithText => 52,
LauncherDisplayMode.LargeIconOnly => 56,
_ => 28
};
}
}

116
Services/HotkeyService.cs Normal file
View File

@@ -0,0 +1,116 @@
using System.Runtime.InteropServices;
using System.Windows;
using System.Windows.Input;
using System.Windows.Interop;
namespace TaskbarLauncher.Services;
public sealed class HotkeyService : IDisposable
{
private const int WmHotkey = 0x0312;
private const int HotkeyId = 9001;
private HwndSource? source;
private Window? window;
public event EventHandler? Pressed;
public void Register(string hotkey)
{
Unregister();
window = new Window
{
Width = 0,
Height = 0,
ShowInTaskbar = false,
WindowStyle = WindowStyle.None,
AllowsTransparency = true,
Opacity = 0,
Left = -10000,
Top = -10000
};
window.SourceInitialized += (_, _) =>
{
var helper = new WindowInteropHelper(window);
source = HwndSource.FromHwnd(helper.Handle);
source?.AddHook(WndProc);
ParseHotkey(hotkey, out uint modifiers, out uint key);
RegisterHotKey(helper.Handle, HotkeyId, modifiers, key);
};
window.Show();
window.Hide();
}
public void Unregister()
{
if (window is not null)
{
var helper = new WindowInteropHelper(window);
if (helper.Handle != IntPtr.Zero)
{
UnregisterHotKey(helper.Handle, HotkeyId);
}
}
source?.RemoveHook(WndProc);
source = null;
window?.Close();
window = null;
}
public void Dispose()
{
Unregister();
}
private IntPtr WndProc(IntPtr hwnd, int msg, IntPtr wParam, IntPtr lParam, ref bool handled)
{
if (msg == WmHotkey && wParam.ToInt32() == HotkeyId)
{
Pressed?.Invoke(this, EventArgs.Empty);
handled = true;
}
return IntPtr.Zero;
}
private static void ParseHotkey(string hotkey, out uint modifiers, out uint key)
{
modifiers = 0;
key = (uint)KeyInterop.VirtualKeyFromKey(Key.Space);
foreach (string part in hotkey.Split('+', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries))
{
if (part.Equals("Ctrl", StringComparison.OrdinalIgnoreCase) || part.Equals("Control", StringComparison.OrdinalIgnoreCase))
{
modifiers |= 0x0002;
}
else if (part.Equals("Alt", StringComparison.OrdinalIgnoreCase))
{
modifiers |= 0x0001;
}
else if (part.Equals("Shift", StringComparison.OrdinalIgnoreCase))
{
modifiers |= 0x0004;
}
else if (part.Equals("Win", StringComparison.OrdinalIgnoreCase))
{
modifiers |= 0x0008;
}
else if (Enum.TryParse(part, true, out Key parsedKey))
{
key = (uint)KeyInterop.VirtualKeyFromKey(parsedKey);
}
}
}
[DllImport("user32.dll", SetLastError = true)]
private static extern bool RegisterHotKey(IntPtr hWnd, int id, uint fsModifiers, uint vk);
[DllImport("user32.dll", SetLastError = true)]
private static extern bool UnregisterHotKey(IntPtr hWnd, int id);
}

127
Services/IconService.cs Normal file
View File

@@ -0,0 +1,127 @@
using System.Drawing;
using System.IO;
using System.Runtime.InteropServices;
using System.Windows;
using System.Windows.Interop;
using System.Windows.Media;
using System.Windows.Media.Imaging;
using TaskbarLauncher.Models;
namespace TaskbarLauncher.Services;
public static class IconService
{
private const uint ShgfiIcon = 0x000000100;
private const uint ShgfiLargeIcon = 0x000000000;
private const uint ShgfiSmallIcon = 0x000000001;
private const uint ShgfiUseFileAttributes = 0x000000010;
private const uint FileAttributeDirectory = 0x00000010;
private const uint FileAttributeNormal = 0x00000080;
public static ImageSource? GetIcon(LauncherItem item, bool large)
{
ImageSource? explicitIcon = TryLoadIcon(item.IconPath, large, useAttributes: false);
if (explicitIcon is not null)
{
return explicitIcon;
}
if (!string.IsNullOrWhiteSpace(item.Target))
{
ImageSource? targetIcon = TryLoadIcon(item.Target, large, useAttributes: false);
if (targetIcon is not null)
{
return targetIcon;
}
}
return item.Type switch
{
LauncherItemType.Folder => TryLoadIcon("folder", large, useAttributes: true, isDirectory: true),
LauncherItemType.Website => TryLoadIcon(".url", large, useAttributes: true),
LauncherItemType.File => TryLoadIcon(".txt", large, useAttributes: true),
LauncherItemType.Menu => TryLoadIcon("folder", large, useAttributes: true, isDirectory: true),
_ => TryLoadIcon(".exe", large, useAttributes: true)
};
}
private static ImageSource? TryLoadIcon(string? path, bool large, bool useAttributes, bool isDirectory = false)
{
if (string.IsNullOrWhiteSpace(path))
{
return null;
}
try
{
uint flags = ShgfiIcon | (large ? ShgfiLargeIcon : ShgfiSmallIcon);
uint attributes = isDirectory ? FileAttributeDirectory : FileAttributeNormal;
if (useAttributes)
{
flags |= ShgfiUseFileAttributes;
}
IntPtr result = SHGetFileInfo(path, attributes, out ShFileInfo fileInfo, (uint)Marshal.SizeOf<ShFileInfo>(), flags);
if (result == IntPtr.Zero || fileInfo.IconHandle == IntPtr.Zero)
{
return null;
}
ImageSource image = Imaging.CreateBitmapSourceFromHIcon(
fileInfo.IconHandle,
Int32Rect.Empty,
BitmapSizeOptions.FromEmptyOptions());
image.Freeze();
DestroyIcon(fileInfo.IconHandle);
return image;
}
catch
{
try
{
using Icon? icon = File.Exists(path) ? Icon.ExtractAssociatedIcon(path) : null;
if (icon is null)
{
return null;
}
ImageSource image = Imaging.CreateBitmapSourceFromHIcon(
icon.Handle,
Int32Rect.Empty,
BitmapSizeOptions.FromEmptyOptions());
image.Freeze();
return image;
}
catch
{
return null;
}
}
}
[DllImport("shell32.dll", CharSet = CharSet.Unicode)]
private static extern IntPtr SHGetFileInfo(
string path,
uint fileAttributes,
out ShFileInfo fileInfo,
uint fileInfoSize,
uint flags);
[DllImport("user32.dll", SetLastError = true)]
private static extern bool DestroyIcon(IntPtr icon);
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
private struct ShFileInfo
{
public IntPtr IconHandle;
public int IconIndex;
public uint Attributes;
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 260)]
public string DisplayName;
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 80)]
public string TypeName;
}
}

View File

@@ -0,0 +1,109 @@
using System.Diagnostics;
using System.Drawing;
using System.IO;
using System.Windows.Forms;
using TaskbarLauncher.Models;
using TaskbarLauncher.Views;
namespace TaskbarLauncher.Services;
public sealed class LauncherController : IDisposable
{
private readonly HotkeyService hotkeyService = new();
private NotifyIcon? notifyIcon;
private LauncherPopup? popup;
private MainWindow? settingsWindow;
public LauncherConfig Config { get; private set; } = new();
public void Start()
{
Config = ConfigService.Load();
CreateTrayIcon();
RegisterHotkey();
}
public void ReloadConfig()
{
Config = ConfigService.Load();
RegisterHotkey();
popup?.Close();
popup = null;
}
public void ShowSettings()
{
settingsWindow ??= new MainWindow(this);
settingsWindow.Show();
settingsWindow.Activate();
}
public void TogglePopup()
{
if (popup?.IsVisible == true)
{
popup.Close();
popup = null;
return;
}
popup = new LauncherPopup(Config, LaunchItem, ShowSettings);
popup.Closed += (_, _) => popup = null;
popup.ShowNearTaskbar();
}
public void Dispose()
{
hotkeyService.Dispose();
notifyIcon?.Dispose();
}
private void RegisterHotkey()
{
hotkeyService.Pressed -= HotkeyPressed;
hotkeyService.Register(Config.Hotkey);
hotkeyService.Pressed += HotkeyPressed;
}
private void HotkeyPressed(object? sender, EventArgs e)
{
System.Windows.Application.Current.Dispatcher.Invoke(TogglePopup);
}
private void CreateTrayIcon()
{
notifyIcon = new NotifyIcon
{
Text = "Taskbar Launcher",
Icon = SystemIcons.Application,
Visible = true,
ContextMenuStrip = new ContextMenuStrip()
};
notifyIcon.DoubleClick += (_, _) => TogglePopup();
notifyIcon.ContextMenuStrip.Items.Add("Open Launcher", null, (_, _) => TogglePopup());
notifyIcon.ContextMenuStrip.Items.Add("Settings", null, (_, _) => ShowSettings());
notifyIcon.ContextMenuStrip.Items.Add("Reload Config", null, (_, _) => ReloadConfig());
notifyIcon.ContextMenuStrip.Items.Add("Open Config Folder", null, (_, _) => ConfigService.OpenConfigFolder());
notifyIcon.ContextMenuStrip.Items.Add("Exit", null, (_, _) => System.Windows.Application.Current.Shutdown());
}
private void LaunchItem(LauncherItem item)
{
if (item.Type == LauncherItemType.Menu || string.IsNullOrWhiteSpace(item.Target))
{
return;
}
ProcessStartInfo startInfo = new()
{
FileName = item.Target,
Arguments = item.Arguments ?? "",
UseShellExecute = true,
WorkingDirectory = Directory.Exists(item.Target) ? item.Target : ConfigService.AppFolder
};
Process.Start(startInfo);
popup?.Close();
}
}

View File

@@ -0,0 +1,68 @@
using System.IO;
namespace TaskbarLauncher.Services;
public static class StartupService
{
private const string ShortcutName = "TaskbarLauncher.lnk";
public static string StartupFolder => Environment.GetFolderPath(Environment.SpecialFolder.Startup);
public static string ShortcutPath => Path.Combine(StartupFolder, ShortcutName);
public static bool IsEnabled()
{
return File.Exists(ShortcutPath);
}
public static void SetEnabled(bool enabled)
{
if (enabled)
{
CreateShortcut();
}
else
{
RemoveShortcut();
}
}
public static string? GetShortcutTarget()
{
if (!File.Exists(ShortcutPath))
{
return null;
}
Type shellType = Type.GetTypeFromProgID("WScript.Shell")
?? throw new InvalidOperationException("Windows Script Host is not available.");
dynamic shell = Activator.CreateInstance(shellType)
?? throw new InvalidOperationException("Could not create Windows Script Host shell.");
dynamic shortcut = shell.CreateShortcut(ShortcutPath);
return shortcut.TargetPath;
}
private static void CreateShortcut()
{
Directory.CreateDirectory(StartupFolder);
string executablePath = Environment.ProcessPath ?? Path.Combine(ConfigService.AppFolder, "TaskbarLauncher.exe");
Type shellType = Type.GetTypeFromProgID("WScript.Shell")
?? throw new InvalidOperationException("Windows Script Host is not available.");
dynamic shell = Activator.CreateInstance(shellType)
?? throw new InvalidOperationException("Could not create Windows Script Host shell.");
dynamic shortcut = shell.CreateShortcut(ShortcutPath);
shortcut.TargetPath = executablePath;
shortcut.WorkingDirectory = ConfigService.AppFolder;
shortcut.Description = "Start Taskbar Launcher";
shortcut.Save();
}
private static void RemoveShortcut()
{
if (File.Exists(ShortcutPath))
{
File.Delete(ShortcutPath);
}
}
}

84
Services/ThemeService.cs Normal file
View File

@@ -0,0 +1,84 @@
using System.Windows;
using System.Windows.Media;
using Microsoft.Win32;
using MediaColor = System.Windows.Media.Color;
namespace TaskbarLauncher.Services;
public static class ThemeService
{
private const string PersonalizeKey = @"Software\Microsoft\Windows\CurrentVersion\Themes\Personalize";
private const string DwmKey = @"Software\Microsoft\Windows\DWM";
public static void Apply()
{
bool isLight = IsLightTheme();
MediaColor accent = GetAccentColor();
MediaColor window = isLight ? MediaColor.FromRgb(0xF7, 0xF7, 0xF7) : MediaColor.FromRgb(0x20, 0x20, 0x20);
MediaColor surface = isLight ? System.Windows.Media.Colors.White : MediaColor.FromRgb(0x2B, 0x2B, 0x2B);
MediaColor control = isLight ? MediaColor.FromRgb(0xF3, 0xF3, 0xF3) : MediaColor.FromRgb(0x33, 0x33, 0x33);
MediaColor hover = isLight ? MediaColor.FromRgb(0xEA, 0xF3, 0xFF) : MediaColor.FromRgb(0x3B, 0x4A, 0x5C);
MediaColor text = isLight ? MediaColor.FromRgb(0x1A, 0x1A, 0x1A) : MediaColor.FromRgb(0xF3, 0xF3, 0xF3);
MediaColor muted = isLight ? MediaColor.FromRgb(0x66, 0x66, 0x66) : MediaColor.FromRgb(0xB8, 0xB8, 0xB8);
MediaColor border = isLight ? MediaColor.FromRgb(0xC8, 0xC8, 0xC8) : MediaColor.FromRgb(0x55, 0x55, 0x55);
MediaColor selectedText = IsDark(accent) ? System.Windows.Media.Colors.White : System.Windows.Media.Colors.Black;
SetBrush("AppWindowBrush", window);
SetBrush("AppSurfaceBrush", surface);
SetBrush("AppControlBrush", control);
SetBrush("AppControlHoverBrush", hover);
SetBrush("AppTextBrush", text);
SetBrush("AppMutedTextBrush", muted);
SetBrush("AppBorderBrush", border);
SetBrush("AppAccentBrush", accent);
SetBrush("AppSelectedTextBrush", selectedText);
SetBrush(System.Windows.SystemColors.WindowBrushKey, surface);
SetBrush(System.Windows.SystemColors.WindowTextBrushKey, text);
SetBrush(System.Windows.SystemColors.ControlBrushKey, control);
SetBrush(System.Windows.SystemColors.ControlTextBrushKey, text);
SetBrush(System.Windows.SystemColors.ControlDarkBrushKey, border);
SetBrush(System.Windows.SystemColors.HighlightBrushKey, accent);
SetBrush(System.Windows.SystemColors.HighlightTextBrushKey, selectedText);
}
private static bool IsLightTheme()
{
object? value = Registry.CurrentUser.OpenSubKey(PersonalizeKey)?.GetValue("AppsUseLightTheme");
return value is null || Convert.ToInt32(value) != 0;
}
private static MediaColor GetAccentColor()
{
object? value = Registry.CurrentUser.OpenSubKey(DwmKey)?.GetValue("AccentColor");
if (value is int colorValue)
{
byte r = (byte)(colorValue & 0xFF);
byte g = (byte)((colorValue >> 8) & 0xFF);
byte b = (byte)((colorValue >> 16) & 0xFF);
return MediaColor.FromRgb(r, g, b);
}
return MediaColor.FromRgb(0x00, 0x78, 0xD4);
}
private static void SetBrush(string key, MediaColor color)
{
SolidColorBrush brush = new(color);
brush.Freeze();
System.Windows.Application.Current.Resources[key] = brush;
}
private static void SetBrush(ResourceKey key, MediaColor color)
{
SolidColorBrush brush = new(color);
brush.Freeze();
System.Windows.Application.Current.Resources[key] = brush;
}
private static bool IsDark(MediaColor color)
{
double luminance = (0.299 * color.R) + (0.587 * color.G) + (0.114 * color.B);
return luminance < 140;
}
}

17
TaskbarLauncher.csproj Normal file
View File

@@ -0,0 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net10.0-windows</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<UseWPF>true</UseWPF>
<UseWindowsForms>true</UseWindowsForms>
<DefaultItemExcludes>$(DefaultItemExcludes);bin\**;obj\**;..\TaskbarLauncher-build\**</DefaultItemExcludes>
<Version>1.0.0</Version>
<AssemblyVersion>1.0.0.0</AssemblyVersion>
<FileVersion>1.0.0.0</FileVersion>
<InformationalVersion>1.0.0</InformationalVersion>
</PropertyGroup>
</Project>

View File

@@ -0,0 +1,98 @@
param(
[string]$Configuration = "Release",
[string]$Runtime = "win-x64",
[switch]$IncludeCurrentConfig,
[switch]$SelfContained
)
$ErrorActionPreference = "Stop"
$projectDir = Split-Path -Parent $PSScriptRoot
$workspaceDir = Split-Path -Parent $projectDir
$projectPath = Join-Path $projectDir "TaskbarLauncher.csproj"
[xml]$projectXml = Get-Content -LiteralPath $projectPath
$version = $projectXml.Project.PropertyGroup.Version | Select-Object -First 1
if ([string]::IsNullOrWhiteSpace($version)) {
$version = "0.0.0"
}
$packageKind = if ($SelfContained) { "self-contained" } else { "small" }
$releaseRoot = Join-Path $workspaceDir "TaskbarLauncher-releases"
$releaseDir = Join-Path $releaseRoot "TaskbarLauncher-v$version-$packageKind"
$zipPath = Join-Path $releaseRoot "TaskbarLauncher-v$version-$packageKind.zip"
$buildDir = Join-Path $workspaceDir "TaskbarLauncher-build-release-$packageKind"
Get-Process TaskbarLauncher -ErrorAction SilentlyContinue | Stop-Process -Force
if (Test-Path -LiteralPath $releaseDir) {
Remove-Item -LiteralPath $releaseDir -Recurse -Force
}
New-Item -ItemType Directory -Path $releaseDir | Out-Null
dotnet publish $projectPath `
-c $Configuration `
-r $Runtime `
--self-contained:$($SelfContained.IsPresent.ToString().ToLowerInvariant()) `
-p:PublishSingleFile=false `
-p:DebugType=None `
-p:DebugSymbols=false `
-p:BaseOutputPath="$buildDir\bin\" `
-p:BaseIntermediateOutputPath="$buildDir\obj\" `
-o $releaseDir
Get-ChildItem -LiteralPath $releaseDir -Filter "*.pdb" -ErrorAction SilentlyContinue |
Remove-Item -Force
if ($IncludeCurrentConfig) {
$configPath = Join-Path $workspaceDir "TaskbarLauncher-editor-publish\config.json"
if (Test-Path -LiteralPath $configPath) {
Copy-Item -LiteralPath $configPath -Destination (Join-Path $releaseDir "config.json") -Force
}
}
$runtimeNote = if ($SelfContained) {
"This package includes the .NET runtime. No separate .NET install is required."
} else {
"This small package requires Microsoft .NET 10 Desktop Runtime x64."
}
$readme = @"
Taskbar Launcher v$version
Requirements:
- Windows x64
- $runtimeNote
.NET 10 Desktop Runtime download:
https://dotnet.microsoft.com/en-us/download/dotnet/10.0
Run:
Double-click TaskbarLauncher.exe.
Notes:
- The app stores portable settings in config.json beside TaskbarLauncher.exe.
- If config.json is missing, the app creates a clean starter config on first run.
- Use Settings > Backup Config before making larger menu changes.
"@
Set-Content -LiteralPath (Join-Path $releaseDir "README.txt") -Value $readme -Encoding UTF8
if (Test-Path -LiteralPath $zipPath) {
Remove-Item -LiteralPath $zipPath -Force
}
Compress-Archive -Path (Join-Path $releaseDir "*") -DestinationPath $zipPath -Force
$folderSize = (Get-ChildItem -LiteralPath $releaseDir -Recurse | Measure-Object -Property Length -Sum).Sum
$zip = Get-Item -LiteralPath $zipPath
[pscustomobject]@{
Version = $version
PackageKind = $packageKind
ReleaseFolder = $releaseDir
ZipPath = $zip.FullName
FolderBytes = $folderSize
ZipBytes = $zip.Length
}

36
Views/LauncherPopup.xaml Normal file
View File

@@ -0,0 +1,36 @@
<Window x:Class="TaskbarLauncher.Views.LauncherPopup"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="Taskbar Launcher"
Width="360" SizeToContent="Height"
MaxHeight="720"
WindowStyle="None"
ResizeMode="NoResize"
ShowInTaskbar="False"
Topmost="True"
Background="{DynamicResource AppWindowBrush}"
Foreground="{DynamicResource AppTextBrush}">
<Border BorderThickness="1"
BorderBrush="{DynamicResource AppBorderBrush}"
Background="{DynamicResource AppSurfaceBrush}"
Padding="10">
<DockPanel>
<StackPanel DockPanel.Dock="Top" Orientation="Horizontal" Margin="0,0,0,8">
<TextBlock Text="Taskbar Launcher" FontWeight="SemiBold" VerticalAlignment="Center"/>
<Button Content="Settings" HorizontalAlignment="Right" Margin="12,0,0,0" Padding="8,4"
Click="Settings_Click"/>
</StackPanel>
<TextBox x:Name="SearchBox"
DockPanel.Dock="Top"
Margin="0,0,0,8"
Padding="8,5"
TextChanged="SearchBox_TextChanged"
PreviewKeyDown="SearchBox_PreviewKeyDown"/>
<ScrollViewer x:Name="ItemsScrollViewer"
VerticalScrollBarVisibility="Auto"
HorizontalScrollBarVisibility="Disabled">
<StackPanel x:Name="ItemsPanel"/>
</ScrollViewer>
</DockPanel>
</Border>
</Window>

331
Views/LauncherPopup.xaml.cs Normal file
View File

@@ -0,0 +1,331 @@
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using System.Windows.Media;
using System.Windows.Threading;
using TaskbarLauncher.Models;
using TaskbarLauncher.Services;
namespace TaskbarLauncher.Views;
public partial class LauncherPopup : Window
{
private const double ScreenMargin = 12;
private const double HeaderAndChromeHeight = 118;
private bool isRepositionQueued;
private readonly List<System.Windows.Controls.Button> visibleLaunchButtons = [];
private readonly LauncherConfig config;
private readonly Action<LauncherItem> launchItem;
private readonly Action showSettings;
public LauncherPopup(LauncherConfig config, Action<LauncherItem> launchItem, Action showSettings)
{
InitializeComponent();
this.config = config;
this.launchItem = launchItem;
this.showSettings = showSettings;
BuildItems();
SizeChanged += (_, _) => QueueReposition();
}
public void ShowNearTaskbar()
{
WindowStartupLocation = WindowStartupLocation.Manual;
ApplyScreenBounds();
Show();
UpdateLayout();
RepositionAboveTaskbar();
Activate();
SearchBox.Focus();
}
private void QueueReposition()
{
if (isRepositionQueued)
{
return;
}
isRepositionQueued = true;
Dispatcher.BeginInvoke(() =>
{
isRepositionQueued = false;
ApplyScreenBounds();
RepositionAboveTaskbar();
}, DispatcherPriority.Loaded);
}
private void ApplyScreenBounds()
{
Rect workArea = SystemParameters.WorkArea;
double maxPopupHeight = Math.Max(240, workArea.Height - (ScreenMargin * 2));
MaxHeight = maxPopupHeight;
ItemsScrollViewer.MaxHeight = Math.Max(160, maxPopupHeight - HeaderAndChromeHeight);
Left = Math.Max(workArea.Left + ScreenMargin, workArea.Right - Width - ScreenMargin);
}
private void RepositionAboveTaskbar()
{
Rect workArea = SystemParameters.WorkArea;
double height = ActualHeight > 0 ? ActualHeight : DesiredSize.Height;
double clampedHeight = Math.Min(height, MaxHeight);
if (height > MaxHeight && !double.IsNaN(MaxHeight) && MaxHeight > 0)
{
Height = MaxHeight;
clampedHeight = MaxHeight;
}
else if (Height > 0 && height < MaxHeight)
{
ClearValue(HeightProperty);
}
Top = Math.Max(workArea.Top + ScreenMargin, workArea.Bottom - clampedHeight - ScreenMargin);
}
private void BuildItems()
{
ItemsPanel.Children.Clear();
visibleLaunchButtons.Clear();
bool expandMenusByDefault = config.Items.Count <= 2;
foreach (LauncherItem item in config.Items)
{
ItemsPanel.Children.Add(CreateItemControl(item, 0, expandMenusByDefault));
}
}
private FrameworkElement CreateItemControl(LauncherItem item, int depth, bool expandMenusByDefault)
{
if (item.Type == LauncherItemType.Menu)
{
Expander expander = new()
{
Header = item.Title,
IsExpanded = expandMenusByDefault,
Margin = new Thickness(depth * 12, 4, 0, 4)
};
StackPanel children = new();
foreach (LauncherItem child in item.Children)
{
children.Children.Add(CreateItemControl(child, depth + 1, expandMenusByDefault));
}
expander.Content = children;
return expander;
}
System.Windows.Controls.Button button = new()
{
Content = CreateButtonContent(item),
Margin = new Thickness(depth * 12, 2, 0, 2),
MinHeight = DisplayModeMetrics.GetRowHeight(GetEffectiveDisplayMode(item)),
Tag = item
};
button.Click += (_, _) => launchItem(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);
return button;
}
private void SearchBox_TextChanged(object sender, TextChangedEventArgs e)
{
string query = SearchBox.Text.Trim();
if (string.IsNullOrWhiteSpace(query))
{
BuildItems();
}
else
{
BuildSearchResults(query);
}
QueueReposition();
}
private void BuildSearchResults(string query)
{
ItemsPanel.Children.Clear();
visibleLaunchButtons.Clear();
List<LauncherItem> matches = [];
CollectMatches(config.Items, query, matches);
if (matches.Count == 0)
{
ItemsPanel.Children.Add(new TextBlock
{
Text = "No matches",
Foreground = (System.Windows.Media.Brush)System.Windows.Application.Current.Resources["AppMutedTextBrush"],
Margin = new Thickness(4, 8, 4, 8)
});
return;
}
foreach (LauncherItem item in matches)
{
ItemsPanel.Children.Add(CreateItemControl(item, 0, expandMenusByDefault: false));
}
}
private static void CollectMatches(IEnumerable<LauncherItem> items, string query, List<LauncherItem> matches)
{
foreach (LauncherItem item in items)
{
bool isLaunchable = item.Type != LauncherItemType.Menu;
if (isLaunchable && Matches(item, query))
{
matches.Add(item);
}
if (item.Children.Count > 0)
{
CollectMatches(item.Children, query, matches);
}
}
}
private static bool Matches(LauncherItem item, string query)
{
return item.Title.Contains(query, StringComparison.OrdinalIgnoreCase) ||
item.Target.Contains(query, StringComparison.OrdinalIgnoreCase) ||
(item.Arguments?.Contains(query, StringComparison.OrdinalIgnoreCase) ?? false);
}
private void SearchBox_PreviewKeyDown(object sender, System.Windows.Input.KeyEventArgs e)
{
if (e.Key == Key.Escape)
{
Close();
e.Handled = true;
return;
}
if (e.Key == Key.Enter && visibleLaunchButtons.FirstOrDefault()?.Tag is LauncherItem item)
{
launchItem(item);
e.Handled = true;
return;
}
if (e.Key == Key.Down && visibleLaunchButtons.FirstOrDefault() is System.Windows.Controls.Button firstButton)
{
Keyboard.ClearFocus();
firstButton.Focus();
e.Handled = true;
}
}
protected override void OnKeyDown(System.Windows.Input.KeyEventArgs e)
{
if (e.Key == Key.Escape)
{
Close();
e.Handled = true;
return;
}
base.OnKeyDown(e);
}
private object CreateButtonContent(LauncherItem item)
{
LauncherDisplayMode displayMode = GetEffectiveDisplayMode(item);
if (displayMode == LauncherDisplayMode.LargeIconOnly)
{
ImageSource? iconOnly = IconService.GetIcon(item, large: true);
if (iconOnly is not null)
{
double iconOnlySize = DisplayModeMetrics.GetIconSize(displayMode);
return new System.Windows.Controls.Image
{
Source = iconOnly,
Width = iconOnlySize,
Height = iconOnlySize,
HorizontalAlignment = System.Windows.HorizontalAlignment.Center
};
}
return new TextBlock
{
Text = GetLabel(item),
FontSize = 20,
HorizontalAlignment = System.Windows.HorizontalAlignment.Center
};
}
StackPanel panel = new() { Orientation = System.Windows.Controls.Orientation.Horizontal };
bool large = displayMode is LauncherDisplayMode.LargeIconOnly or LauncherDisplayMode.LargeIconWithText;
ImageSource? icon = IconService.GetIcon(item, large);
if (icon is not null)
{
double size = DisplayModeMetrics.GetIconSize(displayMode);
panel.Children.Add(new System.Windows.Controls.Image
{
Source = icon,
Width = size,
Height = size,
Margin = new Thickness(0, 0, 10, 0),
VerticalAlignment = VerticalAlignment.Center
});
}
else
{
panel.Children.Add(new TextBlock
{
Text = GetLabel(item),
FontSize = displayMode == LauncherDisplayMode.SmallIconWithText ? 12 : 14,
Width = displayMode == LauncherDisplayMode.SmallIconWithText ? 64 : 74,
VerticalAlignment = VerticalAlignment.Center
});
}
panel.Children.Add(new TextBlock
{
Text = item.Title,
FontSize = displayMode == LauncherDisplayMode.CompactList ? 12 : 14,
VerticalAlignment = VerticalAlignment.Center,
TextTrimming = TextTrimming.CharacterEllipsis
});
return panel;
}
private LauncherDisplayMode GetEffectiveDisplayMode(LauncherItem item)
{
return item.DisplayMode;
}
private static string GetLabel(LauncherItem item)
{
return item.Type switch
{
LauncherItemType.Folder => "[Folder]",
LauncherItemType.Website => "[Web]",
LauncherItemType.File => "[File]",
_ => "[App]"
};
}
private void Settings_Click(object sender, RoutedEventArgs e)
{
showSettings();
}
}