Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 7 additions & 5 deletions Circuit/Circuit.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,13 @@
<ProjectReference Include="..\Util\Util.csproj" />
</ItemGroup>
<ItemGroup>
<Content Include="Components\Diodes.xml" />
<Content Include="Components\OpAmps.xml" />
<Content Include="Components\Readme.txt" />
<Content Include="Components\Transistors.xml" />
<Content Include="Components\Tubes.xml">
<!-- The whole library is copied: only Tubes.xml used to be, so a host looking beside its
executable found one library out of four. -->
<Content Include="Components\*.xml">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<Link>Components\%(Filename)%(Extension)</Link>
</Content>
<Content Include="Components\Readme.txt">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
</ItemGroup>
Expand Down
47 changes: 47 additions & 0 deletions LiveSPICE.Avalonia.Tests/ComponentLibraryTests.cs
Original file line number Diff line number Diff line change
@@ -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<BipolarJunctionTransistor>(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);
}
}
133 changes: 133 additions & 0 deletions LiveSPICE.Avalonia/ComponentLibrary.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// 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.
/// </summary>
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);
}
}

/// <summary>
/// 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.
/// </summary>
internal static class ComponentLibrary
{
/// <summary>
/// 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.
/// </summary>
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<LibraryPart> 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<LibraryPart>();
}
return Load(path);
}

public static IReadOnlyList<LibraryPart> Load(string Path)
{
List<LibraryPart> parts = new List<LibraryPart>();
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<LibraryPart> 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);
}
}
}
}
105 changes: 96 additions & 9 deletions LiveSPICE.Avalonia/MainWindow.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<ComponentListItem> allComponents = new List<ComponentListItem>();
private readonly PropertyInspector propertyInspector = new PropertyInspector();
private readonly TextBlock status = new TextBlock { Text = "Ready", VerticalAlignment = VerticalAlignment.Center };
private readonly AppSettings settings = AppSettings.Load();
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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<ComponentListItem> 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<TreeViewItem> categories = new List<TreeViewItem>();
foreach (IGrouping<string, ComponentListItem> 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";

/// <summary>Basic first, then the generic types, then the part libraries alphabetically.</summary>
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))
Expand All @@ -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";
}

Expand Down Expand Up @@ -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<DescriptionAttribute>()?.Description ?? componentType.Name;
}

public Type ComponentType { get; }
/// <summary>This item filed under a different category.</summary>
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;
}

/// <summary>Null for library parts, which are built by deserializing their library entry.</summary>
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;
}
}
Loading