Skip to content

Commit 63b8332

Browse files
sebgodclaude
andcommitted
Add dropdown menu for filter names, fix Filter.FromName with generated regex
- Generic dropdown menu widget (DIR.Lib 1.3.70) with filter name selection - Filter.FromName rewritten with [GeneratedRegex], supports unicode α/β, fixes dual-band parsing (H-Alpha+OIII, SII+OIII) - Filter gains DisplayName field for user-friendly names (H-Alpha vs HydrogenAlpha) - InstalledFilter preserves custom names (Optolong L-Ultimate etc.) via CustomName - Equipment tab: dropdown opens on filter name click with common names + Custom... entry; Custom... activates inline text input with SDL focus - CommonFilterNames moved to shared EquipmentActions - 67 new Filter unit tests (roundtrip Name/DisplayName/ShortName, dual-band, unicode, custom filter names) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 0958b9d commit 63b8332

File tree

9 files changed

+447
-146
lines changed

9 files changed

+447
-146
lines changed

nupkgs/DIR.Lib.1.3.70.nupkg

45.6 KB
Binary file not shown.

src/Directory.Packages.props

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@
4040
<PackageVersion Include="Pastel" Version="7.0.1" />
4141
<PackageVersion Include="System.CommandLine" Version="2.0.5" />
4242
<!-- SDL3 + Vulkan packages -->
43-
<PackageVersion Include="DIR.Lib" Version="1.3.69" />
43+
<PackageVersion Include="DIR.Lib" Version="1.3.70" />
4444
<PackageVersion Include="Console.Lib" Version="1.4.93" />
4545
<PackageVersion Include="SdlVulkan.Renderer" Version="1.1.67" />
4646
<PackageVersion Include="SDL3-CS" Version="3.5.0-preview.20260213-150035" />
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
using Shouldly;
2+
using TianWen.Lib.Imaging;
3+
using Xunit;
4+
5+
namespace TianWen.Lib.Tests;
6+
7+
public sealed class FilterTests
8+
{
9+
[Theory]
10+
[InlineData("Luminance", "Luminance")]
11+
[InlineData("L", "Luminance")]
12+
[InlineData("LUM", "Luminance")]
13+
[InlineData("Red", "Red")]
14+
[InlineData("R", "Red")]
15+
[InlineData("Green", "Green")]
16+
[InlineData("G", "Green")]
17+
[InlineData("Blue", "Blue")]
18+
[InlineData("B", "Blue")]
19+
[InlineData("H-Alpha", "HydrogenAlpha")]
20+
[InlineData("Ha", "HydrogenAlpha")]
21+
[InlineData("HAlpha", "HydrogenAlpha")]
22+
[InlineData("H-Beta", "HydrogenBeta")]
23+
[InlineData("Hb", "HydrogenBeta")]
24+
[InlineData("OIII", "OxygenIII")]
25+
[InlineData("O3", "OxygenIII")]
26+
[InlineData("SII", "SulphurII")]
27+
[InlineData("S2", "SulphurII")]
28+
public void FromName_SingleBand_ResolvesCorrectly(string input, string expectedName)
29+
{
30+
Filter.FromName(input).Name.ShouldBe(expectedName);
31+
}
32+
33+
[Theory]
34+
[InlineData("H-Alpha + OIII")]
35+
[InlineData("HAlpha+OIII")]
36+
[InlineData("Ha+OIII")]
37+
[InlineData("HydrogenAlphaOxygenIII")]
38+
public void FromName_DualBand_HAlphaOIII_ResolvesCorrectly(string input)
39+
{
40+
var filter = Filter.FromName(input);
41+
filter.ShouldBe(Filter.HydrogenAlphaOxygenIII);
42+
filter.DisplayName.ShouldBe("H-Alpha + OIII");
43+
}
44+
45+
[Theory]
46+
[InlineData("SII + OIII")]
47+
[InlineData("SII+OIII")]
48+
[InlineData("S2+OIII")]
49+
[InlineData("SulphurIIOxygenIII")]
50+
public void FromName_DualBand_SIIOIII_ResolvesCorrectly(string input)
51+
{
52+
var filter = Filter.FromName(input);
53+
filter.ShouldBe(Filter.SulphurIIOxygenIII);
54+
filter.DisplayName.ShouldBe("SII + OIII");
55+
}
56+
57+
[Theory]
58+
[InlineData("None")]
59+
[InlineData("NONE")]
60+
public void FromName_None_ResolvesCorrectly(string input)
61+
{
62+
Filter.FromName(input).ShouldBe(Filter.None);
63+
}
64+
65+
[Fact]
66+
public void FromName_Null_ReturnsNone()
67+
{
68+
Filter.FromName(null!).ShouldBe(Filter.None);
69+
}
70+
71+
[Theory]
72+
[InlineData("")]
73+
[InlineData("FooBar")]
74+
[InlineData("XYZ")]
75+
[InlineData("L-Quad Enhance")]
76+
[InlineData("Optolong L-Ultimate")]
77+
[InlineData("L-eXtreme")]
78+
[InlineData("IDAS NBZ")]
79+
public void FromName_Unknown_ReturnsUnknown(string input)
80+
{
81+
Filter.FromName(input).ShouldBe(Filter.Unknown);
82+
}
83+
84+
[Fact]
85+
public void DisplayName_MatchesCommonNames()
86+
{
87+
Filter.Luminance.DisplayName.ShouldBe("Luminance");
88+
Filter.Red.DisplayName.ShouldBe("Red");
89+
Filter.Green.DisplayName.ShouldBe("Green");
90+
Filter.Blue.DisplayName.ShouldBe("Blue");
91+
Filter.HydrogenAlpha.DisplayName.ShouldBe("H-Alpha");
92+
Filter.HydrogenBeta.DisplayName.ShouldBe("H-Beta");
93+
Filter.OxygenIII.DisplayName.ShouldBe("OIII");
94+
Filter.SulphurII.DisplayName.ShouldBe("SII");
95+
Filter.HydrogenAlphaOxygenIII.DisplayName.ShouldBe("H-Alpha + OIII");
96+
Filter.SulphurIIOxygenIII.DisplayName.ShouldBe("SII + OIII");
97+
}
98+
99+
[Fact]
100+
public void ShortName_UsesAbbreviations()
101+
{
102+
Filter.Luminance.ShortName.ShouldBe("L");
103+
Filter.Red.ShortName.ShouldBe("R");
104+
Filter.HydrogenAlpha.ShortName.ShouldBe("H\u03B1");
105+
Filter.HydrogenBeta.ShortName.ShouldBe("H\u03B2");
106+
Filter.HydrogenAlphaOxygenIII.ShortName.ShouldBe("H\u03B1+OIII");
107+
}
108+
109+
[Theory]
110+
[InlineData("H\u03B1", "HydrogenAlpha")]
111+
[InlineData("H\u03B2", "HydrogenBeta")]
112+
[InlineData("H\u03B1+OIII", "HydrogenAlphaOxygenIII")]
113+
public void FromName_UnicodeGreekLetters_ResolvesCorrectly(string input, string expectedName)
114+
{
115+
Filter.FromName(input).Name.ShouldBe(expectedName);
116+
}
117+
118+
[Theory]
119+
[MemberData(nameof(AllKnownFilters))]
120+
public void FromName_Roundtrip_Name(Filter filter)
121+
{
122+
if (filter == Filter.None || filter == Filter.Unknown) return;
123+
Filter.FromName(filter.Name).ShouldBe(filter);
124+
}
125+
126+
[Theory]
127+
[MemberData(nameof(AllKnownFilters))]
128+
public void FromName_Roundtrip_DisplayName(Filter filter)
129+
{
130+
if (filter == Filter.None || filter == Filter.Unknown) return;
131+
Filter.FromName(filter.DisplayName).ShouldBe(filter);
132+
}
133+
134+
[Theory]
135+
[MemberData(nameof(AllKnownFilters))]
136+
public void FromName_Roundtrip_ShortName(Filter filter)
137+
{
138+
if (filter == Filter.None || filter == Filter.Unknown) return;
139+
Filter.FromName(filter.ShortName).ShouldBe(filter);
140+
}
141+
142+
public static TheoryData<Filter> AllKnownFilters => new()
143+
{
144+
Filter.Luminance,
145+
Filter.Red,
146+
Filter.Green,
147+
Filter.Blue,
148+
Filter.HydrogenAlpha,
149+
Filter.HydrogenBeta,
150+
Filter.OxygenIII,
151+
Filter.SulphurII,
152+
Filter.HydrogenAlphaOxygenIII,
153+
Filter.SulphurIIOxygenIII,
154+
};
155+
}

