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..331d109a 100644
--- a/LiveSPICE.Avalonia/MainWindow.cs
+++ b/LiveSPICE.Avalonia/MainWindow.cs
@@ -36,7 +36,9 @@ 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();
private readonly TextBlock status = new TextBlock { Text = "Ready", VerticalAlignment = VerticalAlignment.Center };
private readonly AppSettings settings = AppSettings.Load();
@@ -81,11 +83,18 @@ 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,12 +631,63 @@ private void PopulateComponents()
}
}
- componentList.ItemsSource = items.OrderBy(i => i.Name).ToList();
+ // 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();
+ }
+
+ private void ApplyComponentFilter()
+ {
+ string filter = componentFilter.Text?.Trim() ?? string.Empty;
+ 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));
+
+ // 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))
@@ -637,7 +697,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 +872,49 @@ private async void OnKeyDown(object? sender, KeyEventArgs e)
internal sealed class ComponentListItem
{
- public ComponentListItem(Type componentType)
+ private readonly LibraryPart? part;
+
+ public ComponentListItem(Type componentType, string category = "")
{
ComponentType = componentType;
CircuitComponent component = (CircuitComponent)Activator.CreateInstance(componentType)!;
Name = component.TypeName;
+ Category = category;
Description = componentType.CustomAttribute()?.Description ?? componentType.Name;
}
- public Type ComponentType { get; }
+ /// 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;
+ 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