Skip to content

Decklist Panel

Pablo Martínez López edited this page Oct 21, 2026 · 4 revisions

Decklist Panel

English · Español

The left panel of the deck screen (src/features/decklist/). It owns the text box where the user pastes a card list, the buttons that turn that list into cards (replace or append), the "Detect tokens" dialog, the Archidekt URL import, own-image uploads, and the resolve options (language, image policy, preview quality). Everything heavy (parsing, Scryfall, downloads) runs in Rust; the panel only drives it and paints state.

For the parser itself see Decklist Parsing; for the Scryfall side of resolving see Scryfall Integration.

Main view

Files

File Responsibility
src/features/decklist/decklist-panel.tsx DecklistPanel (layout, buttons, options, confirmations) and PiePanel (footer: running job or summary)
src/features/decklist/editor-lista.tsx EditorLista: the text box with line numbers and red marks on lines Rust could not read
src/features/decklist/acciones.ts Plain functions behind the buttons: resolver, cargarLista, reaplicarIdioma, anadirPropias, subirCartasPropias, subirReverso, reversoGenerico, elegirImagenes, cancelarTrabajo
src/features/decklist/fichas-dialog.tsx FichasDialog: "Detect tokens" review dialog
src/features/decklist/ejemplo.ts EJEMPLO: the sample list behind the "Example" button
src/features/decklist/__tests__/acciones.test.ts Vitest coverage of acciones.ts
src-tauri/src/commands/decklist.rs parse_decklist, deck_list_text, deck_list_save
src-tauri/src/commands/cards.rs resolve_decklist, job_cancel, deck_tokens_detect, import_archidekt

Decision: the button logic lives in acciones.ts, outside the components. Why: the context menu of a card and the drag-and-drop of files onto the preview reuse the same functions (src/features/preview/arrastrar-ficheros.ts imports anadirPropias, EXTENSIONES_IMAGEN from it).

Layout

DecklistPanel is a vertical ResizablePanelGroup (react-resizable-panels, layout persisted in localStorage under proxybuilder.panel-lista) with two panels:

  1. lista: the EditorLista text box. Its height is user-adjustable through the handle.
  2. opciones: a fixed strip (line count, "Load list", "Add", "Add tokens automatically" checkbox, "Detect tokens") and, below it, a shared ScrollArea with three collapsible Seccions: "Import from URL", "Custom images", "Options".