src/TianWen.Lib/Devices/InstalledFilter.cs

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,16 @@
22

33
namespace TianWen.Lib.Devices;
44

5-
public readonly record struct InstalledFilter(Filter Filter, int Position = 0)
5+
public readonly record struct InstalledFilter(Filter Filter, int Position = 0, string? CustomName = null)
66
{
7-
public InstalledFilter(string Name, int Position = 0) : this(Filter.FromName(Name), Position) { }
7+
public InstalledFilter(string Name, int Position = 0)
8+
: this(Filter.FromName(Name), Position, Filter.FromName(Name) == Filter.Unknown ? Name : null) { }
9+
10+
/// <summary>
11+
/// Display name: uses <see cref="CustomName"/> for unknown filters,
12+
/// otherwise <see cref="Filter.DisplayName"/>.
13+
/// </summary>
14+
public string DisplayName => CustomName ?? Filter.DisplayName;
815

916
public static implicit operator Filter(InstalledFilter installedFilter) => installedFilter.Filter;
1017
}

src/TianWen.Lib/Imaging/Filter.cs

Lines changed: 74 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,44 +1,85 @@
1-
using System.Text.RegularExpressions;
1+
using System.Text.RegularExpressions;
22

33
namespace TianWen.Lib.Imaging;
44

