Initial release of Orbit 1.1.2

This commit is contained in:
Dario P. committed 2026-10-15 22:46:21 +02:00
commit 93ab510b9f
20 files changed
+1121

No files matched your search

+9
View File
@@ -0,0 +1,9 @@
bin/
obj/
portable/
release/
*.zip
.vs/
*.user
settings.json*
shortcuts.json*
+82
View File
@@ -0,0 +1,82 @@
<Application x:Class="ShortcutsPanel.App" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<Application.Resources>
<SolidColorBrush x:Key="Ink" Color="#252924"/>
<Style TargetType="Window"><Setter Property="Background" Value="{DynamicResource WindowBackground}"/><Setter Property="Foreground" Value="{DynamicResource Ink}"/><Setter Property="FontFamily" Value="Segoe UI"/></Style>
<Style TargetType="Button">
<Setter Property="Foreground" Value="{DynamicResource Ink}"/><Setter Property="Background" Value="{DynamicResource ControlBackground}"/><Setter Property="Padding" Value="16,10"/><Setter Property="Cursor" Value="Hand"/><Setter Property="BorderThickness" Value="0"/>
<Setter Property="Template"><Setter.Value><ControlTemplate TargetType="Button"><Border x:Name="Surface" Background="{TemplateBinding Background}" CornerRadius="7" Padding="{TemplateBinding Padding}"><ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/></Border><ControlTemplate.Triggers><Trigger Property="IsMouseOver" Value="True"><Setter TargetName="Surface" Property="Opacity" Value="0.78"/></Trigger><Trigger Property="IsKeyboardFocused" Value="True"><Setter TargetName="Surface" Property="BorderBrush" Value="{DynamicResource FocusBorder}"/><Setter TargetName="Surface" Property="BorderThickness" Value="2"/></Trigger><Trigger Property="IsEnabled" Value="False"><Setter TargetName="Surface" Property="Opacity" Value="0.4"/></Trigger></ControlTemplate.Triggers></ControlTemplate></Setter.Value></Setter>
</Style>
<Style TargetType="TextBox">
<Setter Property="MinHeight" Value="42"/>
<Setter Property="VerticalContentAlignment" Value="Center"/>
<Setter Property="Background" Value="{DynamicResource InputBackground}"/>
<Setter Property="Foreground" Value="{DynamicResource Ink}"/>
<Setter Property="CaretBrush" Value="{DynamicResource Ink}"/>
<Setter Property="BorderBrush" Value="{DynamicResource InputBorder}"/>
<Setter Property="Padding" Value="12,6"/>
<Setter Property="FontSize" Value="14"/>
</Style>
<Style x:Key="ThemeSwitch" TargetType="CheckBox">
<Setter Property="Foreground" Value="{DynamicResource Ink}"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="Template"><Setter.Value><ControlTemplate TargetType="CheckBox">
<StackPanel Orientation="Horizontal">
<Border x:Name="Track" Width="36" Height="20" CornerRadius="10" Background="{DynamicResource SwitchOff}" Margin="0,0,10,0">
<Ellipse x:Name="Thumb" Width="14" Height="14" Fill="White" HorizontalAlignment="Left" Margin="3,0,3,0"/>
</Border>
<ContentPresenter VerticalAlignment="Center"/>
</StackPanel>
<ControlTemplate.Triggers>
<Trigger Property="IsChecked" Value="True"><Setter TargetName="Track" Property="Background" Value="{DynamicResource SwitchOn}"/><Setter TargetName="Thumb" Property="HorizontalAlignment" Value="Right"/></Trigger>
<Trigger Property="IsKeyboardFocused" Value="True"><Setter TargetName="Track" Property="BorderBrush" Value="{DynamicResource SwitchFocus}"/><Setter TargetName="Track" Property="BorderThickness" Value="2"/></Trigger>
</ControlTemplate.Triggers>
</ControlTemplate></Setter.Value></Setter>
</Style>
<Style x:Key="CardButton" TargetType="Button" BasedOn="{StaticResource {x:Type Button}}">
<Setter Property="FocusVisualStyle" Value="{x:Null}"/>
<Setter Property="Template"><Setter.Value><ControlTemplate TargetType="Button">
<Border Background="{TemplateBinding Background}" Padding="{TemplateBinding Padding}">
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
</ControlTemplate></Setter.Value></Setter>
</Style>
</Application.Resources>
</Application>
+10
View File
@@ -0,0 +1,10 @@
using System.Windows;
namespace ShortcutsPanel;
public partial class App : Application
{
protected override void OnStartup(StartupEventArgs e)
{
base.OnStartup(e);
new MainWindow().Show();
}
}
+26
View File
@@ -0,0 +1,26 @@
using System.IO;
using System.Windows.Media.Imaging;
namespace ShortcutsPanel;
public static class CustomIcons
{
public static string FromFile(string path)
{
if (new FileInfo(path).Length > 5 * 1024 * 1024) throw new InvalidDataException("Choose an image smaller than 5 MB.");
using var stream = File.OpenRead(path);
var bitmap = new BitmapImage(); bitmap.BeginInit(); bitmap.CacheOption = BitmapCacheOption.OnLoad;
bitmap.DecodePixelWidth = 96; bitmap.StreamSource = stream; bitmap.EndInit(); bitmap.Freeze();
var png = new PngBitmapEncoder(); png.Frames.Add(BitmapFrame.Create(bitmap)); using var output = new MemoryStream(); png.Save(output);
var encoded = Convert.ToBase64String(output.ToArray()); Decode(encoded); return encoded;
}
public static BitmapSource Decode(string encoded)
{
if (encoded.Length > 350000) throw new InvalidDataException("Custom icon is too large.");
using var stream = new MemoryStream(Convert.FromBase64String(encoded));
var bitmap = new BitmapImage(); bitmap.BeginInit(); bitmap.CacheOption = BitmapCacheOption.OnLoad;
bitmap.DecodePixelWidth = 96; bitmap.StreamSource = stream; bitmap.EndInit(); bitmap.Freeze();
if (bitmap.PixelHeight > 1024) throw new InvalidDataException("Custom icon has unsupported dimensions.");
return bitmap;
}
}
+64
View File
@@ -0,0 +1,64 @@
using System.IO;
using System.Text.Json;
using System.Text.RegularExpressions;
namespace ShortcutsPanel;
public sealed record LibraryExport(int Version, List<Shortcut> Links, List<string> Collections);
public static class LibraryOperations
{
public static List<Shortcut> Validate(IEnumerable<Shortcut> input)
{
var result = new List<Shortcut>(); var ids = new HashSet<string>();
foreach (var item in input)
{
if (result.Count >= 5000) throw new InvalidDataException("A library can contain at most 5,000 websites.");
if (item == null || string.IsNullOrWhiteSpace(item.Name) || item.Name.Length > 60 || string.IsNullOrWhiteSpace(item.Group) || item.Group.Length > 60 || item.Url?.Length > 2048 || !MainWindow.ValidUrl(item.Url, out var url)) throw new InvalidDataException("Every website needs a name, collection, and valid HTTP or HTTPS URL.");
if (item.Color == null || !Regex.IsMatch(item.Color, "^#[0-9a-fA-F]{6}$")) throw new InvalidDataException("Invalid website accent color.");
if (item.Icon != null) CustomIcons.Decode(item.Icon);
var id = item.Id;
if (string.IsNullOrWhiteSpace(id) || id.Length > 64 || !ids.Add(id)) { id = Guid.NewGuid().ToString("N"); ids.Add(id); }
result.Add(item with { Id = id, Name = item.Name.Trim(), Url = url, Group = item.Group.Trim() == "All" ? "All (collection)" : item.Group.Trim() });
}
return result;
}
public static List<string> Collections(IEnumerable<Shortcut> entries, IEnumerable<string> order) =>
order.Concat(entries.Select(x => x.Group)).Where(x => !string.IsNullOrWhiteSpace(x) && x != "All" && x.Length <= 60).Select(x => x.Trim()).Distinct().ToList();
public static List<T> MoveBefore<T>(IEnumerable<T> source, T moving, T target)
{
var list = source.ToList();
if (EqualityComparer<T>.Default.Equals(moving, target) || !list.Contains(moving) || !list.Contains(target)) return list;
list.Remove(moving); list.Insert(list.IndexOf(target), moving); return list;
}
public static List<T> MoveAfter<T>(IEnumerable<T> source, T moving, T target)
{
var list = source.ToList();
if (EqualityComparer<T>.Default.Equals(moving, target) || !list.Contains(moving) || !list.Contains(target)) return list;
list.Remove(moving); list.Insert(list.IndexOf(target) + 1, moving); return list;
}
public static LibraryExport ParseExport(string json)
{
if (json.Length > 20 * 1024 * 1024) throw new InvalidDataException("Import is too large (20 MB maximum).");
using var document = JsonDocument.Parse(json);
List<Shortcut>? links; List<string> order = [];
if (document.RootElement.ValueKind == JsonValueKind.Array) links = document.RootElement.Deserialize<List<Shortcut>>();
else
{
var exported = document.RootElement.Deserialize<LibraryExport>() ?? throw new InvalidDataException("Invalid library file.");
if (exported.Version != 1) throw new InvalidDataException("This library version is not supported.");
links = exported.Links; order = exported.Collections ?? [];
}
if (links == null) throw new InvalidDataException("No websites found in the import.");
var valid = Validate(links); return new(1, valid, Collections(valid, order));
}
public static List<Shortcut> Merge(IEnumerable<Shortcut> existing, IEnumerable<Shortcut> incoming)
{
var result = existing.ToList();
foreach (var item in incoming)
if (!result.Any(x => x.Url == item.Url && x.Name.Equals(item.Name, StringComparison.OrdinalIgnoreCase) && x.Group.Equals(item.Group, StringComparison.OrdinalIgnoreCase)))
result.Add(item with { Id = Guid.NewGuid().ToString("N") });
return Validate(result);
}
}
+206
View File
@@ -0,0 +1,206 @@
using System.Diagnostics;
using System.IO;
using System.Reflection;
using System.Text.Json;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using System.Windows.Media;
namespace ShortcutsPanel;
public partial class MainWindow
{
static Button SidebarButton(string title, string glyph, Action action)
{
var button = Button("", action, "#F1F2ED");
button.Padding = new Thickness(12,10,12,10); button.Margin = new Thickness(0,2,0,2);
var row = new StackPanel { Orientation = Orientation.Horizontal, Width = 138 };
row.Children.Add(new TextBlock { Text = glyph, FontFamily = new FontFamily("Segoe MDL2 Assets"), FontSize = 14, Width = 24, VerticalAlignment = VerticalAlignment.Center, Foreground = Brush("#7B8377") });
row.Children.Add(new TextBlock { Text = title, FontSize = 12, VerticalAlignment = VerticalAlignment.Center });
button.Content = row;
System.Windows.Automation.AutomationProperties.SetName(button, title);
return button;
}
Window Dialog(string title, out StackPanel panel, double height = 420)
{
var window = new Window { Owner = this, Title = title, Width = 500, Height = height, MinHeight = 300, MinWidth = 420, WindowStartupLocation = WindowStartupLocation.CenterOwner, ShowInTaskbar = true };
panel = new StackPanel { Margin = new Thickness(28) };
window.Content = new ScrollViewer { Content = panel, VerticalScrollBarVisibility = ScrollBarVisibility.Auto };
panel.Children.Add(new TextBlock { Text = title, FontSize = 26, FontWeight = FontWeights.SemiBold, Margin = new Thickness(0,0,0,18) });
return window;
}
void About()
{
var dialog = Dialog("About Orbit", out var panel, 380);
var title = (TextBlock)panel.Children[0]; panel.Children.RemoveAt(0);
title.Margin = new Thickness(0); title.VerticalAlignment = VerticalAlignment.Center;
var header = new DockPanel { Margin = new Thickness(0,0,0,20) };
var logo = new Image { Source = LoadAppIcon(), Width = 48, Height = 48, Margin = new Thickness(16,0,0,0) };
DockPanel.SetDock(logo, Dock.Right); header.Children.Add(logo); header.Children.Add(title); panel.Children.Insert(0, header);
var version = Assembly.GetExecutingAssembly().GetName().Version;
panel.Children.Add(new TextBlock { Text = $"Version {version?.Major}.{version?.Minor}.{version?.Build}", FontSize = 14, Margin = new Thickness(0,0,0,20) });
panel.Children.Add(new TextBlock { Text = "Created by Dario P. • 2026", FontSize = 19, FontWeight = FontWeights.SemiBold });
panel.Children.Add(new TextBlock { Text = "Your favorite websites, organized in one place.\nA portable Windows launcher with collections, custom icons, and light and dark themes.", TextWrapping = TextWrapping.Wrap, Margin = new Thickness(0,16,0,24), Foreground = Brush("#7B8377") });
panel.Children.Add(Button("Close", dialog.Close)); dialog.ShowDialog();
}
public static System.Windows.Media.Imaging.BitmapSource LoadAppIcon()
{
using var stream = typeof(MainWindow).Assembly.GetManifestResourceStream("OrbitIcon") ?? throw new IOException("Missing embedded icon.");
var decoder = System.Windows.Media.Imaging.BitmapDecoder.Create(stream, System.Windows.Media.Imaging.BitmapCreateOptions.None, System.Windows.Media.Imaging.BitmapCacheOption.OnLoad);
var frame = decoder.Frames[0]; frame.Freeze(); return frame;
}
void Transfer()
{
var dialog = Dialog("Import / Export", out var panel, 430);
panel.Children.Add(new TextBlock { Text = "Share your websites, collections, order, and custom icons in a single JSON file. Your theme stays personal to this PC.", TextWrapping = TextWrapping.Wrap, Margin = new Thickness(0,0,0,20) });
panel.Children.Add(Button("Export library…", () => {
if (!readable) { MessageBox.Show(dialog, "Resolve the startup library error first."); return; }
var picker = new Microsoft.Win32.SaveFileDialog { Title = "Export Orbit library", FileName = "Orbit-library.json", Filter = "Orbit library|*.json", DefaultExt = ".json" };
if (picker.ShowDialog(dialog) != true) return;
try { File.WriteAllText(picker.FileName, JsonSerializer.Serialize(new LibraryExport(1, entries, collectionOrder), new JsonSerializerOptions { WriteIndented = true })); dialog.Close(); }
catch (Exception ex) { MessageBox.Show(dialog, ex.Message, "Export failed"); }
}));
var import = Button("Import library…", () => {
var picker = new Microsoft.Win32.OpenFileDialog { Title = "Import Orbit library", Filter = "Orbit library|*.json" };
if (picker.ShowDialog(dialog) != true) return;
try
{
if (new FileInfo(picker.FileName).Length > 20 * 1024 * 1024) throw new InvalidDataException("Choose a library smaller than 20 MB.");
var incoming = LibraryOperations.ParseExport(File.ReadAllText(picker.FileName));
var choice = MessageBox.Show(dialog, $"Import {incoming.Links.Count} websites in {incoming.Collections.Count} collections?\n\nYes: Merge with your library (skip matching name, URL, and collection).\nNo: Replace your library.\nCancel: Make no changes.", "Choose import mode", MessageBoxButton.YesNoCancel, MessageBoxImage.Question, MessageBoxResult.Cancel);
if (choice == MessageBoxResult.Cancel) return;
var next = choice == MessageBoxResult.Yes ? LibraryOperations.Merge(entries, incoming.Links) : incoming.Links;
var order = choice == MessageBoxResult.Yes ? collectionOrder.Concat(incoming.Collections).Distinct().ToList() : incoming.Collections;
if (Save(next, order)) { deleted.Clear(); if (undoButton != null) undoButton.IsEnabled = false; dialog.Close(); }
}
catch (Exception ex) { MessageBox.Show(dialog, ex.Message, "Import failed — library unchanged"); }
}); import.Margin = new Thickness(0,12,0,18); panel.Children.Add(import);
panel.Children.Add(Button("Cancel", dialog.Close)); dialog.ShowDialog();
}
bool DeleteShortcut(Shortcut item)
{
var index = entries.FindIndex(x => x.Id == item.Id); if (index < 0) return false;
var next = entries.ToList(); next.RemoveAt(index);
if (!Save(next)) return false;
deleted.Push((item, index)); if (undoButton != null) undoButton.IsEnabled = true;
status.Text = $"Deleted {item.Name} · Undo is available"; return true;
}
void UndoDelete()
{
if (!deleted.TryPeek(out var restore)) return;
var next = entries.ToList(); next.Insert(Math.Min(restore.Index, next.Count), restore.Item);
if (Save(next)) { deleted.Pop(); if (undoButton != null) undoButton.IsEnabled = deleted.Count > 0; }
}
void ManageCollections()
{
var dialog = Dialog("Collections", out var panel, 540);
panel.Children.Add(new TextBlock { Text = "Select a collection to rename, open, or remove.\nDrag collections in the sidebar to reorder them.", TextWrapping = TextWrapping.Wrap, Margin = new Thickness(0,0,0,14) });
var list = new ComboBox { ItemsSource = collectionOrder.ToList(), SelectedItem = selectedGroup != "All" ? selectedGroup : collectionOrder.FirstOrDefault(), MinHeight = 34, Foreground = Brushes.Black }; panel.Children.Add(list);
var name = new TextBox { MaxLength = 60, Margin = new Thickness(0,14,0,12), Text = list.SelectedItem as string ?? "" }; panel.Children.Add(name);
list.SelectionChanged += (_, _) => name.Text = list.SelectedItem as string ?? "";
var result = new TextBlock { TextWrapping = TextWrapping.Wrap, Foreground = Brush("#A83C38"), Margin = new Thickness(0,8,0,8) };
var row = new StackPanel { Orientation = Orientation.Horizontal }; panel.Children.Add(row);
row.Children.Add(Button("Rename", () => {
if (list.SelectedItem is not string old) return;
var value = name.Text.Trim();
if (value.Length == 0 || value == "All") { result.Text = "Choose a collection name other than All."; return; }
if (collectionOrder.Any(x => x != old && x.Equals(value, StringComparison.OrdinalIgnoreCase))) { result.Text = "That collection already exists."; return; }
if (Save(entries.Select(x => x.Group == old ? x with { Group = value } : x).ToList(), collectionOrder.Select(x => x == old ? value : x).ToList())) { selectedGroup = value; Render(); dialog.Close(); }
}));
var create = Button("New collection", () => {
var value = name.Text.Trim();
if (value.Length == 0 || value == "All" || collectionOrder.Any(x => x.Equals(value, StringComparison.OrdinalIgnoreCase))) { result.Text = "Enter a new, unique name other than All."; return; }
if (Save(entries, collectionOrder.Append(value).ToList())) { selectedGroup = value; Render(); dialog.Close(); }
}); create.Margin = new Thickness(10,0,0,0); row.Children.Add(create);
var open = Button("Open collection", () => { if (list.SelectedItem is string group) OpenCollection(group); }); open.Margin = new Thickness(0,12,0,10); panel.Children.Add(open);
panel.Children.Add(Button("Delete collection", () => {
if (list.SelectedItem is not string old) return;
var destination = old == "Unsorted" ? "Everyday" : "Unsorted";
if (MessageBox.Show(dialog, $"Delete {old}? Its websites will be kept in {destination}.", "Delete collection", MessageBoxButton.YesNo, MessageBoxImage.Question, MessageBoxResult.No) != MessageBoxResult.Yes) return;
if (Save(entries.Select(x => x.Group == old ? x with { Group = destination } : x).ToList(), collectionOrder.Where(x => x != old).ToList())) dialog.Close();
}, "#F8E9E5")); panel.Children.Add(result); panel.Children.Add(Button("Close", dialog.Close)); dialog.ShowDialog();
}
void OpenCollection(string group)
{
var links = entries.Where(x => x.Group == group).Select(x => x.Url).Distinct().ToList();
if (links.Count == 0) { MessageBox.Show(this, "This collection has no websites yet."); return; }
if (links.Count > 1 && MessageBox.Show(this, $"Open {links.Count} websites from {group} in your default browser?", "Open collection", MessageBoxButton.YesNo, MessageBoxImage.Question, MessageBoxResult.No) != MessageBoxResult.Yes) return;
var errors = new List<string>();
foreach (var url in links) { try { Process.Start(new ProcessStartInfo(url) { UseShellExecute = true }); } catch (Exception ex) { errors.Add(ex.Message); } }
if (errors.Count > 0) MessageBox.Show(this, $"{errors.Count} websites could not be opened.\n" + errors[0], "Open collection");
}
static void DragSource(FrameworkElement source, string format, string value, Action<bool>? dragState = null)
{
Point? start = null;
source.PreviewMouseLeftButtonDown += (_, e) => start = e.GetPosition(source);
source.PreviewMouseLeftButtonUp += (_, _) => start = null;
source.PreviewMouseMove += (_, e) => {
if (e.LeftButton != MouseButtonState.Pressed || start is not Point origin) return;
var current = e.GetPosition(source);
if (Math.Abs(current.X - origin.X) < SystemParameters.MinimumHorizontalDragDistance && Math.Abs(current.Y - origin.Y) < SystemParameters.MinimumVerticalDragDistance) return;
start = null; e.Handled = true;
// Cancel a nested button's pressed state so dragging a card never opens its website.
Mouse.Capture(null);
dragState?.Invoke(true);
try { DragDrop.DoDragDrop(source, new DataObject(format, value), DragDropEffects.Move); }
finally { dragState?.Invoke(false); }
};
}
void ConfigureCard(Border card, Shortcut item)
{
bool dragging = false, dropTarget = false;
void UpdateOutline()
{
var highlighted = dragging || dropTarget || card.IsKeyboardFocusWithin;
card.BorderBrush = highlighted ? Brush("#56B879") : Brush(card.IsMouseOver ? "#ACBDA4" : "#E5E8E0");
card.BorderThickness = new Thickness(highlighted ? 2 : 1);
card.Padding = highlighted ? new Thickness(19,17,19,13) : new Thickness(20,18,20,14);
card.Opacity = dragging ? 0.65 : 1;
}
card.MouseEnter += (_, _) => UpdateOutline(); card.MouseLeave += (_, _) => UpdateOutline();
card.IsKeyboardFocusWithinChanged += (_, _) => UpdateOutline();
DragSource(card, "Orbit.Website", item.Id, active => { dragging = active; UpdateOutline(); }); card.AllowDrop = true;
card.DragEnter += (_, e) => { dropTarget = e.Data.GetDataPresent("Orbit.Website"); UpdateOutline(); };
card.DragLeave += (_, _) => { dropTarget = false; UpdateOutline(); };
card.DragOver += (_, e) => { e.Effects = e.Data.GetDataPresent("Orbit.Website") ? DragDropEffects.Move : DragDropEffects.None; e.Handled = true; };
card.Drop += (_, e) => {
dropTarget = false; UpdateOutline();
if (e.Data.GetData("Orbit.Website") is string id && entries.FirstOrDefault(x => x.Id == id) is Shortcut moving)
Save(e.GetPosition(card).Y > card.ActualHeight / 2 ? LibraryOperations.MoveAfter(entries, moving, item) : LibraryOperations.MoveBefore(entries, moving, item));
e.Handled = true;
};
var menu = new ContextMenu();
foreach (var label in new[] { "Move earlier", "Move later" })
{
var option = new MenuItem { Header = label }; option.Click += (_, _) => {
var index = entries.FindIndex(x => x.Id == item.Id); var destination = index + (label == "Move earlier" ? -1 : 1);
if (index < 0 || destination < 0 || destination >= entries.Count) return;
var next = entries.ToList(); (next[index], next[destination]) = (next[destination], next[index]); Save(next);
}; menu.Items.Add(option);
}
card.ContextMenu = menu;
}
void ConfigureCollection(Button button, string group)
{
button.ToolTip = "Drag to reorder • Right-click for collection actions";
DragSource(button, "Orbit.Collection", group); button.AllowDrop = true;
button.DragOver += (_, e) => { e.Effects = e.Data.GetDataPresent("Orbit.Collection") ? DragDropEffects.Move : DragDropEffects.None; e.Handled = true; };
button.Drop += (_, e) => {
if (e.Data.GetData("Orbit.Collection") is string moving)
Save(entries, e.GetPosition(button).Y > button.ActualHeight / 2 ? LibraryOperations.MoveAfter(collectionOrder, moving, group) : LibraryOperations.MoveBefore(collectionOrder, moving, group));
e.Handled = true;
};
var menu = new ContextMenu(); var open = new MenuItem { Header = "Open collection" }; open.Click += (_, _) => OpenCollection(group); menu.Items.Add(open);
var manage = new MenuItem { Header = "Manage collection" }; manage.Click += (_, _) => { selectedGroup = group; ManageCollections(); }; menu.Items.Add(manage);
foreach (var label in new[] { "Move earlier", "Move later" })
{
var option = new MenuItem { Header = label }; option.Click += (_, _) => {
var index = collectionOrder.IndexOf(group); var destination = index + (label == "Move earlier" ? -1 : 1);
if (index < 0 || destination < 0 || destination >= collectionOrder.Count) return;
var next = collectionOrder.ToList(); (next[index], next[destination]) = (next[destination], next[index]); Save(entries, next);
}; menu.Items.Add(option);
}
button.ContextMenu = menu;
}
}
+221
View File
@@ -0,0 +1,221 @@
using System.Diagnostics;
using System.IO;
using System.Text.Json;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using System.Windows.Media;
using System.Windows.Media.Imaging;
using System.Net.Http;
namespace ShortcutsPanel;
public record Shortcut(string Name, string Url, string Group, string Color, string? Icon = null, string Id = "");
public partial class MainWindow : Window
{
readonly SettingsStore settings;
readonly WrapPanel cards = new();
readonly StackPanel groups = new();
readonly TextBlock heading = new() { FontSize = 30, FontWeight = FontWeights.SemiBold };
static readonly Dictionary<string, Task<BitmapSource?>> logos = new();
readonly TextBox search = new() { Width = 280, ToolTip = "Search by name, website, or collection" };
readonly TextBlock status = new() { Foreground = Brush("#939DB8"), FontSize = 12 };
List<Shortcut> entries = [];
string selectedGroup = "All";
List<string> collectionOrder = [];
readonly Stack<(Shortcut Item, int Index)> deleted = new();
Button? undoButton;
bool readable = true;
static readonly string[] Palette = ["#A697FF", "#60D6B2", "#6CB8FF", "#FFB878", "#F58EB9", "#DDD17B"];
public MainWindow(SettingsStore? store = null)
{
settings = store ?? new SettingsStore();
try { Theme.Apply(settings.LoadDarkTheme()); }
catch { Theme.Apply(false); }
Background = Brush("#FAF9F6"); Foreground = Brush("#252924"); FontFamily = new FontFamily("Segoe UI");
Title = "Orbit • Shortcuts Panel"; Width = 1100; Height = 720; MinWidth = 800; MinHeight = 520;
WindowStartupLocation = WindowStartupLocation.CenterScreen; WindowState = WindowState.Maximized;
var root = new Grid(); Content = root;
root.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(210) });
root.ColumnDefinitions.Add(new ColumnDefinition());
var sidebar = new DockPanel { Margin = new Thickness(20, 32, 20, 24) };
var sideSurface = new Border { Background = Brush("#F1F2ED"), BorderBrush = Brush("#E2E5DD"), BorderThickness = new Thickness(0,0,1,0), Child = sidebar }; root.Children.Add(sideSurface);
var brand = new StackPanel { Margin = new Thickness(12, 0, 0, 36) };
brand.Children.Add(new TextBlock { Text = "◉ orbit", FontSize = 27, FontWeight = FontWeights.SemiBold, Foreground = Brush("#294B3C") });
brand.Children.Add(new TextBlock { Text = "A little more organized.", FontSize = 11, Foreground = Brush("#80877D"), Margin = new Thickness(1, 6, 0, 0) });
DockPanel.SetDock(brand, Dock.Top); sidebar.Children.Add(brand);
var bottom = new StackPanel();
var bottomSurface = new Border { BorderBrush = Brush("#E2E5DD"), BorderThickness = new Thickness(0,1,0,0), Padding = new Thickness(0,18,0,0), Child = bottom }; DockPanel.SetDock(bottomSurface, Dock.Bottom); sidebar.Children.Add(bottomSurface);
var themeSwitch = new CheckBox { Content = "Dark mode", IsChecked = Theme.IsDark, Margin = new Thickness(12, 0, 0, 0), VerticalContentAlignment = VerticalAlignment.Center };
themeSwitch.SetResourceReference(StyleProperty, "ThemeSwitch");
themeSwitch.Click += (_, _) =>
{
Theme.Apply(themeSwitch.IsChecked == true);
try { settings.SaveDarkTheme(Theme.IsDark); }
catch (Exception ex) { MessageBox.Show(this, $"Theme changed for this session, but Windows could not save the preference.\n\n{ex.Message}", "Theme preference"); }
};
bottom.Children.Add(SidebarButton("Import / Export", "\uE8B5", Transfer));
bottom.Children.Add(SidebarButton("Collections", "\uE8F1", ManageCollections));
bottom.Children.Add(SidebarButton("About Orbit", "\uE946", About));
bottom.Children.Add(SidebarButton("Open settings", "\uE8B7", () =>
{
try { settings.OpenLocation(); }
catch (Exception ex) { MessageBox.Show(this, "Could not open the settings folder.\n\n" + ex.Message, "Open settings"); }
}));
bottom.Children.Add(new Border { BorderBrush = Brush("#E2E5DD"), BorderThickness = new Thickness(0,1,0,0), Margin = new Thickness(0,14,0,0), Padding = new Thickness(0,18,0,0), Child = themeSwitch });
var nav = new DockPanel(); sidebar.Children.Add(nav);
var label = new TextBlock { Text = "LIBRARY", FontSize = 10, FontWeight = FontWeights.SemiBold, Foreground = Brush("#838B7F"), Margin = new Thickness(12, 0, 0, 14) }; DockPanel.SetDock(label, Dock.Top); nav.Children.Add(label);
nav.Children.Add(new ScrollViewer { Content = groups, VerticalScrollBarVisibility = ScrollBarVisibility.Auto });
var main = new DockPanel { Margin = new Thickness(36, 36, 28, 24) }; Grid.SetColumn(main, 1); root.Children.Add(main);
var top = new StackPanel(); DockPanel.SetDock(top, Dock.Top); main.Children.Add(top);
var header = new DockPanel(); top.Children.Add(header);
var add = Button("+ Add website", () => Edit(null), "#294B3D"); add.Foreground = Brushes.White; add.VerticalAlignment = VerticalAlignment.Center; DockPanel.SetDock(add, Dock.Right); header.Children.Add(add);
header.Children.Add(heading);
top.Children.Add(new TextBlock { Text = "Your favorite places, thoughtfully collected.", FontSize = 13, Foreground = Brush("#7B8377"), Margin = new Thickness(0, 9, 0, 26) });
search.Width = double.NaN; search.HorizontalAlignment = HorizontalAlignment.Stretch;
search.ToolTip = "Search websites and collections";
System.Windows.Automation.AutomationProperties.SetName(search, "Search websites");
var searchRow = new DockPanel { Margin = new Thickness(0, 0, 8, 28) };
var searchLabel = new TextBlock { Text = "Search", VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(0,0,12,0), Foreground = Brush("#7B8377"), FontSize = 12 }; searchRow.Children.Add(searchLabel); searchRow.Children.Add(search); top.Children.Add(searchRow);
var footer = new DockPanel { Margin = new Thickness(0, 16, 8, 0) }; DockPanel.SetDock(footer, Dock.Bottom); main.Children.Add(footer);
undoButton = Button("Undo deletion", UndoDelete); undoButton.IsEnabled = false;
DockPanel.SetDock(undoButton, Dock.Right); footer.Children.Add(undoButton);
status.Foreground = Brush("#868D80"); footer.Children.Add(status);
main.Children.Add(new ScrollViewer { Content = cards, VerticalScrollBarVisibility = ScrollBarVisibility.Auto, HorizontalScrollBarVisibility = ScrollBarVisibility.Disabled });
search.TextChanged += (_, _) => RenderCards();
Load(); Render();
}
static SolidColorBrush Brush(string color) => Theme.Brush(color);
static Button Button(string text, Action action, string? color = null)
{
var b = new Button { Content = text, FontSize = 13 }; if (color != null) b.Background = Brush(color);
b.Click += (_, _) => action(); return b;
}
void Load()
{
try
{
var json = settings.Load();
entries = json == null
? [new("Google", "https://www.google.com", "Everyday", Palette[0]), new("YouTube", "https://www.youtube.com", "Everyday", Palette[4]), new("GitHub", "https://github.com", "Work", Palette[2]), new("Wikipedia", "https://www.wikipedia.org", "Discover", Palette[1])]
: JsonSerializer.Deserialize<List<Shortcut>>(json) ?? throw new InvalidDataException("Empty settings.");
entries = LibraryOperations.Validate(entries);
collectionOrder = LibraryOperations.Collections(entries, settings.LoadCollections());
}
catch (Exception ex) { readable = false; entries = []; MessageBox.Show(this, $"Could not load your library. Existing data has been preserved.\n\n{ex.Message}", "Settings could not be loaded"); }
}
bool Save(List<Shortcut> updated, List<string>? order = null)
{
if (!readable) { MessageBox.Show(this, "Resolve the library startup error and restart before making changes."); return false; }
try
{
updated = LibraryOperations.Validate(updated);
var nextOrder = LibraryOperations.Collections(updated, order ?? collectionOrder);
settings.SaveLibrary(JsonSerializer.Serialize(updated, new JsonSerializerOptions { WriteIndented = true }), nextOrder);
entries = updated; collectionOrder = nextOrder; Render(); return true;
}
catch (Exception ex) { MessageBox.Show(this, $"Windows could not save changes for this user account. Your previous library has been kept.\n\n{ex.Message}", "Save failed"); return false; }
}
void Render()
{
var names = LibraryOperations.Collections(entries, collectionOrder);
if (!names.Contains(selectedGroup)) selectedGroup = "All";
groups.Children.Clear();
foreach (var name in new[] { "All" }.Concat(names.Where(x => x != "All")))
{
var count = name == "All" ? entries.Count : entries.Count(x => x.Group == name);
var b = Button("", () => { selectedGroup = name; Render(); }, name == selectedGroup ? "#DFE6DA" : "#F1F2ED");
b.HorizontalContentAlignment = HorizontalAlignment.Stretch;
var row = new DockPanel { Width = 138 };
var number = new TextBlock { Text = count.ToString(), Foreground = Brush("#7B8377"), FontSize = 11, VerticalAlignment = VerticalAlignment.Center }; DockPanel.SetDock(number, Dock.Right); row.Children.Add(number);
row.Children.Add(new TextBlock { Text = name == "All" ? "▦ All websites" : "• " + name, FontSize = 12, TextTrimming = TextTrimming.CharacterEllipsis, Foreground = Brush("#374632") });
b.Content = row; b.Margin = new Thickness(0,0,0,6); groups.Children.Add(b);
System.Windows.Automation.AutomationProperties.SetName(b, name == "All" ? "All websites" : name);
if (name != "All") ConfigureCollection(b, name);
}
RenderCards();
}
void RenderCards()
{
cards.Children.Clear();
var filtered = entries.Where(x => (selectedGroup == "All" || x.Group == selectedGroup) && $"{x.Name} {x.Url} {x.Group}".Contains(search.Text.Trim(), StringComparison.OrdinalIgnoreCase)).ToList();
foreach (var item in filtered)
{
var layout = new DockPanel();
var actions = new DockPanel { Margin = new Thickness(0, 10, 0, 0) }; DockPanel.SetDock(actions, Dock.Bottom); layout.Children.Add(actions);
var edit = Button("Edit", () => Edit(item), "#FFFFFF"); edit.Padding = new Thickness(6, 3, 6, 3); edit.Foreground = Brush("#7B8377"); edit.FontSize = 11; edit.SetResourceReference(StyleProperty, "CardButton"); DockPanel.SetDock(edit, Dock.Right); actions.Children.Add(edit);
var tag = new StackPanel { Orientation = Orientation.Horizontal, VerticalAlignment = VerticalAlignment.Center };
tag.Children.Add(new Border { Width = 5, Height = 5, CornerRadius = new CornerRadius(3), Background = Brush(item.Color), Margin = new Thickness(0,0,6,0) });
tag.Children.Add(new TextBlock { Text = item.Group, Foreground = Brush("#7B8377"), FontSize = 10, MaxWidth = 130, TextTrimming = TextTrimming.CharacterEllipsis }); actions.Children.Add(tag);
var content = new StackPanel { Width = 182 };
var logoRow = new DockPanel { Margin = new Thickness(0,0,0,16) };
var arrow = new TextBlock { Text = "↗", FontSize = 17, Foreground = Brush("#A2AA9B"), VerticalAlignment = VerticalAlignment.Top }; DockPanel.SetDock(arrow, Dock.Right); logoRow.Children.Add(arrow);
var badge = new Border { Background = Brush("#F5F6F2"), CornerRadius = new CornerRadius(10), Width = 42, Height = 42, HorizontalAlignment = HorizontalAlignment.Left,
Child = new TextBlock { Text = System.Globalization.StringInfo.GetNextTextElement(item.Name).ToUpperInvariant(), Foreground = Brush("#53654B"), FontSize = 20, FontWeight = FontWeights.SemiBold, HorizontalAlignment = HorizontalAlignment.Center, VerticalAlignment = VerticalAlignment.Center } };
logoRow.Children.Add(badge); content.Children.Add(logoRow); if (item.Icon != null) badge.Child = new Image { Source = CustomIcons.Decode(item.Icon), Width = 28, Height = 28, Stretch = Stretch.Uniform };
else _ = ApplyLogo(badge, item.Url);
content.Children.Add(new TextBlock { Text = item.Name, Foreground = Brush("#252D23"), FontSize = 16, FontWeight = FontWeights.SemiBold, TextTrimming = TextTrimming.CharacterEllipsis });
content.Children.Add(new TextBlock { Text = new Uri(item.Url).Host.Replace("www.", ""), Foreground = Brush("#8B9285"), FontSize = 11, Margin = new Thickness(0,5,0,0), TextTrimming = TextTrimming.CharacterEllipsis });
var launch = Button("", () => Launch(item), "#FFFFFF"); launch.SetResourceReference(StyleProperty, "CardButton"); launch.Content = content; launch.Padding = new Thickness(0); launch.ToolTip = item.Url;
System.Windows.Automation.AutomationProperties.SetName(launch, "Open " + item.Name); layout.Children.Add(launch);
var card = new Border { Width = 226, Height = 188, Background = Brush("#FFFFFF"), BorderBrush = Brush("#E5E8E0"), BorderThickness = new Thickness(1), CornerRadius = new CornerRadius(12), Padding = new Thickness(20,18,20,14), Margin = new Thickness(0,0,14,14), Child = layout };
cards.Children.Add(card); ConfigureCard(card, item);
}
if (filtered.Count == 0) cards.Children.Add(new TextBlock { Text = entries.Count == 0 ? "A fresh start. Add your first website above." : "No websites match. Try another search or collection.", FontSize = 18, Margin = new Thickness(0, 35, 0, 35) });
heading.Text = selectedGroup == "All" ? "All websites" : selectedGroup;
status.Text = $"{filtered.Count} websites · Opens in your default browser";
}
static async Task ApplyLogo(Border badge, string url)
{
var key = new Uri(url).GetLeftPart(UriPartial.Path);
if (!logos.TryGetValue(key, out var task)) logos[key] = task = WebsiteIcons.Fetch(key);
var bitmap = await task;
if (bitmap != null) badge.Child = new Image { Source = bitmap, Width = 26, Height = 26, Stretch = Stretch.Uniform };
}
void Launch(Shortcut item)
{
try { if (!ValidUrl(item.Url, out var url)) throw new Exception("Only HTTP and HTTPS websites are supported."); Process.Start(new ProcessStartInfo(url) { UseShellExecute = true }); }
catch (Exception ex) { MessageBox.Show(this, ex.Message, "Could not open website"); }
}
public static bool ValidUrl(string? input, out string result)
{
result = ""; if (string.IsNullOrWhiteSpace(input)) return false;
var value = input.Trim(); if (!value.Contains("://")) value = "https://" + value;
if (!Uri.TryCreate(value, UriKind.Absolute, out var uri) || (uri.Scheme != "http" && uri.Scheme != "https") || string.IsNullOrEmpty(uri.Host) || !string.IsNullOrEmpty(uri.UserInfo)) return false;
result = uri.AbsoluteUri; return true;
}
void Edit(Shortcut? original)
{
var dialog = new Window { Owner = this, Title = original == null ? "Add website" : "Edit website", Width = 480, Height = 690, ResizeMode = ResizeMode.NoResize, WindowStartupLocation = WindowStartupLocation.CenterOwner, ShowInTaskbar = true };
var panel = new StackPanel { Margin = new Thickness(28) }; dialog.Content = new ScrollViewer { Content = panel, VerticalScrollBarVisibility = ScrollBarVisibility.Auto };
panel.Children.Add(new TextBlock { Text = original == null ? "A new destination." : "Make it yours.", FontSize = 26, FontWeight = FontWeights.SemiBold, Margin = new Thickness(0, 0, 0, 18) });
TextBox Field(string label, string value) { panel.Children.Add(new TextBlock { Text = label, Margin = new Thickness(0, 10, 0, 6) }); var t = new TextBox { Text = value, MaxLength = label == "Website URL" ? 2048 : 60 }; panel.Children.Add(t); return t; }
var name = Field("Name", original?.Name ?? ""); var url = Field("Website URL", original?.Url ?? ""); var group = Field("Collection", original?.Group ?? "Everyday");
panel.Children.Add(new TextBlock { Text = "Accent color", Margin = new Thickness(0, 16, 0, 8) });
var colors = new StackPanel { Orientation = Orientation.Horizontal }; panel.Children.Add(colors); string color = original?.Color ?? Palette[0];
void DrawColors() { colors.Children.Clear(); foreach (var p in Palette) { var b = Button(p == color ? "✓" : "", () => { color = p; DrawColors(); }, p); b.Foreground = Brush("#10131E"); b.Width = 49; b.Height = 35; b.Padding = new Thickness(0); b.Margin = new Thickness(0, 0, 10, 0); b.ToolTip = p; colors.Children.Add(b); } } DrawColors();
string? customIcon = original?.Icon;
var iconRow = new StackPanel { Orientation = Orientation.Horizontal, Margin = new Thickness(0,16,0,0) };
var iconPreview = new Image { Width = 32, Height = 32, Margin = new Thickness(0,0,10,0) };
var iconStatus = new TextBlock { FontSize = 11, Foreground = Brush("#7B8377"), Margin = new Thickness(0,6,0,0) };
void ShowIcon() { iconPreview.Source = customIcon == null ? null : CustomIcons.Decode(customIcon); iconStatus.Text = customIcon == null ? "Automatic website icon" : "Custom icon included in exports"; }
iconRow.Children.Add(iconPreview);
iconRow.Children.Add(Button("Choose icon", () => {
var picker = new Microsoft.Win32.OpenFileDialog { Title = "Choose a website icon", Filter = "Images|*.png;*.jpg;*.jpeg;*.ico;*.bmp" };
if (picker.ShowDialog(dialog) != true) return;
try { customIcon = CustomIcons.FromFile(picker.FileName); ShowIcon(); }
catch (Exception ex) { MessageBox.Show(dialog, ex.Message, "Could not load icon"); }
}));
var automatic = Button("Use website icon", () => { customIcon = null; ShowIcon(); }); automatic.Margin = new Thickness(8,0,0,0); iconRow.Children.Add(automatic);
panel.Children.Add(iconRow); panel.Children.Add(iconStatus); ShowIcon();
var error = new TextBlock { Foreground = Brush("#A83C38"), TextWrapping = TextWrapping.Wrap, Margin = new Thickness(0, 12, 0, 10) }; panel.Children.Add(error);
var row = new StackPanel { Orientation = Orientation.Horizontal }; panel.Children.Add(row);
var save = Button("Save website", () => {
if (string.IsNullOrWhiteSpace(name.Text) || !ValidUrl(url.Text, out var normalized)) { error.Text = "Enter a name and a valid HTTP or HTTPS website."; return; }
var next = entries.ToList(); var item = new Shortcut(name.Text.Trim(), normalized, string.IsNullOrWhiteSpace(group.Text) ? "Everyday" : group.Text.Trim(), color, customIcon, original?.Id ?? Guid.NewGuid().ToString("N"));
if (original == null) next.Add(item); else next[next.FindIndex(x => x.Id == original.Id)] = item;
if (Save(next)) dialog.Close();
}, "#294B3D"); save.Foreground = Brushes.White; save.IsDefault = true; row.Children.Add(save);
var cancel = Button("Cancel", dialog.Close); cancel.IsCancel = true; cancel.Margin = new Thickness(8, 0, 0, 0); row.Children.Add(cancel);
if (original != null) { var remove = Button("Delete", () => { if (MessageBox.Show(dialog, $"Remove {original.Name}?", "Delete website", MessageBoxButton.YesNo) == MessageBoxResult.Yes) { if (DeleteShortcut(original)) dialog.Close(); } }, "#F8E9E5"); remove.Margin = new Thickness(8, 0, 0, 0); row.Children.Add(remove); }
dialog.ContentRendered += (_, _) => { dialog.Activate(); name.Focus(); Keyboard.Focus(name); }; dialog.ShowDialog();
}
}
+47
View File
@@ -0,0 +1,47 @@
# Orbit
A portable C# / WPF website launcher for Windows, created by **Dario P.**
## Features
- Website cards with automatic or custom icons
- Collections, search, and drag-to-reorder
- Import/export libraries with custom icons
- Undo shortcut deletion and open collections together
- Light and charcoal themes
- Starts maximized; green outer outlines indicate dragging and focus
## Run
Download the portable Windows x64 package from this repository's Releases page,
extract it, and run `ShortcutsPanel.exe`. No installer or separate .NET runtime
is required. Windows 10 or Windows 11, 64-bit.
## Settings
Links and preferences are stored in `%LOCALAPPDATA%\Orbit\settings.json`.
**Open settings** opens that folder. The previous save is retained as
`settings.json.bak`. Older Orbit registry or sidecar settings migrate automatically.
Use **Import / Export** to share your library. Copying the executable alone does
not carry personal links. Custom icons and collection order are included in exports;
the theme remains local. Undo history lasts for the current session.
## Build
Requires Windows and the .NET 8 SDK.
```powershell
dotnet publish ShortcutsPanel.csproj -c Release -r win-x64 --self-contained true -p:PublishSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true -p:DebugType=None -p:DebugSymbols=false -o release
```
## Checks
```powershell
dotnet run --project tests/StorageChecks.csproj
dotnet run --project tests/FeatureChecks.csproj
dotnet run --project tests/ThemeChecks.csproj
```
Theme checks include live icon downloads; unavailable sites use fallback icons.
See [README.txt](README.txt) for detailed controls and release notes.
+56
View File
@@ -0,0 +1,56 @@
ORBIT 1.1.2 - WEBSITE SHORTCUTS
Created by Dario P.
Run ShortcutsPanel.exe on 64-bit Windows 10 or Windows 11.
No installer or separate .NET installation is required.
NEW IN 1.1
- Import / Export: share a JSON library including custom icons and collection
order. Import supports merge (skip matching name, URL, collection) or replace.
Your theme remains personal to this PC. Imports are validated before saving.
- Drag anywhere on a card. Drop in the upper half of a target to place
it before that card, or the lower half to place it after. Drag collection
buttons similarly. Right-click also offers Move earlier / Move later.
- Choose icon in Add/Edit accepts PNG, JPEG, ICO, or BMP under 5 MB. Icons are
resized and stored inside the JSON, so they travel with exported libraries.
Use website icon restores automatic icon detection.
- Manage collections: create, rename, or delete collections. Deleting a
collection preserves its links in Unsorted (or Everyday if deleting Unsorted).
- Undo deletion restores deleted shortcuts while this session remains open.
Importing a library clears undo history. Undo history is not saved on exit.
- Open collection is available in Manage collections and the right-click menu.
Opening multiple sites asks for confirmation and opens each distinct URL once.
- About Orbit displays version 1.1.2 and author Dario P.
Add, edit, delete, search, and group website cards. Dark mode is optional.
Website icons are discovered from page metadata, then /favicon.ico.
Unavailable icons use a letter fallback.
Links, icons, card colors, collection order, and theme preference are saved in:
%LOCALAPPDATA%\Orbit\settings.json
Use Open settings in the sidebar to open this folder in Explorer.
The previous save is kept as settings.json.bak.
This is persistent per-user app storage, not a temporary folder.
Existing Orbit registry settings or a legacy shortcuts.json beside the EXE
are migrated on first use if the new JSON settings file is absent.
Legacy data is preserved. Future changes use the JSON file only.
Use Import / Export to share your library. Copying only the EXE does not include
personal changes. Exports contain website addresses and any custom icons;
review your library before sharing it. The theme is not included in exports.
The bundled .NET runtime extracts supporting files to temporary storage.
This executable is not digitally signed.
Build:
dotnet publish ShortcutsPanel.csproj -c Release -r win-x64 --self-contained true -p:PublishSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true -p:DebugType=None -p:DebugSymbols=false -o release
Checks:
dotnet run --project tests/StorageChecks.csproj
dotnet run --project tests/FeatureChecks.csproj
dotnet run --project tests/ThemeChecks.csproj
1.1.1: aligned sidebar rows, whole-card dragging, and the Orbit icon in About.
1.1.2: starts maximized; green outer borders for drag/focus; Dark mode is at the sidebar bottom; About credits Dario P. and 2026.
+79
View File
@@ -0,0 +1,79 @@
using System.IO;
using System.Text.Json;
using Microsoft.Win32;
namespace ShortcutsPanel;
public sealed class SettingsStore
{
readonly string registryPath;
readonly string legacyPath;
public string Location { get; }
public string SettingsPath => Path.Combine(Location, "settings.json");
public sealed class Data
{
public int Version { get; set; } = 1;
public JsonElement? Links { get; set; }
public bool DarkTheme { get; set; }
public List<string> Collections { get; set; } = [];
}
public SettingsStore(string registryPath = @"Software\OrbitShortcutsPanel", string? legacyPath = null, string? directory = null)
{
this.registryPath = registryPath;
this.legacyPath = legacyPath ?? Path.Combine(AppContext.BaseDirectory, "shortcuts.json");
Location = directory ?? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Orbit");
}
public void OpenLocation()
{
Directory.CreateDirectory(Location);
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo("explorer.exe", "\"" + Location + "\"") { UseShellExecute = true });
}
Data Read()
{
if (File.Exists(SettingsPath))
{
var data = JsonSerializer.Deserialize<Data>(File.ReadAllText(SettingsPath)) ?? throw new InvalidDataException("Empty settings file.");
if (data.Version != 1) throw new InvalidDataException("Unsupported settings version.");
if (data.Links is JsonElement links && links.ValueKind != JsonValueKind.Array) throw new InvalidDataException("Invalid links in settings.");
return data;
}
var migrated = new Data();
// Only read the registry for migration; new changes are always saved to JSON.
using var key = Registry.CurrentUser.OpenSubKey(registryPath);
var previous = key?.GetValue("Library");
if (previous != null && previous is not string) throw new InvalidDataException("Invalid legacy library type.");
string? json = previous as string;
if (json == null && File.Exists(legacyPath)) json = File.ReadAllText(legacyPath);
if (json != null) migrated.Links = ParseLinks(json);
migrated.DarkTheme = key?.GetValue("DarkTheme") is int value && value == 1;
if (json != null || key?.GetValue("DarkTheme") != null) Write(migrated);
return migrated;
}
static JsonElement ParseLinks(string json)
{
using var document = JsonDocument.Parse(json);
if (document.RootElement.ValueKind != JsonValueKind.Array) throw new InvalidDataException("Links must be a JSON array.");
return document.RootElement.Clone();
}
void Write(Data data)
{
Directory.CreateDirectory(Location);
var temporary = Path.Combine(Location, "settings-" + Guid.NewGuid().ToString("N") + ".tmp");
try
{
File.WriteAllText(temporary, JsonSerializer.Serialize(data, new JsonSerializerOptions { WriteIndented = true }));
if (File.Exists(SettingsPath)) File.Replace(temporary, SettingsPath, SettingsPath + ".bak");
else File.Move(temporary, SettingsPath);
}
finally { if (File.Exists(temporary)) File.Delete(temporary); }
}
public string? Load() => Read().Links?.GetRawText();
public bool LoadDarkTheme() => Read().DarkTheme;
public List<string> LoadCollections() => Read().Collections ?? [];
public void SaveLibrary(string json, List<string> collections)
{
var data = Read(); data.Links = ParseLinks(json); data.Collections = collections; Write(data);
}
public void SaveDarkTheme(bool dark) { var data = Read(); data.DarkTheme = dark; Write(data); }
public void Save(string json) { var data = Read(); data.Links = ParseLinks(json); Write(data); }
}
+14
View File
@@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net8.0-windows</TargetFramework>
<UseWPF>true</UseWPF>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<ApplicationIcon>app.ico</ApplicationIcon>
<AssemblyName>ShortcutsPanel</AssemblyName>
<Version>1.1.2</Version>
</PropertyGroup>
<ItemGroup><Compile Remove="tests/**/*.cs" /></ItemGroup>
<ItemGroup><EmbeddedResource Include="app.ico" LogicalName="OrbitIcon" /></ItemGroup>
</Project>
+64
View File
@@ -0,0 +1,64 @@
using System.Windows;␍
using System.Windows.Media;␍
using System.Windows.Data;␍
using System.ComponentModel;␍
␍
namespace ShortcutsPanel;␍
␍
public static class Theme␍
{␍
static readonly Dictionary<string, string> DarkColors = new(StringComparer.OrdinalIgnoreCase)␍
{␍
["#FAF9F6"] = "#202020", ["#252924"] = "#EEEEEE",␍
["#F1F2ED"] = "#252525", ["#E2E5DD"] = "#3B3B3B",␍
["#294B3D"] = "#505050", ["#294B3C"] = "#E0E0E0", ["#80877D"] = "#AAAAAA",␍
["#8C9388"] = "#AAAAAA", ["#838B7F"] = "#AAAAAA",␍
["#7B8377"] = "#BBBBBB", ["#868D80"] = "#AAAAAA",␍
["#DFE6DA"] = "#3D3D3D", ["#374632"] = "#E5E5E5",␍
["#FFFFFF"] = "#2B2B2B", ["#A2AA9B"] = "#AAAAAA",␍
["#F5F6F2"] = "#EAEAEA", ["#252D23"] = "#EEEEEE",␍
["#8B9285"] = "#AAAAAA", ["#E5E8E0"] = "#414141",␍
["#ACBDA4"] = "#777777", ["#EEF0E9"] = "#3A3A3A",␍
["#DDE2D6"] = "#555555", ["#A83C38"] = "#FFB3AB",␍
["#F8E9E5"] = "#583936", ["#53654B"] = "#505050",␍
["#738B65"] = "#999999", ["#528462"] = "#777777",␍
["#AACB94"] = "#BBBBBB", ["#8A9485"] = "#666666"␍
};␍
static readonly Dictionary<string, SolidColorBrush> Brushes = new(StringComparer.OrdinalIgnoreCase);␍
static readonly Dictionary<string, ThemeColor> Colors = new(StringComparer.OrdinalIgnoreCase);␍
sealed class ThemeColor : INotifyPropertyChanged␍
{␍
public Color Color { get; private set; }␍
public event PropertyChangedEventHandler? PropertyChanged;␍
public void Set(Color color) { Color = color; PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Color))); }␍
}␍
public static bool IsDark { get; private set; }␍
public static SolidColorBrush Brush(string light)␍
{␍
if (!Brushes.TryGetValue(light, out var brush))␍
{␍
var value = new ThemeColor(); value.Set(ColorFor(light)); Colors[light] = value;␍
Brushes[light] = brush = new SolidColorBrush();␍
// A binding keeps WPF styles from freezing a brush that must change at runtime.␍
BindingOperations.SetBinding(brush, SolidColorBrush.ColorProperty, new Binding(nameof(ThemeColor.Color)) { Source = value });␍
}␍
return brush;␍
}␍
static Color ColorFor(string light) => (Color)ColorConverter.ConvertFromString(␍
IsDark && DarkColors.TryGetValue(light, out var dark) ? dark : light);␍
public static void Apply(bool dark)␍
{␍
IsDark = dark;␍
foreach (var pair in Colors) pair.Value.Set(ColorFor(pair.Key));␍
var resources = Application.Current.Resources;␍
resources["WindowBackground"] = Brush("#FAF9F6");␍
resources["Ink"] = Brush("#252924");␍
resources["ControlBackground"] = Brush("#EEF0E9");␍
resources["InputBackground"] = Brush("#FFFFFF");␍
resources["InputBorder"] = Brush("#DDE2D6");␍
resources["FocusBorder"] = Brush("#738B65");␍
resources["SwitchOn"] = Brush("#528462");␍
resources["SwitchOff"] = Brush("#8A9485");␍
resources["SwitchFocus"] = Brush("#AACB94");␍
}␍
}␍
+93
View File
@@ -0,0 +1,93 @@
using System.IO;
using System.Net;
using System.Net.Http;
using System.Text;
using System.Text.RegularExpressions;
using System.Windows.Media.Imaging;
namespace ShortcutsPanel;
public static class WebsiteIcons
{
static readonly HttpClient Client = new() { Timeout = TimeSpan.FromSeconds(12) };
static readonly SemaphoreSlim Slots = new(4);
static readonly TimeSpan RegexTimeout = TimeSpan.FromMilliseconds(200);
// Read declared favicons and touch icons, including relative and CDN URLs.
public static IEnumerable<Uri> Discover(string html, Uri page)
{
var baseTag = Regex.Match(html, @"<base\b[^>]*>", RegexOptions.IgnoreCase, RegexTimeout);
var baseAttrs = Attributes(baseTag.Value);
if (baseAttrs.TryGetValue("href", out var baseHref) && SafeUri(page, baseHref) is Uri resolvedBase) page = resolvedBase;
var found = new List<(Uri Uri, int Rank)>();
foreach (Match tag in Regex.Matches(html, @"<link\b[^>]*>", RegexOptions.IgnoreCase, RegexTimeout))
{
var attrs = Attributes(tag.Value);
if (!attrs.TryGetValue("rel", out var rel) || !attrs.TryGetValue("href", out var href)) continue;
var tokens = rel.ToLowerInvariant().Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries);
if (!tokens.Contains("icon") && !tokens.Contains("apple-touch-icon") && !tokens.Contains("apple-touch-icon-precomposed")) continue;
var uri = SafeUri(page, href);
if (uri == null || uri.AbsolutePath.EndsWith(".svg", StringComparison.OrdinalIgnoreCase) || attrs.GetValueOrDefault("type", "").Contains("svg", StringComparison.OrdinalIgnoreCase)) continue;
found.Add((uri, tokens.Contains("apple-touch-icon") ? 0 : 1));
}
return found.OrderBy(x => x.Rank).Select(x => x.Uri).Distinct().Take(6).ToArray();
}
static Dictionary<string, string> Attributes(string tag)
{
var result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (Match match in Regex.Matches(tag, "([\\w:-]+)\\s*=\\s*(?:\"([^\"]*)\"|'([^']*)'|([^\\s>]+))", RegexOptions.None, RegexTimeout))
result[match.Groups[1].Value] = WebUtility.HtmlDecode(match.Groups[2].Success ? match.Groups[2].Value : match.Groups[3].Success ? match.Groups[3].Value : match.Groups[4].Value);
return result;
}
static Uri? SafeUri(Uri page, string href) => Uri.TryCreate(page, href, out var uri) && (uri.Scheme == "http" || uri.Scheme == "https") && string.IsNullOrEmpty(uri.UserInfo) ? uri : null;
static async Task<(byte[] Bytes, Uri FinalUri)> Download(Uri uri, int limit, CancellationToken token)
{
using var response = await Client.GetAsync(uri, HttpCompletionOption.ResponseHeadersRead, token);
response.EnsureSuccessStatusCode();
if (response.Content.Headers.ContentLength > limit) throw new IOException("Icon response too large.");
using var stream = await response.Content.ReadAsStreamAsync(token);
using var output = new MemoryStream(); var buffer = new byte[8192]; int count;
while ((count = await stream.ReadAsync(buffer, token)) > 0)
{
if (output.Length + count > limit) throw new IOException("Icon response too large.");
output.Write(buffer, 0, count);
}
return (output.ToArray(), response.RequestMessage?.RequestUri ?? uri);
}
public static async Task<BitmapSource?> Fetch(string address)
{
await Slots.WaitAsync();
try
{
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(15));
var page = new Uri(address); var candidates = new List<Uri>();
try
{
using var pageTimeout = CancellationTokenSource.CreateLinkedTokenSource(timeout.Token);
pageTimeout.CancelAfter(TimeSpan.FromSeconds(5));
var document = await Download(page, 1024 * 1024, pageTimeout.Token);
page = document.FinalUri;
candidates.AddRange(Discover(Encoding.UTF8.GetString(document.Bytes), page));
}
catch { /* Try the conventional favicon when HTML discovery is unavailable. */ }
candidates.Add(new Uri(page.GetLeftPart(UriPartial.Authority) + "/favicon.ico"));
foreach (var candidate in candidates.Distinct())
{
if (timeout.IsCancellationRequested) break;
try
{
using var iconTimeout = CancellationTokenSource.CreateLinkedTokenSource(timeout.Token);
iconTimeout.CancelAfter(TimeSpan.FromSeconds(3));
var data = await Download(candidate, 512 * 1024, iconTimeout.Token);
using var bytes = new MemoryStream(data.Bytes);
var bitmap = new BitmapImage(); bitmap.BeginInit(); bitmap.CacheOption = BitmapCacheOption.OnLoad;
bitmap.DecodePixelWidth = 32; bitmap.StreamSource = bytes; bitmap.EndInit(); bitmap.Freeze(); return bitmap;
}
catch { /* A missing or unsupported icon must not prevent using a shortcut. */ }
}
return null;
}
catch { return null; }
finally { Slots.Release(); }
}
}
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

