From 15411eb9c32df5d0aaa1f758311692b55c1ba12a Mon Sep 17 00:00:00 2001 From: Charles <252065487+tobleromed@users.noreply.github.com> Date: Sat, 8 Aug 2026 08:07:15 -0700 Subject: [PATCH 1/2] Load the component libraries in the Avalonia palette The Avalonia palette listed only component *types* - a generic BJT, Diode, JFET - built from a hardcoded array plus a reflection scan. The part libraries the project ships were unreachable, so placing a 2N3904 meant dropping a bare BJT and typing IS, BF and BR by hand, and the tube models were effectively unusable. The WPF app has always read these (its Library.cs parses Circuit/Components/*.xml); the port never carried it over. GUI_PORT_PLAN.md:85 lists component library insertion as Phase 3 work, so this was known-incomplete rather than an oversight. ComponentLibrary reads the same XML the WPF app does: a Library element of Component elements, each handed to Circuit.Component.Deserialize. Parts are listed after the generic types so the built-ins stay where they were, and are labelled with their category. Since this takes the palette from ~25 entries to ~110, it also adds a filter box. Each LibraryPart keeps its source XElement rather than a deserialized prototype, and deserializes afresh per placement: components are mutable, so a shared instance would let an edit to one placed part appear in the next one. There is a test for exactly that. Also fixes the reason the libraries would not have been found even once this code existed: Circuit.csproj marked all four XML files as Content but set CopyToOutputDirectory on Tubes.xml alone, so a host looking beside its executable saw one library out of four. Tests: 49/49 Avalonia, 49/49 circuits against goldens. Verified in the GUI by filtering for 2N39 and getting 2N3904 and 2N3906. Co-Authored-By: Claude Fable 5 --- Circuit/Circuit.csproj | 12 +- .../ComponentLibraryTests.cs | 47 +++++++ LiveSPICE.Avalonia/ComponentLibrary.cs | 133 ++++++++++++++++++ LiveSPICE.Avalonia/MainWindow.cs | 58 +++++++- 4 files changed, 240 insertions(+), 10 deletions(-) create mode 100644 LiveSPICE.Avalonia.Tests/ComponentLibraryTests.cs create mode 100644 LiveSPICE.Avalonia/ComponentLibrary.cs diff --git a/Circuit/Circuit.csproj b/Circuit/Circuit.csproj index 56e2b4da..60710b8e 100644 --- a/Circuit/Circuit.csproj +++ b/Circuit/Circuit.csproj @@ -13,11 +13,13 @@ - - - - - + + + PreserveNewest + Components\%(Filename)%(Extension) + + PreserveNewest diff --git a/LiveSPICE.Avalonia.Tests/ComponentLibraryTests.cs b/LiveSPICE.Avalonia.Tests/ComponentLibraryTests.cs new file mode 100644 index 00000000..d226dfe0 --- /dev/null +++ b/LiveSPICE.Avalonia.Tests/ComponentLibraryTests.cs @@ -0,0 +1,47 @@ +using System.Linq; +using Circuit; +using LiveSPICE.Avalonia; +using Xunit; + +namespace LiveSPICE.Avalonia.Tests; + +public class ComponentLibraryTests +{ + [Fact] + public void LibrariesAreFoundAndLoaded() + { + var parts = ComponentLibrary.Load(); + + // All four shipped libraries should contribute; previously only Tubes.xml was copied to + // the build output, so a host beside its executable found one of four. + Assert.True(parts.Count > 80, $"Expected the full library, got {parts.Count} parts."); + foreach (string category in new[] { "Diodes", "Op-Amps", "Transistors", "Tubes" }) + Assert.Contains(parts, i => i.Category == category); + } + + [Fact] + public void PartsCarryTheirLibraryParameters() + { + LibraryPart part = ComponentLibrary.Load().Single(i => i.Name == "2N3904"); + + BipolarJunctionTransistor bjt = Assert.IsType(part.Create()); + Assert.Equal(1e-14, (double)bjt.IS, 15); + Assert.Equal(300, (double)bjt.BF, 6); + Assert.Equal(4, (double)bjt.BR, 6); + } + + [Fact] + public void EachPlacementGetsItsOwnInstance() + { + // Components are mutable, so a shared prototype would let an edit to one placed part show + // up in the next one. + LibraryPart part = ComponentLibrary.Load().Single(i => i.Name == "2N3904"); + + Circuit.Component first = part.Create(); + Circuit.Component second = part.Create(); + Assert.NotSame(first, second); + + ((BipolarJunctionTransistor)first).BF = Quantity.Parse("999"); + Assert.Equal(300, (double)((BipolarJunctionTransistor)second).BF, 6); + } +} diff --git a/LiveSPICE.Avalonia/ComponentLibrary.cs b/LiveSPICE.Avalonia/ComponentLibrary.cs new file mode 100644 index 00000000..52517626 --- /dev/null +++ b/LiveSPICE.Avalonia/ComponentLibrary.cs @@ -0,0 +1,133 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Xml.Linq; +using Circuit; +using Util; + +namespace LiveSPICE.Avalonia; + +/// +/// A part from a component library: a named, pre-configured component such as a 2N3904, as opposed +/// to a bare BJT the user has to fill in by hand. +/// +/// The source XML element is kept rather than the deserialized component, so every placement gets a +/// fresh instance - components are mutable and a shared prototype would let edits to one placed +/// part show up in the next one. +/// +internal sealed class LibraryPart +{ + private readonly XElement source; + + public LibraryPart(XElement Source, string Name, string Category, string Description) + { + source = Source; + this.Name = Name; + this.Category = Category; + this.Description = Description; + } + + public string Name { get; } + public string Category { get; } + public string Description { get; } + + public Circuit.Component Create() + { + return Circuit.Component.Deserialize(source); + } +} + +/// +/// Loads the component libraries shipped in the Components folder. This mirrors what the WPF app's +/// Library control does, minus the WPF: the XML format is a Library element of Component elements, +/// each deserializable by Circuit.Component.Deserialize. +/// +internal static class ComponentLibrary +{ + /// + /// Find the Components folder. It sits beside the executable in a normal install, but when + /// running from a build output it is easier to walk up to the repository and use the copy in + /// the Circuit project, so a developer build finds parts without an install step. + /// + public static string? FindComponentsDirectory() + { + DirectoryInfo? directory = new DirectoryInfo(AppContext.BaseDirectory); + while (directory != null) + { + foreach (string candidate in new[] + { + Path.Combine(directory.FullName, "Components"), + Path.Combine(directory.FullName, "Circuit", "Components"), + }) + { + if (Directory.Exists(candidate) && Directory.EnumerateFiles(candidate, "*.xml").Any()) + return candidate; + } + directory = directory.Parent; + } + return null; + } + + public static IReadOnlyList Load() + { + string? path = FindComponentsDirectory(); + if (path == null) + { + Log.Global.WriteLine(MessageType.Warning, "No component library found; only built-in component types will be listed."); + return Array.Empty(); + } + return Load(path); + } + + public static IReadOnlyList Load(string Path) + { + List parts = new List(); + foreach (string file in Directory.EnumerateFiles(Path, "*.xml").OrderBy(i => i)) + { + // One bad library should not hide the others. + try + { + LoadLibrary(file, parts); + } + catch (Exception Ex) + { + Log.Global.WriteLine(MessageType.Warning, "Failed to load component library '{0}': {1}", + System.IO.Path.GetFileName(file), Ex.Message); + } + } + Log.Global.WriteLine(MessageType.Info, "Loaded {0} library parts from '{1}'.", parts.Count, Path); + return parts; + } + + private static void LoadLibrary(string File, List Parts) + { + XElement? library = XDocument.Load(File).Element("Library"); + if (library == null) + return; + + // The category is declared on the library, falling back to the file name. + string category = library.Attribute("Category")?.Value + ?? System.IO.Path.GetFileNameWithoutExtension(File); + + foreach (XElement element in library.Elements("Component")) + { + // Deserialize once here to validate the entry and to read its name, then discard it - + // Create() makes the instances that actually get placed. + try + { + Circuit.Component component = Circuit.Component.Deserialize(element); + string name = string.IsNullOrEmpty(component.PartNumber) ? component.TypeName : component.PartNumber; + string description = string.IsNullOrEmpty(component.Description) + ? $"{component.TypeName} ({category})" + : component.Description; + Parts.Add(new LibraryPart(element, name, category, description)); + } + catch (Exception Ex) + { + Log.Global.WriteLine(MessageType.Warning, "Failed to load component from '{0}': {1}", + System.IO.Path.GetFileName(File), Ex.Message); + } + } + } +} diff --git a/LiveSPICE.Avalonia/MainWindow.cs b/LiveSPICE.Avalonia/MainWindow.cs index 16bb3218..2cb49f35 100644 --- a/LiveSPICE.Avalonia/MainWindow.cs +++ b/LiveSPICE.Avalonia/MainWindow.cs @@ -37,6 +37,8 @@ public sealed class MainWindow : Window private readonly TabControl documents = new TabControl(); private readonly ListBox componentList = new ListBox(); + private readonly TextBox componentFilter = new TextBox { Watermark = "Filter components" }; + private List allComponents = new List(); private readonly PropertyInspector propertyInspector = new PropertyInspector(); private readonly TextBlock status = new TextBlock { Text = "Ready", VerticalAlignment = VerticalAlignment.Center }; private readonly AppSettings settings = AppSettings.Load(); @@ -83,9 +85,17 @@ public MainWindow() componentList.Margin = new global::Avalonia.Thickness(6); componentList.DoubleTapped += (_, _) => ActivateSelectedComponent(); componentList.SelectionChanged += (_, _) => ActivateSelectedComponent(); + componentFilter.Margin = new global::Avalonia.Thickness(6, 0, 6, 4); + componentFilter.TextChanged += (_, _) => ApplyComponentFilter(); PopulateComponents(); - Border componentsPanel = Panel("Components", componentList); + // The library adds ~90 parts to what was a short list, so it needs a filter to stay usable. + DockPanel componentsContent = new DockPanel(); + DockPanel.SetDock(componentFilter, Dock.Top); + componentsContent.Children.Add(componentFilter); + componentsContent.Children.Add(componentList); + + Border componentsPanel = Panel("Components", componentsContent); Grid.SetColumn(componentsPanel, 0); content.Children.Add(componentsPanel); @@ -622,7 +632,24 @@ private void PopulateComponents() } } - componentList.ItemsSource = items.OrderBy(i => i.Name).ToList(); + // Library parts (2N3904, 1N4148, 12AX7, ...) come after the generic types, so the + // built-in components stay at the top where they were rather than being buried among + // 80-odd part numbers. + allComponents = items.OrderBy(i => i.Name) + .Concat(ComponentLibrary.Load().Select(i => new ComponentListItem(i)).OrderBy(i => i.Category).ThenBy(i => i.Name)) + .ToList(); + ApplyComponentFilter(); + } + + private void ApplyComponentFilter() + { + string filter = componentFilter.Text?.Trim() ?? string.Empty; + componentList.ItemsSource = filter.Length == 0 + ? allComponents + : allComponents.Where(i => + i.Name.Contains(filter, StringComparison.OrdinalIgnoreCase) || + i.Category.Contains(filter, StringComparison.OrdinalIgnoreCase) || + i.Description.Contains(filter, StringComparison.OrdinalIgnoreCase)).ToList(); } private void ActivateSelectedComponent() @@ -637,7 +664,7 @@ private void ActivateSelectedComponent() return; } - ActiveCanvas.PendingComponent = (CircuitComponent)Activator.CreateInstance(item.ComponentType)!; + ActiveCanvas.PendingComponent = item.Create(); status.Text = $"Place {item.Name}: click schematic"; } @@ -812,22 +839,43 @@ private async void OnKeyDown(object? sender, KeyEventArgs e) internal sealed class ComponentListItem { + private readonly LibraryPart? part; + public ComponentListItem(Type componentType) { ComponentType = componentType; CircuitComponent component = (CircuitComponent)Activator.CreateInstance(componentType)!; Name = component.TypeName; + Category = string.Empty; Description = componentType.CustomAttribute()?.Description ?? componentType.Name; } - public Type ComponentType { get; } + public ComponentListItem(LibraryPart part) + { + this.part = part; + Name = part.Name; + Category = part.Category; + Description = part.Description; + } + + /// Null for library parts, which are built by deserializing their library entry. + public Type? ComponentType { get; } public string Name { get; } + public string Category { get; } + public string Description { get; } + public CircuitComponent Create() + { + if (part != null) + return (CircuitComponent)part.Create(); + return (CircuitComponent)Activator.CreateInstance(ComponentType!)!; + } + public override string ToString() { - return Name; + return Category.Length > 0 ? $"{Name} ({Category})" : Name; } } \ No newline at end of file From 6fb0009d6f8ec7c85eb63e98e8cfb2a3d503cde0 Mon Sep 17 00:00:00 2001 From: Charles <252065487+tobleromed@users.noreply.github.com> Date: Sat, 8 Aug 2026 09:23:09 -0700 Subject: [PATCH 2/2] Group the component palette into collapsible categories A flat list put the generic Diode at the same level as the sixteen specific ones, with only a trailing "s" distinguishing "Diode" from "Diodes" - and 110 entries is too many to scan anyway. The palette is now a tree of collapsible categories, matching what the WPF app has always done (ComponentLibrary.xaml.cs builds Common, then Generic, then the libraries). The tier names are taken from it too: "Generic" says what "Component Types" only implied - the unconfigured component you fill in yourself, as opposed to a pre-configured part. A Windows user now finds the same layout. Common (10) everyday parts, expanded by default Generic (22) the reflection-discovered component classes Diodes (16), Op-Amps (12), Transistors (47), Tubes (12) Both Diode entries earn their place: the generic one is what you need for a part the libraries do not carry, or to hand-tune a model. Headers carry counts. Filtering expands every category that matched, since results hidden behind a collapsed header would defeat the search, and drops categories that did not match entirely. Verified in the running GUI: the tree renders as above, filtering "1N4" leaves Diodes (6) expanded with the four 1N4xxx parts, and placing two 2N3904s and editing one leaves the other unchanged. Co-Authored-By: Claude Fable 5 --- LiveSPICE.Avalonia/MainWindow.cs | 63 ++++++++++++++++++++++++++------ 1 file changed, 51 insertions(+), 12 deletions(-) diff --git a/LiveSPICE.Avalonia/MainWindow.cs b/LiveSPICE.Avalonia/MainWindow.cs index 2cb49f35..331d109a 100644 --- a/LiveSPICE.Avalonia/MainWindow.cs +++ b/LiveSPICE.Avalonia/MainWindow.cs @@ -36,7 +36,7 @@ public sealed class MainWindow : Window }; private readonly TabControl documents = new TabControl(); - private readonly ListBox componentList = new ListBox(); + private readonly TreeView componentList = new TreeView(); private readonly TextBox componentFilter = new TextBox { Watermark = "Filter components" }; private List allComponents = new List(); private readonly PropertyInspector propertyInspector = new PropertyInspector(); @@ -83,7 +83,6 @@ public MainWindow() }; componentList.Margin = new global::Avalonia.Thickness(6); - componentList.DoubleTapped += (_, _) => ActivateSelectedComponent(); componentList.SelectionChanged += (_, _) => ActivateSelectedComponent(); componentFilter.Margin = new global::Avalonia.Thickness(6, 0, 6, 4); componentFilter.TextChanged += (_, _) => ApplyComponentFilter(); @@ -632,11 +631,11 @@ private void PopulateComponents() } } - // Library parts (2N3904, 1N4148, 12AX7, ...) come after the generic types, so the - // built-in components stay at the top where they were rather than being buried among - // 80-odd part numbers. - allComponents = items.OrderBy(i => i.Name) - .Concat(ComponentLibrary.Load().Select(i => new ComponentListItem(i)).OrderBy(i => i.Category).ThenBy(i => i.Name)) + // Three tiers, so a generic Diode does not sit beside the specific ones: the handful + // of everyday components, then the remaining generic types, then the part libraries. + allComponents = items + .Select(i => i.InCategory(CommonComponents.Contains(i.ComponentType!) ? BasicCategory : TypesCategory)) + .Concat(ComponentLibrary.Load().Select(i => new ComponentListItem(i))) .ToList(); ApplyComponentFilter(); } @@ -644,17 +643,51 @@ private void PopulateComponents() private void ApplyComponentFilter() { string filter = componentFilter.Text?.Trim() ?? string.Empty; - componentList.ItemsSource = filter.Length == 0 + IEnumerable matches = filter.Length == 0 ? allComponents : allComponents.Where(i => i.Name.Contains(filter, StringComparison.OrdinalIgnoreCase) || i.Category.Contains(filter, StringComparison.OrdinalIgnoreCase) || - i.Description.Contains(filter, StringComparison.OrdinalIgnoreCase)).ToList(); + i.Description.Contains(filter, StringComparison.OrdinalIgnoreCase)); + + // Group into collapsible categories so a generic Diode does not sit at the same level + // as the forty-odd specific diodes. While filtering, expand everything that matched - + // hiding results behind a collapsed header would defeat the search. + List categories = new List(); + foreach (IGrouping group in matches.GroupBy(i => i.Category).OrderBy(i => CategoryOrder(i.Key)).ThenBy(i => i.Key)) + { + TreeViewItem category = new TreeViewItem + { + Header = $"{group.Key} ({group.Count()})", + FontWeight = FontWeight.Bold, + IsExpanded = filter.Length > 0 || group.Key == BasicCategory, + ItemsSource = group + .OrderBy(i => i.Name) + .Select(i => new TreeViewItem { Header = i.Name, Tag = i, FontWeight = FontWeight.Normal }) + .ToList() + }; + categories.Add(category); + } + componentList.ItemsSource = categories; + } + + // Named to match the WPF app's palette (ComponentLibrary.xaml.cs), so the layout is + // familiar: "Generic" is the unconfigured component you fill in yourself, as opposed to a + // pre-configured part from a library. + private const string BasicCategory = "Common"; + private const string TypesCategory = "Generic"; + + /// Basic first, then the generic types, then the part libraries alphabetically. + private static int CategoryOrder(string Category) + { + if (Category == BasicCategory) return 0; + if (Category == TypesCategory) return 1; + return 2; } private void ActivateSelectedComponent() { - if (componentList.SelectedItem is not ComponentListItem item || ActiveCanvas == null) + if (componentList.SelectedItem is not TreeViewItem node || node.Tag is not ComponentListItem item || ActiveCanvas == null) return; if (item.ComponentType == typeof(Conductor)) @@ -841,15 +874,21 @@ internal sealed class ComponentListItem { private readonly LibraryPart? part; - public ComponentListItem(Type componentType) + public ComponentListItem(Type componentType, string category = "") { ComponentType = componentType; CircuitComponent component = (CircuitComponent)Activator.CreateInstance(componentType)!; Name = component.TypeName; - Category = string.Empty; + Category = category; Description = componentType.CustomAttribute()?.Description ?? componentType.Name; } + /// This item filed under a different category. + public ComponentListItem InCategory(string category) + { + return ComponentType != null ? new ComponentListItem(ComponentType, category) : this; + } + public ComponentListItem(LibraryPart part) { this.part = part;