exl100

Émulateur Exelvision EXL 100 (1984) dans le navigateur : dsp-cpp en WebAssembly, cartouches et cassettes BASIC
git clone https://git.schurger.org/exl100.git
Log | Files | Refs | README

commit dc75262734fe3bd87e4f285475ccd10f6dfb5a3c
parent 92da98c03c9de96e218c2a3d063f0ceb1f4fd7bc
Author: Jean Schurger <jean@schurger.org>
Date:   Wed,  4 Nov 2026 17:21:11 -0500

Modale : filtres par catégorie + correctif du défilement

Puces Tous / Cartouches / Cassettes / Jeux / Applications / Éducatif (catégorie extraite
du dossier TOSEC, stockée dans titles.json), combinées au filtre texte. Le défilement de
la modale ne fait plus défiler la page : overscroll-behavior: contain + verrou
body.overflow à l'ouverture, rendu à la fermeture (Échap/croix/fond via l'événement close).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QyJB9yqUAYm3wRQi4uytj9

Diffstat:
Mtools/get_cartridges.py | 14++++++++++++--
Mweb/shell.html.in | 54+++++++++++++++++++++++++++++++++++++++++++-----------
2 files changed, 55 insertions(+), 13 deletions(-)

diff --git a/tools/get_cartridges.py b/tools/get_cartridges.py @@ -50,6 +50,16 @@ def slug_of(title): s = re.sub(r"[^a-z0-9]+", "-", s).strip("-") return s or "cart" +# Catégorie depuis le dossier TOSEC (Games / Applications / Educational). +def cat_of(root): + if "Games" in root: + return "jeu" + if "Educational" in root: + return "educatif" + if "Applications" in root: + return "application" + return "" + def main(): rar = os.path.join(tempfile.gettempdir(), "exl100-tosec.rar") if not os.path.exists(rar) or os.path.getsize(rar) < 1_000_000: @@ -78,7 +88,7 @@ def main(): out = f"{slug}.bin" with open(os.path.join(OUT, out), "wb") as f: f.write(data) - titles[out] = {"t": title, "by": by, "y": year} + titles[out] = {"t": title, "by": by, "y": year, "cat": cat_of(root)} print(f" {out:22s} {len(data):6d} o crc={zlib.crc32(data) & 0xffffffff:08x} {title} — {by} {year}", file=sys.stderr) with open(os.path.join(OUT, "titles.json"), "w", encoding="utf-8") as f: @@ -119,7 +129,7 @@ def extract_tapes(tmp, limit=120): continue with open(os.path.join(TAPES_OUT, out), "wb") as f: f.write(data) - titles[out] = {"t": title, "by": by, "y": year} + titles[out] = {"t": title, "by": by, "y": year, "cat": cat_of(root)} if len(titles) >= limit: break if len(titles) >= limit: diff --git a/web/shell.html.in b/web/shell.html.in @@ -72,7 +72,14 @@ .modal-head .x:hover { color: #fff; } .modal-src { margin: 0; padding: 9px 14px; border-top: 1px solid #2a2c33; background: #1b1d22; color: var(--muted); font-size: .8rem; } - .programs { max-height: 60vh; overflow-y: auto; background: #17181c; } + .chips { display: flex; flex-wrap: wrap; gap: 6px; padding: 10px 14px; border-bottom: 1px solid #2a2c33; } + .chip { background: #2b2d35; color: var(--muted); border: 1px solid #40434e; border-radius: 999px; + padding: 4px 12px; font: inherit; font-size: .82rem; cursor: pointer; } + .chip:hover { background: #363946; color: #fff; } + .chip.active { background: var(--cyan); color: #06282a; border-color: var(--cyan); font-weight: 600; } + /* Le défilement reste dans la liste (pas de chaînage vers la page) */ + .programs { max-height: 56vh; overflow-y: auto; overscroll-behavior: contain; background: #17181c; } + .modal { overscroll-behavior: contain; } .pg-group { position: sticky; top: 0; background: #1b1d22; color: var(--cyan); font-size: .78rem; letter-spacing: .04em; text-transform: uppercase; padding: 6px 12px; border-bottom: 1px solid #2a2c33; } .pg { display: flex; justify-content: space-between; align-items: baseline; gap: 12px; width: 100%; @@ -141,6 +148,14 @@ <input id="filter" type="search" placeholder="Filtrer…" autocomplete="off"> <button id="modal-close" type="button" class="x" aria-label="Fermer">✕</button> </div> + <div id="chips" class="chips"> + <button class="chip active" type="button" data-f="all">Tous</button> + <button class="chip" type="button" data-f="cart">Cartouches</button> + <button class="chip" type="button" data-f="tape">Cassettes</button> + <button class="chip" type="button" data-f="jeu">Jeux</button> + <button class="chip" type="button" data-f="application">Applications</button> + <button class="chip" type="button" data-f="educatif">Éducatif</button> + </div> <div id="programs" class="programs" role="listbox" aria-label="Programmes"></div> <p class="modal-src">Contenu préservé par le pack <b>TOSEC</b> sur <a href="https://archive.org/details/pack-roms-exelvision-exl-100">archive.org</a> ; chaque entrée @@ -305,7 +320,8 @@ // (éditeur, année) lue dans titles.json. On relance l'EXL 100 avec le programme choisi (ou le logo). const programsEl = $('programs'); const modal = $('modal'); - $('pick').onclick = () => { modal.showModal(); $('filter').focus(); }; + $('pick').onclick = () => { document.body.style.overflow = 'hidden'; modal.showModal(); $('filter').focus(); }; + modal.addEventListener('close', () => { document.body.style.overflow = ''; }); // Échap/croix/fond : rend le défilement $('modal-close').onclick = () => modal.close(); modal.addEventListener('click', (e) => { if (e.target === modal) modal.close(); }); // clic sur le fond ferme let bootToken = 0; // annule une frappe automatique en cours si on change de programme @@ -369,10 +385,12 @@ if (m.y) p.push(m.y); return p.length ? p.join(', ') : 'source inconnue'; } - function addRow(value, title, source) { + function addRow(value, title, source, cat) { const b = document.createElement('button'); b.className = 'pg'; b.dataset.value = value; + b.dataset.cat = cat || ''; + b.dataset.type = value.startsWith('cart:') ? 'cart' : value.startsWith('tape:') ? 'tape' : ''; b.dataset.search = (title + ' ' + source).toLowerCase(); const t = document.createElement('span'); t.className = 'pg-t'; t.textContent = title; const s = document.createElement('span'); s.className = 'pg-s'; s.textContent = source; @@ -387,26 +405,40 @@ if (!entries.length) return; const h = document.createElement('div'); h.className = 'pg-group'; h.textContent = label; programsEl.appendChild(h); - for (const [file, m] of entries) addRow(prefix + file, m.t, sourceOf(m)); + for (const [file, m] of entries) addRow(prefix + file, m.t, sourceOf(m), m.cat); } function populatePrograms() { programsEl.innerHTML = ''; - addRow('', 'Logo Exelvision', 'aucune cartouche'); + addRow('', 'Logo Exelvision', 'aucune cartouche', ''); addGroup('Cartouches', '/roms/cartridges/titles.json', 'cart:'); addGroup('Cassettes BASIC', '/roms/tapes/titles.json', 'tape:'); } - // Filtre texte : masque les lignes qui ne correspondent pas, et les en-têtes vides. - $('filter').oninput = (e) => { - const q = e.target.value.trim().toLowerCase(); - for (const el of programsEl.querySelectorAll('.pg')) - el.classList.toggle('hide', q !== '' && !el.dataset.search.includes(q)); + + // Filtres combinés : texte (titre/source) + une puce (type ou catégorie), et masquage des en-têtes vides. + let chip = 'all'; + function applyFilter() { + const q = $('filter').value.trim().toLowerCase(); + for (const el of programsEl.querySelectorAll('.pg')) { + const okText = q === '' || el.dataset.search.includes(q); + const okChip = chip === 'all' || + (chip === 'cart' || chip === 'tape' ? el.dataset.type === chip : el.dataset.cat === chip); + el.classList.toggle('hide', !(okText && okChip)); + } let group = null, any = false; for (const el of programsEl.children) { if (el.classList.contains('pg-group')) { if (group) group.classList.toggle('hide', !any); group = el; any = false; } else if (!el.classList.contains('hide')) any = true; } if (group) group.classList.toggle('hide', !any); - }; + } + $('filter').oninput = applyFilter; + for (const c of $('chips').querySelectorAll('.chip')) { + c.onclick = () => { + for (const o of $('chips').querySelectorAll('.chip')) o.classList.toggle('active', o === c); + chip = c.dataset.f; + applyFilter(); + }; + } const Module = window.Module = { canvas: canvas,