5-
public readonly record struct Filter(string Name, string ShortName, Bandpass Bandpass)
5+
public readonly partial record struct Filter(string Name, string ShortName, string DisplayName, Bandpass Bandpass)
66
{
7-
public static readonly Filter None = new(nameof(None), nameof(None), Bandpass.None);
8-
public static readonly Filter Unknown = new(nameof(Unknown), nameof(Unknown), Bandpass.None);
9-
public static readonly Filter Luminance = new(nameof(Luminance), "L", Bandpass.Luminance);
10-
public static readonly Filter Red = new(nameof(Red), "R", Bandpass.Red);
11-
public static readonly Filter Green = new(nameof(Green), "G", Bandpass.Green);
12-
public static readonly Filter Blue = new(nameof(Blue), "B", Bandpass.Blue);
13-
public static readonly Filter HydrogenAlpha = new(nameof(HydrogenAlpha), "H\u03B1", Bandpass.Ha);
14-
public static readonly Filter HydrogenBeta = new(nameof(HydrogenBeta), "H\u03B2", Bandpass.Hb);
15-
public static readonly Filter OxygenIII = new(nameof(OxygenIII), "OIII", Bandpass.OIII);
16-
public static readonly Filter SulphurII = new(nameof(SulphurII), "SII", Bandpass.SII);
7+
/// <summary>Backwards-compatible constructor (DisplayName defaults to Name).</summary>
8+
public Filter(string Name, string ShortName, Bandpass Bandpass)
9+
: this(Name, ShortName, Name, Bandpass) { }
10+
11+
public static readonly Filter None = new(nameof(None), nameof(None), nameof(None), Bandpass.None);
12+
public static readonly Filter Unknown = new(nameof(Unknown), nameof(Unknown), nameof(Unknown), Bandpass.None);
13+
public static readonly Filter Luminance = new(nameof(Luminance), "L", "Luminance", Bandpass.Luminance);
14+
public static readonly Filter Red = new(nameof(Red), "R", "Red", Bandpass.Red);
15+
public static readonly Filter Green = new(nameof(Green), "G", "Green", Bandpass.Green);
16+
public static readonly Filter Blue = new(nameof(Blue), "B", "Blue", Bandpass.Blue);
17+
public static readonly Filter HydrogenAlpha = new(nameof(HydrogenAlpha), "H\u03B1", "H-Alpha", Bandpass.Ha);
18+
public static readonly Filter HydrogenBeta = new(nameof(HydrogenBeta), "H\u03B2", "H-Beta", Bandpass.Hb);
19+
public static readonly Filter OxygenIII = new(nameof(OxygenIII), "OIII", "OIII", Bandpass.OIII);
20+
public static readonly Filter SulphurII = new(nameof(SulphurII), "SII", "SII", Bandpass.SII);
1721
// dual band filters
18-
public static readonly Filter HydrogenAlphaOxygenIII = new(nameof(HydrogenAlphaOxygenIII), "H\u03B1+OIII", Bandpass.Ha | Bandpass.OIII);
19-
public static readonly Filter SulphurIIOxygenIII = new(nameof(HydrogenAlphaOxygenIII), "SII+OIII", Bandpass.SII | Bandpass.OIII);
22+
public static readonly Filter HydrogenAlphaOxygenIII = new(nameof(HydrogenAlphaOxygenIII), "H\u03B1+OIII", "H-Alpha + OIII", Bandpass.Ha | Bandpass.OIII);
23+
public static readonly Filter SulphurIIOxygenIII = new(nameof(SulphurIIOxygenIII), "SII+OIII", "SII + OIII", Bandpass.SII | Bandpass.OIII);
24+
25+
// Dual-band patterns must be tested before single-band to avoid partial matches (e.g. "Ha+OIII" matching "Ha")
26+
[GeneratedRegex(@"^\s*(?:h(?:ydrogen)?[\s\-]*(?:a(?:lpha)?|\u03B1)|ha|h\u03B1)[\s\+]*o(?:xygen)?[\s\-]*iii\s*$", RegexOptions.IgnoreCase)]
27+
private static partial Regex HAlphaOIIIPattern();
28+
29+
[GeneratedRegex(@"^\s*s(?:ulphur)?[\s\-]*(?:ii|2)[\s\+]*o(?:xygen)?[\s\-]*iii\s*$", RegexOptions.IgnoreCase)]
30+
private static partial Regex SIIOIIIPattern();
31+
32+
[GeneratedRegex(@"^\s*(?:l(?:um(?:inance)?)?)\s*$", RegexOptions.IgnoreCase)]
33+
private static partial Regex LuminancePattern();
34+
35+
[GeneratedRegex(@"^\s*(?:r(?:ed)?)\s*$", RegexOptions.IgnoreCase)]
36+
private static partial Regex RedPattern();
37+
38+
[GeneratedRegex(@"^\s*(?:g(?:reen)?)\s*$", RegexOptions.IgnoreCase)]
39+
private static partial Regex GreenPattern();
40+
41+
[GeneratedRegex(@"^\s*(?:b(?:lue)?)\s*$", RegexOptions.IgnoreCase)]
42+
private static partial Regex BluePattern();
43+
44+
[GeneratedRegex(@"^\s*(?:h(?:ydrogen)?[\s\-]*(?:a(?:lpha)?|\u03B1)|ha|h\u03B1)\s*$", RegexOptions.IgnoreCase)]
45+
private static partial Regex HAlphaPattern();
2046

21-
/// <summary>Parses a known set of filters or <see cref="Unknown"/> if none match</summary>
22-
/// <param name="name"></param>
23-
/// <returns></returns>
24-
public static Filter FromName(string name)
47+
[GeneratedRegex(@"^\s*(?:h(?:ydrogen)?[\s\-]*(?:b(?:eta)?|\u03B2)|hb|h\u03B2)\s*$", RegexOptions.IgnoreCase)]
48+
private static partial Regex HBetaPattern();
49+
50+
[GeneratedRegex(@"^\s*(?:o(?:xygen)?[\s\-]*iii|o3|oiii)\s*$", RegexOptions.IgnoreCase)]
51+
private static partial Regex OIIIPattern();
52+
53+
[GeneratedRegex(@"^\s*(?:s(?:ulphur)?[\s\-]*(?:ii|2)|sii|s2)\s*$", RegexOptions.IgnoreCase)]
54+
private static partial Regex SIIPattern();
55+
56+
[GeneratedRegex(@"^\s*none\s*$", RegexOptions.IgnoreCase)]
57+
private static partial Regex NonePattern();
58+
59+
/// <summary>Parses a known set of filters or <see cref="Unknown"/> if none match.</summary>
60+
public static Filter FromName(string? name)
2561
{
26-
var normalizedName = name?.Trim().ToUpperInvariant().Replace(" ", "").Replace("-", "").Replace("+", "");
27-
return normalizedName switch
62+
if (name is null)
2863
{
29-
"NONE" => None,
30-
"L" or "LUM" or "LUMINANCE" => Luminance,
31-
"R" or "RED" => Red,
32-
"B" or "BLUE" => Blue,
33-
"G" or "GREEN" => Green,
34-
"HALPHA" or "HA" or "HYDROGENALPHA" => HydrogenAlpha,
35-
"HBETA" or "HB" or "HYDROGENBETA" => HydrogenBeta,
36-
"OIII" or "O3" or "OIII" or "OXYIII" or "OXYGENIII" => OxygenIII,
37-
"SII" or "S2" or "SULPHURII" => SulphurII,
38-
"HYDROGENALPHAOXYGENIII" or "HALPHA" => HydrogenAlphaOxygenIII,
39-
null => None,
40-
_ => Unknown
41-
};
64+
return None;
65+
}
66+
67+
// Dual-band patterns first (contain single-band substrings)
68+
if (HAlphaOIIIPattern().IsMatch(name)) return HydrogenAlphaOxygenIII;
69+
if (SIIOIIIPattern().IsMatch(name)) return SulphurIIOxygenIII;
70+
71+
// Single-band
72+
if (NonePattern().IsMatch(name)) return None;
73+
if (LuminancePattern().IsMatch(name)) return Luminance;
74+
if (RedPattern().IsMatch(name)) return Red;
75+
if (GreenPattern().IsMatch(name)) return Green;
76+
if (BluePattern().IsMatch(name)) return Blue;
77+
if (HAlphaPattern().IsMatch(name)) return HydrogenAlpha;
78+
if (HBetaPattern().IsMatch(name)) return HydrogenBeta;
79+
if (OIIIPattern().IsMatch(name)) return OxygenIII;
80+
if (SIIPattern().IsMatch(name)) return SulphurII;
81+
82+
return Unknown;
4283
}
4384