+61
View File
@@ -0,0 +1,61 @@
using System.IO;
using System.Reflection;
using System.Text.Json;
using System.Windows.Media;
using System.Windows.Media.Imaging;
using ShortcutsPanel;
internal static class FeatureChecks
{
static void Check(bool condition, string name) { if (!condition) throw new Exception(name); Console.WriteLine("PASS " + name); }
[STAThread]
static void Main()
{
var directory = Path.Combine(Path.GetTempPath(), "OrbitFeatures-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(directory);
var store = new SettingsStore(@"Software\OrbitTests-" + Guid.NewGuid().ToString("N"), Path.Combine(directory, "absent.json"), directory);
var app = new App(); app.InitializeComponent();
Check(MainWindow.LoadAppIcon().PixelWidth > 0, "About icon loads from embedded resource");
var window = new MainWindow(store);
object? Call(string method, params object?[] args) => typeof(MainWindow).GetMethod(method, BindingFlags.NonPublic | BindingFlags.Instance)!.Invoke(window, args);
try
{
var original = LibraryOperations.Validate([new("A", "https://a.example", "One", "#A697FF"), new("B", "https://b.example", "Two", "#60D6B2"), new("C", "https://c.example", "One", "#60D6B2")]);
Check(original.Select(x => x.Id).Distinct().Count() == 3, "Legacy links receive unique identities");
var moved = LibraryOperations.MoveBefore(original, original[2], original[0]);
Check(moved.Select(x => x.Name).SequenceEqual(new[] { "C", "A", "B" }), "Card reorder preserves all entries");
Check(LibraryOperations.MoveAfter(original, original[0], original[2]).Select(x => x.Name).SequenceEqual(new[] { "B", "C", "A" }), "Dragging after last card reaches final position");
var order = LibraryOperations.MoveBefore(new[] { "One", "Two", "Empty" }, "Two", "One");
Check((bool)Call("Save", moved, order)!, "Save reordered library");
Check(store.LoadCollections().SequenceEqual(order), "Collection order and empty collection persist");
Check(JsonSerializer.Deserialize<List<Shortcut>>(store.Load()!)![0].Name == "C", "Card order persists");
Check((bool)Call("DeleteShortcut", original[0])!, "Delete shortcut");
Check(JsonSerializer.Deserialize<List<Shortcut>>(store.Load()!)!.Count == 2, "Deletion saved");
Call("UndoDelete");
Check(JsonSerializer.Deserialize<List<Shortcut>>(store.Load()!)!.Select(x => x.Name).SequenceEqual(new[] { "C", "A", "B" }), "Undo restores original position");
var pngPath = Path.Combine(directory, "icon.png");
var bitmap = BitmapSource.Create(2, 2, 96, 96, PixelFormats.Bgra32, null, new byte[] { 0,0,255,255, 0,255,0,255, 255,0,0,255, 255,255,255,255 }, 8);
var encoder = new PngBitmapEncoder(); encoder.Frames.Add(BitmapFrame.Create(bitmap)); using (var file = File.Create(pngPath)) encoder.Save(file);
var icon = CustomIcons.FromFile(pngPath); moved[0] = moved[0] with { Icon = icon };
var export = new LibraryExport(1, moved, order);
var roundTrip = LibraryOperations.ParseExport(JsonSerializer.Serialize(export));
Check(roundTrip.Links[0].Icon == icon && CustomIcons.Decode(roundTrip.Links[0].Icon!).PixelWidth > 0, "Custom icon survives export/import");
Check(roundTrip.Collections.SequenceEqual(order) && roundTrip.Links.Select(x => x.Id).SequenceEqual(moved.Select(x => x.Id)), "Export retains identities and order");
Check(LibraryOperations.Merge(moved, roundTrip.Links).Count == 3, "Merge skips duplicate shortcuts");
Check(LibraryOperations.ParseExport(JsonSerializer.Serialize(original)).Links.Count == 3, "Legacy array import supported");
foreach (var invalid in new[] { "{}", "{\"Version\":99,\"Links\":[]}", "[{\"Name\":\"Unsafe\",\"Url\":\"file:///C:/test\",\"Group\":\"One\",\"Color\":\"#FFFFFF\"}]" })
{
bool rejected = false; try { LibraryOperations.ParseExport(invalid); } catch { rejected = true; }
Check(rejected, "Invalid import rejected");
}
Check((bool)Call("Save", roundTrip.Links, roundTrip.Collections)!, "Custom-icon library saves");
Check(JsonSerializer.Deserialize<List<Shortcut>>(store.Load()!)![0].Icon == icon, "Custom icon persists in JSON");
}
finally
{
window.Close(); app.Shutdown();
foreach (var file in Directory.GetFiles(directory)) File.Delete(file);
Directory.Delete(directory);
}
}
}
+4
View File
@@ -0,0 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup><OutputType>Exe</OutputType><TargetFramework>net8.0-windows</TargetFramework><UseWPF>true</UseWPF><ImplicitUsings>enable</ImplicitUsings><Nullable>enable</Nullable><EnableDefaultCompileItems>false</EnableDefaultCompileItems></PropertyGroup>
<ItemGroup><Compile Include="FeatureChecks.cs"/><ProjectReference Include="../ShortcutsPanel.csproj"/></ItemGroup>
</Project>
+41
View File
@@ -0,0 +1,41 @@
using Microsoft.Win32;
using ShortcutsPanel;
var key = @"Software\OrbitStorageChecks\" + Guid.NewGuid().ToString("N");
var folder = Path.Combine(Path.GetTempPath(), "OrbitStorageChecks-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(folder);
var legacy = Path.Combine(folder, "shortcuts.json");
var dataFolder = Path.Combine(folder, "data");
void Check(bool condition, string label) { if (!condition) throw new Exception(label); Console.WriteLine("PASS " + label); }
SettingsStore Store() => new(key, legacy, dataFolder);
try
{
using (var old = Registry.CurrentUser.CreateSubKey(key)) { old.SetValue("Library", "[{\"Name\":\"Original\"}]"); old.SetValue("DarkTheme", 1); }
var store = Store();
Check(store.LoadDarkTheme(), "Registry theme migrated");
Check(store.Load()!.Contains("Original"), "Registry links migrated");
Check(File.Exists(store.SettingsPath), "Migration creates JSON in app data directory");
using (var old = Registry.CurrentUser.CreateSubKey(key)) old.SetValue("Library", "[]");
Check(Store().Load()!.Contains("Original"), "JSON takes precedence after migration");
store.Save("[{\"Name\":\"Edited\"}]");
Check(Store().Load()!.Contains("Edited"), "Edited links persist");
Check(Store().LoadDarkTheme(), "Link save preserves theme");
store.SaveDarkTheme(false);
Check(!Store().LoadDarkTheme() && Store().Load()!.Contains("Edited"), "Theme save preserves links");
Check(File.Exists(store.SettingsPath + ".bak"), "Previous JSON backed up");
store.Save("[]"); Check(Store().Load() == "[]", "Empty library persists");
File.WriteAllText(store.SettingsPath, "broken");
try { store.Save("[]"); throw new Exception("Corrupt settings overwritten"); } catch (System.Text.Json.JsonException) { }
Check(File.ReadAllText(store.SettingsPath) == "broken", "Corrupt settings preserved");
File.Delete(store.SettingsPath);
Registry.CurrentUser.DeleteSubKeyTree(key, false);
File.WriteAllText(legacy, "[]"); File.SetAttributes(legacy, FileAttributes.ReadOnly);
Check(Store().Load() == "[]", "Read-only sidecar migration");
Check(File.ReadAllText(legacy) == "[]", "Legacy file untouched");
}
finally
{
Registry.CurrentUser.DeleteSubKeyTree(key, false);
if (File.Exists(legacy)) { File.SetAttributes(legacy, FileAttributes.Normal); File.Delete(legacy); }
if (Directory.Exists(dataFolder)) { foreach (var file in Directory.GetFiles(dataFolder)) File.Delete(file); Directory.Delete(dataFolder); }
Directory.Delete(folder);
}
+4
View File
@@ -0,0 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup><OutputType>Exe</OutputType><TargetFramework>net8.0-windows</TargetFramework><ImplicitUsings>enable</ImplicitUsings><Nullable>enable</Nullable></PropertyGroup>
<ItemGroup><Compile Remove="ThemeChecks.cs;FeatureChecks.cs"/><Compile Include="../SettingsStore.cs" Link="SettingsStore.cs" /></ItemGroup>
</Project>
+36
View File
@@ -0,0 +1,36 @@
using System.Windows;
using System.Windows.Controls;
using System.Windows.Media;
using System.Windows.Media.Imaging;
using System.IO;
using ShortcutsPanel;
internal static class ThemeChecks
{
[STAThread]
static void Main()
{
var discovered = WebsiteIcons.Discover("<base href='/assets/'><link href='logo.png?a=1&amp;b=2' rel='apple-touch-icon'><link rel='shortcut icon' href=../favicon.ico><link rel='icon' href='icon.svg'><link rel='icon' href='javascript:alert(1)'>", new Uri("https://example.com/page")).ToArray();
if (discovered.Length != 2 || discovered[0].AbsoluteUri != "https://example.com/assets/logo.png?a=1&b=2" || discovered[1].AbsoluteUri != "https://example.com/favicon.ico") throw new Exception("Icon discovery failed");
Console.WriteLine("PASS icon discovery resolves relative URLs, entities, and filters unsupported icons");
foreach (var site in new[] { "https://github.com", "https://www.google.com" })
Console.WriteLine("Live icon " + site + ": " + (WebsiteIcons.Fetch(site).GetAwaiter().GetResult() != null ? "loaded" : "fallback"));
var app = new App(); app.InitializeComponent();
var window = new MainWindow();
var content = (FrameworkElement)window.Content;
var output = Path.Combine(Path.GetTempPath(), "OrbitThemePreview"); Directory.CreateDirectory(output);
foreach (var dark in new[] { false, true, false })
{
Theme.Apply(dark);
content.Measure(new Size(1100, 680)); content.Arrange(new Rect(0, 0, 1100, 680)); content.UpdateLayout();
if (((SolidColorBrush)window.Background).Color != (Color)ColorConverter.ConvertFromString(dark ? "#202020" : "#FAF9F6")) throw new Exception("Window theme did not update");
var bitmap = new RenderTargetBitmap(1100, 680, 96, 96, PixelFormats.Pbgra32);
var frame = new DrawingVisual(); using (var dc = frame.RenderOpen()) { dc.DrawRectangle(window.Background, null, new Rect(0,0,1100,680)); dc.DrawRectangle(new VisualBrush(content), null, new Rect(0,0,1100,680)); }
bitmap.Render(frame); var encoder = new PngBitmapEncoder(); encoder.Frames.Add(BitmapFrame.Create(bitmap));
using var file = File.Create(Path.Combine(output, dark ? "dark.png" : "light.png")); encoder.Save(file);
Console.WriteLine("PASS rendered " + (dark ? "dark" : "light") + " theme");
}
Console.WriteLine(output);
window.Close(); app.Shutdown();
}
}
+4
View File
@@ -0,0 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup><OutputType>Exe</OutputType><TargetFramework>net8.0-windows</TargetFramework><UseWPF>true</UseWPF><ImplicitUsings>enable</ImplicitUsings><Nullable>enable</Nullable><EnableDefaultCompileItems>false</EnableDefaultCompileItems></PropertyGroup>
<ItemGroup><Compile Include="ThemeChecks.cs"/><ProjectReference Include="../ShortcutsPanel.csproj"/></ItemGroup>
</Project>