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
147 changes: 133 additions & 14 deletions src/MUI.Catalog/Facets.cs
Original file line number Diff line number Diff line change
Expand Up @@ -88,30 +88,97 @@ public enum FacetKind
/// real and useful question; it is not "games with no codebase", and neither is it a games-with-any
/// filter left blank. Modelling it as a value would let one be typed where the other was meant.
/// </remarks>
public sealed record FacetChoice(string? Value)
public sealed record FacetChoice(string? Value, bool Exclude = false)
{
/// <summary>
/// The querystring spelling of the absence. Tilde-prefixed so it cannot collide with a real
/// value: a game may legitimately be called <c>none</c> and none may legitimately be a genre.
/// </summary>
public const string UnknownToken = "~unknown";

/// <summary>
/// The prefix that turns a selection inside out: <c>?codebase=!Evennia</c> is every game whose
/// codebase is not Evennia.
/// </summary>
/// <remarks>
/// <para>
/// <b>A facet has three states, not two</b>, and the third one is what makes the panel a filter
/// rather than a set of shortcuts. Absent means the facet is not being asked about; a value
/// means <em>only these</em>; an excluded value means <em>anything but these</em>. Without the
/// third, "show me the games that are not Evennia" is a question the catalogue can answer and
/// the interface cannot ask.
/// </para>
/// <para>
/// <c>!</c> rather than <c>-</c> because a codebase, genre or language may legitimately begin
/// with a hyphen and none of the values observed in the wild begin with a bang. A literal
/// leading <c>!</c> is written <c>!!</c>, so a value is never unreachable — see
/// <see cref="Parse"/>.
/// </para>
/// </remarks>
public const string ExcludeToken = "!";

/// <summary>Games for which this facet has no value at all.</summary>
public static readonly FacetChoice Unknown = new((string?)null);

public static FacetChoice Of(string value) => new(value);

/// <summary>The same selection, inside out.</summary>
public static FacetChoice Not(string value) => new(value, Exclude: true);

public bool IsUnknown => Value is null;

/// <summary>What this selection is called in a URL.</summary>
public string Token => Value ?? UnknownToken;
/// <summary>What this selection is called in a URL, polarity included.</summary>
public string Token =>
(Exclude ? ExcludeToken : string.Empty) + Escaped(Value ?? UnknownToken);

public static FacetChoice Parse(string token) =>
string.Equals(token, UnknownToken, StringComparison.Ordinal) ? Unknown : Of(token);
/// <summary>The same facet with its polarity flipped, which is what a panel's toggle emits.</summary>
/// <remarks>
/// <b>A method and not a property, deliberately.</b> A record's generated <c>ToString</c> prints
/// every public property, so a property returning another <see cref="FacetChoice"/> makes
/// printing one recurse until the stack runs out — which is exactly what happened, and it
/// surfaced as an unrelated test dying inside an assertion message rather than as anything to do
/// with facets.
/// </remarks>
public FacetChoice Invert() => this with { Exclude = !Exclude };

/// <summary>Whether a game whose value for this facet is <paramref name="actual"/> matches.</summary>
public bool Matches(string? actual) =>
public static FacetChoice Parse(string token)
{
ArgumentNullException.ThrowIfNull(token);

var exclude = token.StartsWith(ExcludeToken, StringComparison.Ordinal)
&& !token.StartsWith(ExcludeToken + ExcludeToken, StringComparison.Ordinal);

var body = exclude
? token[ExcludeToken.Length..]
// A doubled bang is a literal one: a value that genuinely starts with "!" stays
// reachable rather than being silently reinterpreted as its own negation.
: token.StartsWith(ExcludeToken + ExcludeToken, StringComparison.Ordinal)
? token[ExcludeToken.Length..]
: token;

return string.Equals(body, UnknownToken, StringComparison.Ordinal)
? Unknown with { Exclude = exclude }
: new FacetChoice(body, exclude);
}

/// <summary>
/// Whether <paramref name="actual"/> is the value this selection names — <b>polarity ignored</b>.
/// </summary>
/// <remarks>
/// Deliberately separate from <see cref="Admits"/>. A facet can hand a row several tokens (a
/// game reached an hour ago is in the last day, week and month), and inverting the comparison
/// per token would make an excluded selection mean "some token differs" — which every row with
/// more than one token satisfies. The polarity is applied once, to the answer.
/// </remarks>
public bool Covers(string? actual) =>
IsUnknown ? actual is null : string.Equals(actual, Value, StringComparison.OrdinalIgnoreCase);

/// <summary>Whether a row that did or did not match this selection survives it.</summary>
public bool Admits(bool covered) => covered != Exclude;

/// <summary>A value that begins with the exclusion marker is doubled so it round-trips.</summary>
private static string Escaped(string value) =>
value.StartsWith(ExcludeToken, StringComparison.Ordinal) ? ExcludeToken + value : value;
}

