commit 93ab510b9f0e84f0ede9f0e63934e6ceb51f604a
Author: Dario P. <83188092+donishki@users.noreply.github.com>
Date: Thu Oct 15 22:46:21 2026 +0200
Initial release of Orbit 1.1.2
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..8fd9c3a
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,9 @@
+bin/
+obj/
+portable/
+release/
+*.zip
+.vs/
+*.user
+settings.json*
+shortcuts.json*
diff --git a/App.xaml b/App.xaml
new file mode 100644
index 0000000..d9655c5
--- /dev/null
+++ b/App.xaml
@@ -0,0 +1,82 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/App.xaml.cs b/App.xaml.cs
new file mode 100644
index 0000000..e3f4ffc
--- /dev/null
+++ b/App.xaml.cs
@@ -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();
+ }
+}
diff --git a/CustomIcons.cs b/CustomIcons.cs
new file mode 100644
index 0000000..dc075bf
--- /dev/null
+++ b/CustomIcons.cs
@@ -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;
+ }
+}
diff --git a/LibraryOperations.cs b/LibraryOperations.cs
new file mode 100644
index 0000000..e0e3b90
--- /dev/null
+++ b/LibraryOperations.cs
@@ -0,0 +1,64 @@
+using System.IO;
+using System.Text.Json;
+using System.Text.RegularExpressions;
+
+namespace ShortcutsPanel;
+
+public sealed record LibraryExport(int Version, List Links, List Collections);
+
+public static class LibraryOperations
+{
+ public static List Validate(IEnumerable input)
+ {
+ var result = new List(); var ids = new HashSet();
+ 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 Collections(IEnumerable entries, IEnumerable 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 MoveBefore(IEnumerable source, T moving, T target)
+ {
+ var list = source.ToList();
+ if (EqualityComparer.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 MoveAfter(IEnumerable source, T moving, T target)
+ {
+ var list = source.ToList();
+ if (EqualityComparer.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? links; List order = [];
+ if (document.RootElement.ValueKind == JsonValueKind.Array) links = document.RootElement.Deserialize>();
+ else
+ {
+ var exported = document.RootElement.Deserialize() ?? 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 Merge(IEnumerable existing, IEnumerable 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);
+ }
+}
diff --git a/MainWindow.Features.cs b/MainWindow.Features.cs
new file mode 100644
index 0000000..6a7114f
--- /dev/null
+++ b/MainWindow.Features.cs
@@ -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();
+ 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? 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;
+ }
+}
diff --git a/MainWindow.cs b/MainWindow.cs
new file mode 100644
index 0000000..fa0282e
--- /dev/null
+++ b/MainWindow.cs
@@ -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> 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 entries = [];
+ string selectedGroup = "All";
+ List 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>(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 updated, List? 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();
+ }
+}
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..8d17912
--- /dev/null
+++ b/README.md
@@ -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.
diff --git a/README.txt b/README.txt
new file mode 100644
index 0000000..964d286
--- /dev/null
+++ b/README.txt
@@ -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.
diff --git a/SettingsStore.cs b/SettingsStore.cs
new file mode 100644
index 0000000..5fbe571
--- /dev/null
+++ b/SettingsStore.cs
@@ -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 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(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 LoadCollections() => Read().Collections ?? [];
+ public void SaveLibrary(string json, List 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); }
+}
diff --git a/ShortcutsPanel.csproj b/ShortcutsPanel.csproj
new file mode 100644
index 0000000..6e6915d
--- /dev/null
+++ b/ShortcutsPanel.csproj
@@ -0,0 +1,14 @@
+
+
+ WinExe
+ net8.0-windows
+ true
+ enable
+ enable
+ app.ico
+ ShortcutsPanel
+ 1.1.2
+
+
+
+
diff --git a/Theme.cs b/Theme.cs
new file mode 100644
index 0000000..74ccd2b
--- /dev/null
+++ b/Theme.cs
@@ -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 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 Brushes = new(StringComparer.OrdinalIgnoreCase);
+ static readonly Dictionary 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");
+ }
+}
diff --git a/WebsiteIcons.cs b/WebsiteIcons.cs
new file mode 100644
index 0000000..5922df1
--- /dev/null
+++ b/WebsiteIcons.cs
@@ -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 Discover(string html, Uri page)
+ {
+ var baseTag = Regex.Match(html, @"]*>", 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, @"]*>", 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 Attributes(string tag)
+ {
+ var result = new Dictionary(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 Fetch(string address)
+ {
+ await Slots.WaitAsync();
+ try
+ {
+ using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(15));
+ var page = new Uri(address); var candidates = new List();
+ 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(); }
+ }
+}
diff --git a/app.ico b/app.ico
new file mode 100644
index 0000000..f64dbe8
Binary files /dev/null and b/app.ico differ
diff --git a/tests/FeatureChecks.cs b/tests/FeatureChecks.cs
new file mode 100644
index 0000000..e550c96
--- /dev/null
+++ b/tests/FeatureChecks.cs
@@ -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>(store.Load()!)![0].Name == "C", "Card order persists");
+ Check((bool)Call("DeleteShortcut", original[0])!, "Delete shortcut");
+ Check(JsonSerializer.Deserialize>(store.Load()!)!.Count == 2, "Deletion saved");
+ Call("UndoDelete");
+ Check(JsonSerializer.Deserialize>(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>(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);
+ }
+ }
+}
diff --git a/tests/FeatureChecks.csproj b/tests/FeatureChecks.csproj
new file mode 100644
index 0000000..a650ee4
--- /dev/null
+++ b/tests/FeatureChecks.csproj
@@ -0,0 +1,4 @@
+
+ Exenet8.0-windowstrueenableenablefalse
+
+
diff --git a/tests/Program.cs b/tests/Program.cs
new file mode 100644
index 0000000..1469d65
--- /dev/null
+++ b/tests/Program.cs
@@ -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);
+}
diff --git a/tests/StorageChecks.csproj b/tests/StorageChecks.csproj
new file mode 100644
index 0000000..a61662f
--- /dev/null
+++ b/tests/StorageChecks.csproj
@@ -0,0 +1,4 @@
+
+ Exenet8.0-windowsenableenable
+
+
diff --git a/tests/ThemeChecks.cs b/tests/ThemeChecks.cs
new file mode 100644
index 0000000..119269f
--- /dev/null
+++ b/tests/ThemeChecks.cs
@@ -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("", 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();
+ }
+}
diff --git a/tests/ThemeChecks.csproj b/tests/ThemeChecks.csproj
new file mode 100644
index 0000000..d039ad5
--- /dev/null
+++ b/tests/ThemeChecks.csproj
@@ -0,0 +1,4 @@
+
+ Exenet8.0-windowstrueenableenablefalse
+
+