From dfba2403a85839b31a74194a25ba006612684467 Mon Sep 17 00:00:00 2001 From: Garrett Mills Date: Mon, 10 Aug 2026 22:34:46 -0500 Subject: [PATCH] Add colorless filters, and make set listings filterable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Colorless There was no way to ask for colorless cards — the colour toggles are a 5-bit WUBRG mask where 0 already means "don't filter by colour", leaving nothing to express "no colours at all". That's exactly what you need to find a colorless commander's card pool. Adds a "C" pip to both colour rows, matching how ColorPips already renders colorless cards, backed by two explicit booleans rather than a sixth bit — a filter mask and a card's real colour mask staying the same shape is worth keeping. The two rows mean different things and both are useful: on the commander row C filters colour *identity*, which is the Commander rule; on the includes row it filters the card's own colours. A card printed colorless can still carry a coloured identity through mana symbols in its text, and it's the identity that bars it from a colorless commander's deck. Set listings News > Releases had its own card path and so couldn't be filtered. A new set_code filter (matched against printings.set_code, already ingested) lets the set browser run through search_reference instead, so every criterion works there identically rather than being reimplemented over a live Scryfall response. That alone would have cost the ability to browse an upcoming set, since search_reference only knows downloaded card data — and upcoming sets are half the point of the tab. So the browser picks a source per set: downloaded data where it exists, with the full filter panel; otherwise Scryfall directly, with the panel hidden and a line explaining why. A panel that silently does nothing would be worse than no panel. The source is decided by an *unfiltered* probe. Keying off whether the current criteria match anything would flip a properly indexed set to "live" the moment a filter excluded everything, dropping the panel exactly when it was in use. --- crates/mtgc-core/src/filters.rs | 118 ++++++++++++++++++++++++ crates/mtgc-core/src/model.rs | 14 +++ src/api.ts | 8 ++ src/components.tsx | 20 +++++ src/views/CardSearch.tsx | 25 +++++- src/views/LibraryBrowser.tsx | 23 ++++- src/views/NewsView.tsx | 154 +++++++++++++++++++++++++++++--- 7 files changed, 345 insertions(+), 17 deletions(-) diff --git a/crates/mtgc-core/src/filters.rs b/crates/mtgc-core/src/filters.rs index 8344012..12dfc8f 100644 --- a/crates/mtgc-core/src/filters.rs +++ b/crates/mtgc-core/src/filters.rs @@ -84,6 +84,19 @@ fn push_card_filters( sql.push_str(" AND oc.name_lower LIKE ?"); vals.push(Value::Text(format!("%{}%", text.to_lowercase()))); } + if let Some(code) = filter.set_code.as_ref().filter(|c| !c.is_empty()) { + sql.push_str(&format!( + " AND EXISTS (SELECT 1 FROM {REF_DB}.printings p + WHERE p.oracle_id = oc.oracle_id AND p.set_code = ? COLLATE NOCASE)" + )); + vals.push(Value::Text(code.clone())); + } + if filter.colorless_identity { + sql.push_str(" AND oc.color_identity = 0"); + } + if filter.colorless_colors { + sql.push_str(" AND oc.colors = 0"); + } if let Some(c) = filter.colors_exact { sql.push_str(" AND oc.colors = ?"); vals.push(Value::Integer(c.0 as i64)); @@ -303,6 +316,111 @@ mod tests { assert!(all.contains(&"Serra Angel".to_string())); } + #[test] + fn a_set_filter_narrows_to_that_set_and_composes_with_the_rest() { + let fx = owned_fixture(); + let conn = &fx.catalog.conn; + // Fixture cards have no printings; give two of them one. + for (oid, set) in [("serra-angel", "m19"), ("lightning-bolt", "lea")] { + conn.execute( + "INSERT INTO ref.printings(scryfall_id, oracle_id, set_code) VALUES (?1, ?2, ?3)", + rusqlite::params![format!("p-{oid}"), oid, set], + ) + .unwrap(); + } + + let in_m19 = names( + &fx.catalog + .search_library(&LibraryFilter { + set_code: Some("m19".into()), + ..Default::default() + }) + .unwrap(), + ); + assert_eq!(in_m19, vec!["Serra Angel".to_string()]); + + // Set codes are case-insensitive: the UI shows them uppercased. + assert_eq!( + fx.catalog + .search_library(&LibraryFilter { set_code: Some("M19".into()), ..Default::default() }) + .unwrap() + .len(), + 1 + ); + + // And it composes with other criteria rather than overriding them. + let none = fx + .catalog + .search_library(&LibraryFilter { + set_code: Some("m19".into()), + colorless_identity: true, + ..Default::default() + }) + .unwrap(); + assert!(none.is_empty(), "Serra Angel is white, so the two together match nothing"); + } + + #[test] + fn colorless_filters_are_distinct_from_an_empty_colour_selection() { + let fx = owned_fixture(); + // Sol Ring is the only colourless fixture card. + let identity = names( + &fx.catalog + .search_library(&LibraryFilter { + colorless_identity: true, + ..Default::default() + }) + .unwrap(), + ); + assert_eq!(identity, vec!["Sol Ring".to_string()]); + + let colors = names( + &fx.catalog + .search_library(&LibraryFilter { + colorless_colors: true, + ..Default::default() + }) + .unwrap(), + ); + assert_eq!(colors, vec!["Sol Ring".to_string()]); + + // Selecting no colours at all still means "don't filter by colour" — + // that's the ambiguity these fields exist to resolve. + let unfiltered = fx.catalog.search_library(&LibraryFilter::default()).unwrap(); + assert_eq!(unfiltered.len(), 5); + } + + #[test] + fn a_colorless_identity_still_admits_a_card_whose_own_colors_differ() { + let fx = owned_fixture(); + // Contrived but real: a card printed colourless that carries a coloured + // identity (e.g. a hybrid ability). Identity is what Commander polices. + fx.catalog + .conn + .execute( + "UPDATE ref.oracle_cards SET colors = 0 WHERE oracle_id = 'lightning-bolt'", + [], + ) + .unwrap(); + + let by_colors = names( + &fx.catalog + .search_library(&LibraryFilter { colorless_colors: true, ..Default::default() }) + .unwrap(), + ); + assert!(by_colors.contains(&"Lightning Bolt".to_string())); + + let by_identity = names( + &fx.catalog + .search_library(&LibraryFilter { colorless_identity: true, ..Default::default() }) + .unwrap(), + ); + assert!( + !by_identity.contains(&"Lightning Bolt".to_string()), + "its red identity still bars it from a colourless commander" + ); + } + #[test] fn price_range_treats_an_unknown_price_as_free() { let fx = owned_fixture(); diff --git a/crates/mtgc-core/src/model.rs b/crates/mtgc-core/src/model.rs index 5a95397..1b12d4a 100644 --- a/crates/mtgc-core/src/model.rs +++ b/crates/mtgc-core/src/model.rs @@ -314,6 +314,20 @@ pub struct LibraryFilter { /// Only cards that can head a Commander deck. #[serde(default)] pub can_be_commander: bool, + /// Only cards with an empty colour identity — what a colourless commander + /// can actually play. The colour bitmasks can't express this: 0 there means + /// "no filter", not "no colours". + #[serde(default)] + pub colorless_identity: bool, + /// Only cards printed in this set (matched against `printings.set_code`). + /// Lets the release browser reuse the whole criteria pipeline instead of + /// reimplementing it over a live Scryfall response. + pub set_code: Option, + /// Only cards that are themselves colourless (`colors` empty). Distinct + /// from `colorless_identity`: a card can be colourless yet carry a coloured + /// identity through mana symbols in its rules text. + #[serde(default)] + pub colorless_colors: bool, /// Printed power/toughness range. Cards whose printed value isn't a plain /// number ("*", "1+*") are excluded from these, since they can't be /// compared — see `oracle_cards.power`. diff --git a/src/api.ts b/src/api.ts index ee7d13c..751fd97 100644 --- a/src/api.ts +++ b/src/api.ts @@ -110,6 +110,14 @@ export interface LibraryFilter { legal_in?: string | null; /** Only cards that can head a Commander deck. */ can_be_commander?: boolean; + /** Only cards printed in this set (matched against printings.set_code). + * Lets the release browser reuse the whole criteria pipeline. */ + set_code?: string | null; + /** Only cards with an empty color identity — a colorless commander's pool. + * Separate from the color bitmasks, where 0 means "no filter". */ + colorless_identity?: boolean; + /** Only cards that are themselves colorless. */ + colorless_colors?: boolean; /** Printed power/toughness. Cards printed with "*" or "1+*" are excluded, * since those can't be compared numerically. */ power_min?: number | null; diff --git a/src/components.tsx b/src/components.tsx index 5befb66..75427c3 100644 --- a/src/components.tsx +++ b/src/components.tsx @@ -1376,9 +1376,20 @@ export function CardTile({ export function ColorToggles({ value, onChange, + colorless, + onColorlessChange, + colorlessTitle, }: { value: number; onChange: (mask: number) => void; + /** Renders a "C" pip alongside the colours when provided. + * + * Colourlessness can't live in the bitmask: 0 there already means "don't + * filter by colour", so there'd be no way to say "no colours at all" — which + * is exactly what you need to find a colourless commander's card pool. */ + colorless?: boolean; + onColorlessChange?: (v: boolean) => void; + colorlessTitle?: string; }) { return ( @@ -1392,6 +1403,15 @@ export function ColorToggles({ {c.code} ))} + {onColorlessChange && ( + onColorlessChange(!colorless)} + > + C + + )} ); } diff --git a/src/views/CardSearch.tsx b/src/views/CardSearch.tsx index 9377c5d..7494e95 100644 --- a/src/views/CardSearch.tsx +++ b/src/views/CardSearch.tsx @@ -48,6 +48,9 @@ export function CardSearch({ const [text, setText] = useState(""); const [commander, setCommander] = useState(0); const [colorsInclude, setColorsInclude] = useState(0); + // Colourlessness can't ride in the bitmask — 0 there means "no filter". + const [colorlessIdentity, setColorlessIdentity] = useState(false); + const [colorlessColors, setColorlessColors] = useState(false); const [cmcMin, setCmcMin] = useState(""); const [cmcMax, setCmcMax] = useState(""); const [types, setTypes] = useState([]); @@ -89,6 +92,8 @@ export function CardSearch({ text.trim() !== "" || commander !== 0 || colorsInclude !== 0 || + colorlessIdentity || + colorlessColors || cmcMin !== "" || cmcMax !== "" || types.length > 0 || @@ -107,6 +112,8 @@ export function CardSearch({ text: text.trim() || null, commander_identity: commander || null, colors_include: colorsInclude || null, + colorless_identity: colorlessIdentity, + colorless_colors: colorlessColors, cmc_min: cmcMin === "" ? null : Number(cmcMin), cmc_max: cmcMax === "" ? null : Number(cmcMax), types, @@ -133,6 +140,8 @@ export function CardSearch({ text, commander, colorsInclude, + colorlessIdentity, + colorlessColors, cmcMin, cmcMax, types, @@ -454,11 +463,23 @@ export function CardSearch({
- +
- +
diff --git a/src/views/LibraryBrowser.tsx b/src/views/LibraryBrowser.tsx index 0897ae4..feec117 100644 --- a/src/views/LibraryBrowser.tsx +++ b/src/views/LibraryBrowser.tsx @@ -49,6 +49,9 @@ export function LibraryBrowser({ const [text, setText] = useState(""); const [commander, setCommander] = useState(0); const [colorsInclude, setColorsInclude] = useState(0); + // Colourlessness can't ride in the bitmask — 0 there means "no filter". + const [colorlessIdentity, setColorlessIdentity] = useState(false); + const [colorlessColors, setColorlessColors] = useState(false); const [cmcMin, setCmcMin] = useState(""); const [cmcMax, setCmcMax] = useState(""); const [types, setTypes] = useState([]); @@ -119,6 +122,8 @@ export function LibraryBrowser({ text: text.trim() || null, commander_identity: commander || null, colors_include: colorsInclude || null, + colorless_identity: colorlessIdentity, + colorless_colors: colorlessColors, cmc_min: cmcMin === "" ? null : Number(cmcMin), cmc_max: cmcMax === "" ? null : Number(cmcMax), types, @@ -137,6 +142,8 @@ export function LibraryBrowser({ text, commander, colorsInclude, + colorlessIdentity, + colorlessColors, cmcMin, cmcMax, types, @@ -538,11 +545,23 @@ export function LibraryBrowser({
- +
- +
diff --git a/src/views/NewsView.tsx b/src/views/NewsView.tsx index 9994af8..4fff279 100644 --- a/src/views/NewsView.tsx +++ b/src/views/NewsView.tsx @@ -1,7 +1,14 @@ import { useCallback, useEffect, useState } from "react"; import { openUrl } from "@tauri-apps/plugin-opener"; -import { api, News, SetCard, SetInfo } from "../api"; -import { CardTile, QtyStepper } from "../components"; +import { api, LibraryCard, LibraryFilter, News, SetCard, SetInfo } from "../api"; +import { + CardCriteriaFields, + CardTile, + ColorToggles, + QtyStepper, + applyCardCriteria, + useCardCriteria, +} from "../components"; /** Sets whose release date is still ahead of us, newest-announced first. */ function splitByRelease(sets: SetInfo[]): { upcoming: SetInfo[]; released: SetInfo[] } { @@ -59,8 +66,21 @@ export function NewsView() { const [showAll, setShowAll] = useState(false); const [openSet, setOpenSet] = useState(null); - const [cards, setCards] = useState(null); + const [cards, setCards] = useState<(LibraryCard | SetCard)[] | null>(null); + // Where a set's cards come from. "local" reads downloaded card data, which is + // what makes the criteria filters possible; "live" queries Scryfall directly + // for a set that isn't in reference.db yet — an upcoming or newly-released + // one — which can't be filtered, so the panel is hidden rather than shown + // doing nothing. Null while we work out which. + const [source, setSource] = useState<"local" | "live" | null>(null); const [cardsError, setCardsError] = useState(null); + // The same criteria the Library and Card Search panels use — the set browser + // runs through search_reference rather than its own path, so every filter + // works here identically instead of being reimplemented. + const [setText, setSetText] = useState(""); + const [setColors, setSetColors] = useState(0); + const [setColorless, setSetColorless] = useState(false); + const [setCriteria, patchSetCriteria] = useCardCriteria("news-set-criteria"); const load = useCallback(async (refresh: boolean) => { setBusy(true); @@ -78,17 +98,83 @@ export function NewsView() { load(false); }, [load]); - async function chooseSet(set: SetInfo) { + function openSetBrowser(set: SetInfo) { setOpenSet(set); setCards(null); setCardsError(null); - try { - setCards(await api.getSetCards(set.code)); - } catch (err) { - setCardsError(String(err)); - } + setSource(null); } + // Decide the source once per set, with an *unfiltered* probe: asking whether + // the current criteria match anything would flip a perfectly indexed set to + // "live" the moment a filter excluded everything. + useEffect(() => { + if (!openSet) return; + let cancelled = false; + setSource(null); + api + .searchReference({ set_code: openSet.code }, 1) + .then((r) => !cancelled && setSource(r.length > 0 ? "local" : "live")) + .catch(() => !cancelled && setSource("live")); + return () => { + cancelled = true; + }; + }, [openSet]); + + // Not in the downloaded card data: fetch the set straight from Scryfall so an + // upcoming set is still browsable, just without filters. + useEffect(() => { + if (!openSet || source !== "live") return; + let cancelled = false; + api + .getSetCards(openSet.code) + .then((r) => { + if (cancelled) return; + setCards(r); + setCardsError(null); + }) + .catch((e) => { + if (cancelled) return; + setCards([]); + setCardsError(String(e)); + }); + return () => { + cancelled = true; + }; + }, [openSet, source]); + + // Refetch whenever the set or any criterion changes, debounced like the other + // filter panels so typing doesn't fire a query per keystroke. + useEffect(() => { + if (!openSet || source !== "local") return; + const filter: LibraryFilter = { + set_code: openSet.code, + text: setText.trim() || null, + colors_include: setColors || null, + colorless_colors: setColorless, + ...applyCardCriteria(setCriteria), + }; + let cancelled = false; + const t = setTimeout(() => { + api + .searchReference(filter, 500) + .then((r) => { + if (cancelled) return; + setCards(r); + setCardsError(null); + }) + .catch((e) => { + if (cancelled) return; + setCards([]); + setCardsError(String(e)); + }); + }, 120); + return () => { + cancelled = true; + clearTimeout(t); + }; + }, [openSet, source, setText, setColors, setColorless, setCriteria]); + async function adjust(oracleId: string, delta: number) { const qty = await api.addOwned(oracleId, delta); setCards((cs) => @@ -113,17 +199,59 @@ export function NewsView() { {formatDate(openSet.released_at)} · {openSet.set_type.replace(/_/g, " ")} · set code{" "} {openSet.code.toUpperCase()}
+ + {/* Filters run against downloaded card data, so they only exist in + "local" mode. Showing a panel that silently does nothing would be + worse than not showing one. */} + {source === "local" && ( +
+
+
+ + setSetText(e.target.value)} + placeholder="e.g. bolt" + /> +
+
+ + +
+ +
+
+ )} + + {source === "live" && ( +

+ This set isn't in your downloaded card data yet, so it's being read + straight from Scryfall — filters aren't available here. Run{" "} + Update card data once it's out to filter it like the + rest. +

+ )} {cardsError ? (

{cardsError}

- ) : !cards ? ( + ) : !cards || source === null ? (

Loading cards…

) : cards.length === 0 ? ( -

No cards listed for this set yet.

+

+ {source === "local" + ? "No cards match these filters." + : "No cards listed for this set yet."} +

) : (
{cards.map((c) => ( chooseSet(s)} + onClick={() => openSetBrowser(s)} > {/* Fixed-width date gutter: every set name then starts at the same x, which is what turns this from a list into a timeline. */}