/// <summary>
Expand Down Expand Up @@ -151,7 +218,36 @@ public enum LastSeenBand
/// results it will not deliver. A value nothing matches is never offered at all — the one exception
/// is a value that is currently selected, which stays visible at zero so it can be seen and undone.
/// </remarks>
public sealed record FacetValue(string Token, int Count, bool IsSelected, bool IsUnknown);
public sealed record FacetValue(
string Token,
int Count,
bool IsSelected,
bool IsUnknown,
bool IsExcluded = false)
{
/// <summary>
/// The three states a value can be in, as one question a renderer can switch on.
/// </summary>
/// <remarks>
/// A panel that only knew <see cref="IsSelected"/> would draw an included and an excluded value
/// identically, which is the one thing a tri-state filter must not do — a reader would have no
/// way to tell "only Evennia" from "anything but Evennia" except by reading the URL.
/// </remarks>
public FacetState State => (IsSelected, IsExcluded) switch
{
(true, true) => FacetState.Excluded,
(true, false) => FacetState.Included,
_ => FacetState.Unselected,
};
}

/// <summary>Whether a facet value is being filtered in, filtered out, or not asked about.</summary>
public enum FacetState
{
Unselected,
Included,
Excluded,
}

/// <summary>One facet, ready to render: what it is called, what it reads, and what it offers.</summary>
/// <remarks>
Expand Down Expand Up @@ -257,7 +353,7 @@ public static GameListing Search(IReadOnlyList<GameFacetRow> rows, GameFilter fi
var baseRows = rows
.Where(r => (wantsArchived || r.Band is not ActivityBand.Archived)
&& MatchesText(r, filter.Text)
&& CodebaseFamily.Matches(r.Codebase, filter.CodebaseFamily))
&& AdmitsFamily(r, filter.CodebaseFamily))
.ToList();

var results = baseRows.Where(r => Chosen(r, filter, null) && Present(r, filter)).ToList();
Expand Down Expand Up @@ -324,6 +420,18 @@ private static bool MatchesText(GameFacetRow row, string? text)
|| (row.Codebase?.Contains(needle, StringComparison.OrdinalIgnoreCase) ?? false);
}

/// <summary>
/// Whether a row survives the codebase-family filter, polarity included.
/// </summary>
/// <remarks>
/// Separate from the choice facets because the test is a bounded prefix rather than an equality,
/// and because this is a filter rather than a counted facet — it narrows the set the panel's
/// counts are taken over, which is what makes a codebase page's facet counts counts within that
/// codebase.
/// </remarks>
private static bool AdmitsFamily(GameFacetRow row, FacetChoice? family) =>
family is null || family.Admits(CodebaseFamily.Matches(row.Codebase, family.Value));

private static bool Chosen(GameFacetRow row, GameFilter filter, string? except)
{
foreach (var facet in Choices)
Expand All @@ -333,8 +441,9 @@ private static bool Chosen(GameFacetRow row, GameFilter filter, string? except)
continue;
}