Header buttons: "Sets" (opens the Set Browser via useUi.getState().abrir({ tipo: "sets" })), "Example" (setSinHistorial({ decklistText: EJEMPLO })), "Clear" (empties only the text box; the deck's cards are untouched).

The footer PiePanel shows either the running job (useTrabajo -> trabajo.fase, hechas/total, a Progress bar and a Cancel button, disabled until trabajo.jobId is known) or "N cards · P sheets" (useDeck card count and usePlan plan.sheets), plus a "N marked" count of cards with DeckCard.marca and the last action message (useUi.ultimaAccion, auto-cleared after 8 s).

The text box and live parsing

EditorLista is a plain <textarea> that grows with its content inside the shared ScrollArea (so numbers and text scroll together and the project's scroll rules hold). Line height is a constant LINEA = 20 px shared by the gutter and the textarea.

  • 250 ms after the last keystroke it calls call("parse_decklist", { text }). A vigente flag drops stale responses; a failure is ignored because the next keystroke re-parses.
  • The result (ParsedDecklist: lines, cardCount, invalidCount) goes up through onLeida to the panel, which uses leida.cardCount to enable the load buttons and shows "N cards in the list" / "N lines not understood" in red.
  • Lines with kind === "invalid" get a red gutter number with a tooltip (MOTIVOS: one message per LineError: zeroQuantity, badQuantity, tooMany, emptyName) and a red band behind the text.

parse_decklist (commands/decklist.rs) is a synchronous, cheap #[tauri::command] that just calls proxy_core::decklist::parse. The text itself is deck state: useDeck -> deck.decklistText, written through setSinHistorial (a change that does not enter the undo history but is autosaved; see State Stores).

Flow: "Load list" and "Add"

sequenceDiagram
    participant UI as DecklistPanel
    participant A as acciones.ts
    participant R as Rust commands/cards.rs
    participant S as resolve::resolve
    UI->>A: cargarLista(texto, "reemplazar" | "anadir", t)
    A->>R: parse_decklist(text)
    A->>A: resolver(lista, t)
    A->>R: lanzarTrabajo: resolve_decklist(list, options)
    R-->>A: jobId (spawned task)
    R->>S: resolve(api, EnLaApp, list, options, cancelado, progress)
    S-->>A: event resolve://progress {jobId, progress}
    S-->>A: event resolve://done {jobId, result | error}
    A->>A: deck.aplicar / setCartas(insertar)
Loading

Step by step:

  1. pulsarCargar checks hayCartas && ui.pedirConfirmacionAlCargar; if both are true it opens the Confirmar dialog ("Replace the deck's cards?" with a "Don't ask again" checkbox that flips the pedirConfirmacionAlCargar preference). "Add" never asks.
  2. cargarLista re-parses (parse_decklist) so it never relies on a stale leida. A list with cardCount === 0 announces "The list has no cards" and stops.
  3. resolver refuses to start if useTrabajo.getState().actual is set (it throws { code: "Busy", params: {} }), sets useTrabajo.empezar("resolver", ...), and launches the job with lanzarTrabajo("resolve://progress", "resolve://done", ...) from src/lib/trabajos.ts. The options sent are { lang: ui.lang, imagePolicy: ui.imagePolicy, addTokens: ui.addTokens } (ResolveOptions in src-tauri/src/scryfall/resolve.rs).
  4. lanzarTrabajo subscribes to both events before invoking the command and buffers events that arrive before the job id is known. Why: a short job can finish before invoke returns its id.
  5. Progress events carry ResolvePhase (lookup, language, tokens, images); FASES maps each to a translated label ("Searching cards…", "Searching the language…", "Searching tokens…", "Downloading images…").
  6. On resolve://done: error.code === "Cancelled" -> resolver returns null and cargarLista announces "Cancelled: nothing was changed"; any other error is thrown (toast via mensaje(e)); otherwise the ResolveResult (cards: DeckCard[], unresolved: Unresolved[]) is applied.
  7. Apply: replace -> deck.aplicar(() => ({ cards: r.cards })) and clear the selection (undoable with Ctrl+Z); append -> deck.setCartas((c) => insertar(c, r.cards)) (src/lib/deck-ops.ts, inserts at the end).
  8. avisarNoResueltas builds the warning "Not found: A, B, C, D, E (+N)" (first five names, then a count) from r.unresolved; the panel's intentar wrapper shows any returned string as toast.warning.
  9. The finally in resolver always calls useTrabajo.terminar().

Rust side (resolve_decklist in src-tauri/src/commands/cards.rs): allocates a job (state.jobs.alta() -> (job_id, cancelado)), spawns an async task that holds the busy marker (estado.trabajo()), runs resolve::resolve with EnLaApp (an implementation of the Guardar trait that writes images into the blob store and records card data in the Scryfall cache), emits the two events and deregisters the job (jobs.baja). EnLaApp also provides the English-name lookup for non-English lines (fuentes::nombres_ingleses) and the Cockatrice token catalog fallbacks (externa_por_nombre, externas_creadas_por; see External Sources).

Section mapping inside the resolver: a Sideboard section arrives as MarcaCarta::Banquillo and a Maybeboard section as MarcaCarta::Opcional (src-tauri/src/scryfall/resolve.rs, Section::Sideboard => Some(MarcaCarta::Banquillo)). Marked cards are printed but do not count for deck size, copies or statistics (see Deck Model and Statistics).

Cancel: cancelarTrabajo calls job_cancel { jobId } (commands/cards.rs, returns bool), or export_cancel when the running job is an export. Cancelling leaves the deck untouched.

Options section

Control Store field (useUi) Notes
Card language (IDIOMAS, built from IDIOMAS_SCRYFALL of src/lib/idiomas.ts: en, es, fr, de, it, pt, ja, ko, ru, zhs, zht, each shown by its own name, untranslated) lang Sent as ResolveOptions.lang. If the language does not exist for a card, the resolver falls back to English and marks meta.langFallback (badge "↩ EN").
Images imagePolicy (ImagePolicy: withImage, bestAvailable, highresOnly) Which printing is chosen when the list does not give a set.
Preview quality calidadVista (244, 488, 672, 745 px) Screen only; the PDF always uses export resolution.
Ask before replacing pedirConfirmacionAlCargar Default on.
Add tokens automatically addTokens Default off. Tokens come from the English printing's all_parts.

Reapply language

"Reapply language" -> reaplicarIdioma(t):

  1. listaParaReaplicar(deck.cards) (src/lib/cartas.ts) builds a synthetic ParsedDecklist with one line per copy of every card that is not locked (c.locked), is source.kind === "scryfall" and has a name, using its set and collector number, and returns the parallel list of card ids.
  2. resolver(lista, t, { addTokens: false }) runs the normal job (tokens off so the deck does not grow).
  3. aplicarReaplicado(cartas, ids, r.cards, noResueltas) pairs new cards with old ones in order, skipping lines whose lineNo is in unresolved; the new card takes the old id, edits and locked, and keeps a custom back if the old card had one.
  4. Result: "Language reapplied to N cards".

If nothing qualifies: "There are no unlocked Scryfall cards" and Rust is not called.

Detect tokens (FichasDialog)

Opened by useUi.abrir({ tipo: "fichas" }) ("Detect tokens" button, disabled when busy or the deck is empty). It finds tokens that the cards already in the deck create and that the deck does not yet have, including own cards linked to Scryfall (see Card Identification and OCR).

  1. On open it calls deck_tokens_detect { cards } with the current deck cards. Returns FichaPropuesta[] ({ carta: CardSummary, creadaPor: string }).
  2. Rust (deck_tokens_detect in commands/cards.rs): scryfall::fichas_auto::faltan_en_el_deck collects Scryfall ids from the deck, reads each card from the local cache (cache::leer) and follows the English printings' all_parts to find tokens; it excludes tokens already present by id, by the same token from another collection, or by name (also hand-made own cards). Found tokens are written to the Scryfall cache. Then it adds tokens that Scryfall lacks from the Cockatrice catalog (fuentes::cockatrice::catalogo, creadas_por(name)) unless a card with that name is already in the deck or in the proposals. Network errors from the Cockatrice catalog are swallowed (if let Ok(cat)), so Scryfall results still arrive.
  3. The dialog shows the proposals in a RejillaCartas with all of them pre-selected, plus a list "{token}: created by {card}".
  4. "Add N tokens" calls traerCarta(p.carta) for each selected proposal (see Add Card and Variants) and appends them with insertar(cs, nuevas). Announces "N tokens added".

FichasDialog passes cambiosSinGuardar={false} and refuses to close while adding (anadiendo).

Importing from Archidekt

The "Import from URL" section calls import_archidekt { url, includeSideboard } (commands/cards.rs). It extracts the deck id with archidekt::id_de_url, GETs https://archidekt.com/api/decks/{id}/ with a ProxyBuilder/<version> user agent, maps 404/403 to NotFound and other non-success statuses to Network, then archidekt::desde_json(text, include_sideboard) returns ArchidektImport { text, list }. The panel puts r.text into the text box (setSinHistorial) and announces "List imported from Archidekt (N cards)"; it does not load the cards: the user still presses "Load list". A bad URL is InvalidInput with motivo = "bad_url".

Gotcha: this is the one place where Rust talks to a host directly with reqwest::Client instead of going through fuentes::Externo (see External Sources). The panel text states that Moxfield cannot be imported (copy and paste its list instead).

Custom images

  • "Upload cards": elegirImagenes opens the system dialog through abrirParaImportar (src/lib/dialogos.ts, remembers the last import folder), filters by EXTENSIONES_IMAGEN (png, jpg, jpeg, webp), copies the files into the blob store with blob_import_files { paths } (returns FaceImage[]; commands/library.rs) and creates one custom card per image with cartaPropia(img, nombreDeFichero(ruta)). The card name is derived from the file name by nombreDeFichero (strips folder, extension, leading numbering like 0001 - , T003 - , 12. , and trailing (1) / ~2).
  • anadirPropias(rutas, t, en?) is the same for paths that did not come from the dialog (drag and drop), remembering the folder with recordar("importar", ...) and inserting at position en.
  • "Upload back": the first image becomes deck.defaultBack; "Back to generic back" restores the app's generated back (blob_generic_back).
  • "Link with a card" opens the batch linking dialog (tipo: "vincularLote"); with more than one card selected it limits itself to the selection by default (soloSeleccion). Details in Add Card and Variants and Card Identification and OCR.

Images always go to the blob store (app_data_dir/blobs/), never to the cache; see Storage and Blobs.

Thumbnail cache cleaning

"Clear thumbnail cache" first calls cache_stats and shows a Confirmar with size and file count (megas() formats bytes); on confirm cache_clear deletes app_cache_dir/renders/ (commands/library.rs, vaciar). It can never touch the blob store, so deck images survive.

Exporting the list

The list as text is produced by deck_list_text { deck, format } and saved by deck_list_save { deck, format, path } (commands/decklist.rs; FormatoLista, proxy_core::decklist::exportar). They are driven by the three-dot menu of the export button, see Export. deck_list_save requires an absolute path ending in .txt (else InvalidInput) and writes atomically (escribir_atomico).

Error handling

All handlers are wrapped by intentar, which toasts mensaje(e) (see IPC and Error Handling). Codes the panel can surface: Busy (a job is already running), Network / RateLimited (Scryfall, Archidekt), NotFound (private or unknown Archidekt deck), InvalidInput (bad URL), Cancelled (handled silently as a cancel).

Tests

  • src/features/decklist/__tests__/acciones.test.ts: resolver (options passed, phases, Busy, cancel, error, Rust failing to launch), cargarLista (replace, append, unresolved warning of up to five names, cancel), reaplicarIdioma, cancelarTrabajo (per job type), own images and backs.
  • Rust: src-tauri/src/commands/cards.rs tests (queries, thumbnail registry, traer_carta cases) and the resolver tests in src-tauri/src/scryfall/resolve.rs.

Where to start when extending

  • New button that adds cards: write the function in acciones.ts (so menus and drag-and-drop can reuse it) and call useDeck.getState().setCartas(...).
  • New resolve option: add the field to ResolveOptions in Rust, run pnpm bindings, add the preference to src/stores/ui-store.ts and pass it in resolver.
  • Another long job: reuse lanzarTrabajo (listen before launching) and useTrabajo.

See also

Documentation · Documentación

Language: English · Español

Use and build · Uso y desarrollo

Architecture · Arquitectura

Data · Datos

Getting cards · Conseguir cartas

Layout, images, PDF · Maquetación, imagen y PDF

Card designer · Diseñador de cartas

Game table · Mesa de juego

Frontend · Frontend

Reference · Referencia

Clone this wiki locally