4485
public static implicit operator Filter(string name) => FromName(name);

src/TianWen.UI.Abstractions/EquipmentActions.cs

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,21 @@ namespace TianWen.UI.Abstractions;
1616
/// </summary>
1717
public static class EquipmentActions
1818
{
19+
/// <summary>
20+
/// Common filter names for the equipment tab dropdown and CLI.
21+
/// </summary>
22+
public static readonly IReadOnlyList<string> CommonFilterNames =
23+
[
24+
"Luminance", "Red", "Green", "Blue",
25+
"H-Alpha", "OIII", "SII", "H-Beta",
26+
"H-Alpha + OIII"
27+
];
28+
29+
/// <summary>
30+
/// Returns the display-friendly name for a filter.
31+
/// </summary>
32+
public static string FilterDisplayName(InstalledFilter filter) => filter.DisplayName;
33+
1934
public static async Task<Profile> CreateProfileAsync(string name, IExternal external, CancellationToken ct)
2035
{
2136
var profile = new Profile(Guid.NewGuid(), name, ProfileData.Empty);
@@ -273,7 +288,7 @@ public static ProfileData SetFilterConfig(ProfileData data, int otaIndex, IReadO
273288
// Write new filter/offset params
274289
for (var i = 0; i < filters.Count; i++)
275290
{
276-
query[DeviceQueryKeyExtensions.FilterKey(i + 1)] = filters[i].Filter.Name;
291+
query[DeviceQueryKeyExtensions.FilterKey(i + 1)] = filters[i].DisplayName;
277292
query[DeviceQueryKeyExtensions.FilterOffsetKey(i + 1)] = filters[i].Position.ToString(CultureInfo.InvariantCulture);
278293
}
279294

src/TianWen.UI.Gui/EquipmentTabState.cs

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,35 @@ public class EquipmentTabState
3838
// Filter editing: which OTA's filter table is expanded (-1 = none)
3939
public int ExpandedFilterOtaIndex { get; set; } = -1;
4040

41+
// Mutable filter list for in-memory editing (saved on explicit Save)
42+
public List<InstalledFilter>? EditingFilters { get; set; }
43+
public bool FiltersDirty { get; set; }
44+
45+
/// <summary>
46+
/// Loads filters into the mutable editing list from the profile.
47+
/// </summary>
48+
public void BeginEditingFilters(IReadOnlyList<InstalledFilter> filters)
49+
{
50+
EditingFilters = new List<InstalledFilter>(filters);
51+
FiltersDirty = false;
52+
}
53+
54+
/// <summary>
55+
/// Discards the mutable filter list.
56+
/// </summary>
57+
public void StopEditingFilters()
58+
{
59+
EditingFilters = null;
60+
FiltersDirty = false;
61+
}
62+
63+
// Filter name dropdown
64+
public DropdownMenuState FilterNameDropdown { get; } = new();
65+
66+
// Custom filter name input (shared across all slots, activated by "Custom..." entry)
67+
public int CustomFilterSlotIndex { get; set; } = -1;
68+
public TextInputState CustomFilterNameInput { get; } = new() { Placeholder = "Filter name..." };
69+
4170
// OTA property editing: which OTA is being edited (-1 = none)
4271
public int EditingOtaIndex { get; set; } = -1;
4372
public TextInputState OtaNameInput { get; } = new() { Placeholder = "OTA name" };

0 commit comments

Comments
 (0)