// Applied once to the answer, not per token: see FacetChoice.Covers.
if (facet.SelectionOf(filter) is { } selection
&& !facet.TokensOf(row).Any(selection.Matches))
&& !selection.Admits(facet.TokensOf(row).Any(selection.Covers)))
{
return false;
}
Expand Down Expand Up @@ -416,8 +525,9 @@ .. vocabulary
.Select(token => new FacetValue(
token,
counts.GetValueOrDefault(token),
selection?.Matches(token) ?? false,
IsUnknown: false))
selection?.Covers(token) ?? false,
IsUnknown: false,
IsExcluded: (selection?.Covers(token) ?? false) && selection!.Exclude))
.Where(v => v.Count > 0 || v.IsSelected),
];
}
Expand All @@ -442,7 +552,11 @@ private static List<FacetValue> Open(
var named = counts
.Where(c => !string.Equals(c.Key, FacetChoice.UnknownToken, StringComparison.Ordinal))
.Select(c => new FacetValue(
c.Key, c.Value, selection?.Matches(c.Key) ?? false, IsUnknown: false))
c.Key,
c.Value,
selection?.Covers(c.Key) ?? false,
IsUnknown: false,
IsExcluded: (selection?.Covers(c.Key) ?? false) && selection!.Exclude))
.OrderByDescending(v => v.IsSelected)
.ThenByDescending(v => v.Count)
.ThenBy(v => v.Token, StringComparer.Ordinal)
Expand All @@ -456,7 +570,12 @@ private static List<FacetValue> Open(

if (unknown > 0 || unknownSelected)
{
named.Add(new FacetValue(FacetChoice.UnknownToken, unknown, unknownSelected, IsUnknown: true));
named.Add(new FacetValue(
FacetChoice.UnknownToken,
unknown,
unknownSelected,
IsUnknown: true,
IsExcluded: unknownSelected && selection!.Exclude));
}

return named;
Expand Down
9 changes: 8 additions & 1 deletion src/MUI.Catalog/Views.cs
Original file line number Diff line number Diff line change
Expand Up @@ -188,8 +188,15 @@ public sealed record GameFilter
/// variable, which answers <c>TinyMUD</c> or <c>DikuMUD</c>; this is the codebase with its
/// version taken off. A reference page for PennMUSH wants the third and neither of the others.
/// </para>
/// <para>
/// A <see cref="FacetChoice"/> for its polarity rather than its matching: the choice carries the
/// value and whether it is being filtered in or out, and the <em>test</em> is supplied by the
/// caller — <see cref="CodebaseFamily.Matches"/>, a bounded prefix, so <c>ROM</c> does not gather
/// <c>ROMulus</c>. It is not offered as a counted facet in the panel, so it never appears in the
/// vocabulary the choice facets are drawn from.
/// </para>
/// </remarks>
public string? CodebaseFamily { get; init; }
public FacetChoice? CodebaseFamily { get; init; }
}

/// <summary>
Expand Down
2 changes: 1 addition & 1 deletion src/MUI.Web/Api/ApiModels.cs
Original file line number Diff line number Diff line change
Expand Up @@ -213,7 +213,7 @@ public static FilterView Of(GameFilter filter)
filter.Family?.Token,
filter.Genre?.Token,
filter.Language?.Token,
filter.CodebaseFamily);
filter.CodebaseFamily?.Token);
}
}

Expand Down
4 changes: 3 additions & 1 deletion src/MUI.Web/Api/GameFilterBinding.cs
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,9 @@ private static bool TryRead(
Family = Choice(read, FacetKeys.Family),
Genre = Choice(read, FacetKeys.Genre),
Language = Choice(read, FacetKeys.Language),
CodebaseFamily = string.IsNullOrWhiteSpace(codebaseFamily) ? null : codebaseFamily.Trim(),
CodebaseFamily = string.IsNullOrWhiteSpace(codebaseFamily)
? null
: FacetChoice.Parse(codebaseFamily.Trim()),
};

