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