result = new GameQuery(
Expand Down
44 changes: 38 additions & 6 deletions src/MUI.Web/Components/FacetPanel.razor
Original file line number Diff line number Diff line change
Expand Up @@ -38,15 +38,36 @@
@FacetWords.Group(group.Key)
<span class="evidence @Evidence(group)">@FacetWords.Evidence(group.Evidence)</span>
</label>
@*
Every value appears twice — once to filter in, once to filter out — which
is the only way to offer the third state in a plain GET form with no
script. A facet has three states (spec §9 and FacetChoice): not asked
about, only these, anything but these. Two <optgroup>s rather than a
second control, so one <select> still carries the whole question and the
browser's own keyboard handling still works.
*@
<select id="facet-@group.Key" name="@group.Key">
@* Always first, always empty: a facet you cannot un-choose is a trap. *@
<option value="">any (@group.Total)</option>
@foreach (var value in group.Values)
{
<option value="@value.Token" selected="@value.IsSelected">
@FacetWords.Value(group.Key, value) (@value.Count)
</option>
}
<optgroup label="only">
@foreach (var value in group.Values)
{
<option value="@value.Token"
selected="@(value.State is FacetState.Included)">
@FacetWords.Value(group.Key, value) (@value.Count)
</option>
}
</optgroup>
<optgroup label="anything but">
@foreach (var value in group.Values)
{
<option value="@Excluded(value).Token"
selected="@(value.State is FacetState.Excluded)">
not @FacetWords.Value(group.Key, value)
(@(group.Total - value.Count))
</option>
}
</optgroup>
</select>
</div>
}
Expand Down Expand Up @@ -88,6 +109,17 @@
</form>

@code {
/// <summary>
/// The same value, spelled as an exclusion — <c>Evennia</c> becomes <c>!Evennia</c>.
/// </summary>
/// <remarks>
/// Built through <see cref="FacetChoice"/> rather than by prefixing a string here, so the
/// panel's spelling and the binding's parser cannot drift: a value that itself begins with the
/// marker is escaped by the same code that unescapes it.
/// </remarks>
private static FacetChoice Excluded(FacetValue value) =>
(value.IsUnknown ? FacetChoice.Unknown : FacetChoice.Of(value.Token)) with { Exclude = true };

[Parameter, EditorRequired] public IReadOnlyList<FacetGroup> Facets { get; set; } = [];

[Parameter, EditorRequired] public GameFilter Filter { get; set; } = new();
Expand Down
4 changes: 2 additions & 2 deletions src/MUI.Web/Components/Pages/Games.razor
Original file line number Diff line number Diff line change
Expand Up @@ -30,14 +30,14 @@ else
{
<FacetPanel Facets="Listing.Facets" Filter="Filter" />

@if (Filter.CodebaseFamily is { } family)
@if (Filter.CodebaseFamily is { Exclude: false, Value: { } family })
{
@* The filter a reference page links in on. It is a filter and not a search, so it says
which family it is showing and offers the way back out — a reader who arrived from
/reference/codebases/pennmush should not have to guess why the listing is short. *@
<p class="kicker">
codebase <span class="mono">@family</span> ·
<a href="/reference/codebases/@family.ToLowerInvariant()">what this codebase is</a> ·
<a href="/reference/codebases/@(family.ToLowerInvariant())">what this codebase is</a> ·
<a href="/games">every game</a>
</p>
}
Expand Down
2 changes: 1 addition & 1 deletion src/MUI.Web/Reference/ReferenceFigures.cs
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ public static async Task<CodebaseFigures> ReadAsync(
ArgumentNullException.ThrowIfNull(queries);

var games = await queries.ListAsync(
new GameFilter { CodebaseFamily = family, IncludeArchived = true },
new GameFilter { CodebaseFamily = FacetChoice.Of(family), IncludeArchived = true },
cancellationToken);

return new CodebaseFigures(
Expand Down
Loading