commit 796b91dd4c63661b937b21b1576816086c86e573
parent 995e15de01d77f234fd83e900c96f7f1b8ecb7f4
Author: Jean Schurger <jean@schurger.org>
Date: Wed, 4 Nov 2026 17:09:14 -0500
Liste de programmes avec source (éditeur, année) au lieu d'un menu déroulant
tools/get_cartridges.py extrait l'éditeur et l'année du nom TOSEC (titles.json devient
{t,by,y}). La page remplace le <select> par une liste cliquable, groupée (Cartouches /
Cassettes BASIC), chaque entrée montrant son titre et sa source, avec un filtre texte et
la surbrillance de l'actif. 107 entrées.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QyJB9yqUAYm3wRQi4uytj9
Diffstat:
2 files changed, 100 insertions(+), 26 deletions(-)
diff --git a/tools/get_cartridges.py b/tools/get_cartridges.py
@@ -24,12 +24,27 @@ TOSEC_URL = ("https://ia600708.us.archive.org/6/items/pack-roms-exelvision-exl-1
HERE = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
OUT = os.path.join(HERE, "roms", "cartridges")
-# Un titre lisible depuis un nom TOSEC « ExelBASIC+ (198x)(Exelvision)(FR) » -> « ExelBASIC+ ».
+# Titre, éditeur et année depuis un nom TOSEC « Tennis (198x)(Exelvision)(FR) ».
+# Le 1er groupe (...) est l'année, le 2e l'éditeur, le reste le pays/flags.
def title_of(name):
base = os.path.splitext(os.path.basename(name))[0]
base = re.split(r"\s*\(", base, maxsplit=1)[0].strip()
return base or os.path.splitext(os.path.basename(name))[0]
+def meta_of(name):
+ base = os.path.splitext(os.path.basename(name))[0]
+ title = title_of(name)
+ year, by = "", ""
+ for g in re.findall(r"\(([^)]*)\)", base):
+ g = g.strip()
+ if not g or g == "-":
+ continue
+ if not year and re.match(r"(19|20)[0-9x]{2}", g):
+ year = g
+ elif not by and g.lower() not in ("fr", "us", "eu", "en", "de", "es") and not re.fullmatch(r"[A-Za-z]{2}", g):
+ by = g
+ return title, by, year
+
def slug_of(title):
s = title.lower().replace("+", "-plus").replace("é", "e").replace("è", "e")
s = re.sub(r"[^a-z0-9]+", "-", s).strip("-")
@@ -58,16 +73,16 @@ def main():
if not members:
continue
data = z.read(members[0])
- title = title_of(fn)
+ title, by, year = meta_of(fn)
slug = slug_of(title)
out = f"{slug}.bin"
with open(os.path.join(OUT, out), "wb") as f:
f.write(data)
- titles[out] = title
- print(f" {out:22s} {len(data):6d} o crc={zlib.crc32(data) & 0xffffffff:08x} {title}",
+ titles[out] = {"t": title, "by": by, "y": year}
+ 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:
- json.dump(dict(sorted(titles.items(), key=lambda kv: kv[1].lower())), f,
+ json.dump(dict(sorted(titles.items(), key=lambda kv: kv[1]["t"].lower())), f,
ensure_ascii=False, indent=1)
print(f"{len(titles)} cartouches dans {OUT}", file=sys.stderr)
extract_tapes(tmp)
@@ -97,20 +112,20 @@ def extract_tapes(tmp, limit=120):
data = z.read(members[0])
if not data:
continue
- title = title_of(fn)
+ title, by, year = meta_of(fn)
slug = slug_of(title)
out = f"{slug}.k7"
if out in titles: # doublon de titre : on garde le premier
continue
with open(os.path.join(TAPES_OUT, out), "wb") as f:
f.write(data)
- titles[out] = title
+ titles[out] = {"t": title, "by": by, "y": year}
if len(titles) >= limit:
break
if len(titles) >= limit:
break
with open(os.path.join(TAPES_OUT, "titles.json"), "w", encoding="utf-8") as f:
- json.dump(dict(sorted(titles.items(), key=lambda kv: kv[1].lower())), f,
+ json.dump(dict(sorted(titles.items(), key=lambda kv: kv[1]["t"].lower())), f,
ensure_ascii=False, indent=1)
print(f"{len(titles)} cassettes dans {TAPES_OUT}", file=sys.stderr)
diff --git a/web/shell.html.in b/web/shell.html.in
@@ -55,6 +55,27 @@
.toolbar input[type=file] { max-width: 230px; }
.hidden { display: none !important; }
+ /* Liste des programmes (avec leur source), à la place d'un menu déroulant */
+ .picker { max-width: 900px; margin: 0 auto 16px; }
+ .picker-head { display: flex; justify-content: space-between; align-items: baseline; gap: 12px;
+ flex-wrap: wrap; margin: 0 0 6px; color: var(--muted); font-size: .9rem; }
+ .picker-head small { color: #7e7b74; }
+ #filter { background: #2b2d35; color: #fff; border: 1px solid #40434e; border-radius: 6px;
+ padding: 6px 10px; font: inherit; font-size: .9rem; min-width: 180px; }
+ .programs { max-height: 260px; overflow-y: auto; border: 1px solid #2a2c33; border-radius: 8px;
+ background: #17181c; }
+ .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%;
+ text-align: left; background: none; border: 0; border-bottom: 1px solid #202228; color: var(--fg);
+ padding: 7px 12px; font: inherit; cursor: pointer; }
+ .pg:hover { background: #22242b; }
+ .pg.active { background: var(--accent); color: #fff; }
+ .pg.active .pg-s { color: #ffd6e2; }
+ .pg-t { font-size: .95rem; }
+ .pg-s { color: var(--muted); font-size: .8rem; white-space: nowrap; flex-shrink: 0; }
+ .pg.hide, .pg-group.hide { display: none; }
+
/* Journal (replié) */
#output { max-width: 900px; margin: 8px auto 0; white-space: pre-wrap; color: #7e7b74;
font: 11px ui-monospace, monospace; max-height: 16vh; overflow: auto; }
@@ -90,10 +111,7 @@
</label>
<label class="hidden">Driver <select id="driver"><option value="">…</option></select></label>
<label class="hidden">ROM <input id="rom" type="file"></label>
- <label>Programme
- <select id="program"><option value="">— Logo Exelvision (aucune cartouche) —</option></select>
- </label>
- <label>Charger un fichier
+ <label>Charger votre fichier
<select id="mediatype">
<option value="--tape">cartouche / cassette</option>
<option value="--cart">cartouche (--cart)</option>
@@ -106,6 +124,14 @@
<details id="more" class="hidden"><summary>options</summary></details>
</div>
+ <section class="picker">
+ <div class="picker-head">
+ <span>Programmes <small>cartouches et cassettes BASIC, avec leur source</small></span>
+ <input id="filter" type="search" placeholder="Filtrer…" autocomplete="off">
+ </div>
+ <div id="programs" class="programs" role="listbox" aria-label="Programmes"></div>
+ </section>
+
<section class="screen">
<div class="bezel"><canvas id="canvas" tabindex="0" oncontextmenu="event.preventDefault()"></canvas></div>
<div id="status">Chargement…</div>
@@ -122,7 +148,8 @@
<p>Au lancement, la machine affiche le papillon arc-en-ciel et le logo <b>exelvision</b> sur fond cyan,
exactement comme un EXL 100 à la mise sous tension. L'EXL 100 n'a pas de BASIC intégré : comme sur la
vraie machine, tout vient d'une <b>cartouche</b> ou d'une <b>cassette</b>.</p>
- <p>Choisissez un titre dans le menu <b>Programme</b>. Les <b>Cartouches</b> (ExelBASIC, Tennis, Guppy,
+ <p>Cliquez un titre dans la <b>liste des programmes</b> (chaque entrée indique sa source : éditeur
+ et année) ; un filtre aide à s'y retrouver. Les <b>Cartouches</b> (ExelBASIC, Tennis, Guppy,
Virus, Exelpaint…) démarrent seules. Les <b>Cassettes (BASIC)</b> — une cinquantaine de jeux et
programmes — <b>se chargent toutes seules</b> : la page lance ExelBASIC puis tape
<code>LOAD "1"</code>, <kbd>Échap</kbd> et <code>RUN</code> pour vous ; patientez quelques secondes.
@@ -259,14 +286,18 @@
$('start').onclick = () => startSelected(true);
canvas.onclick = () => canvas.focus();
- // Menu « Programme » : cartouches embarquées dans dsp.data (/roms/cartridges/), lues par leur
- // titre dans titles.json. On relance l'EXL 100 avec la cartouche choisie (ou le logo seul).
- const programSel = $('program');
+ // Liste « Programmes » : cartouches et cassettes embarquées dans dsp.data, chacune avec sa source
+ // (éditeur, année) lue dans titles.json. On relance l'EXL 100 avec le programme choisi (ou le logo).
+ const programsEl = $('programs');
let bootToken = 0; // annule une frappe automatique en cours si on change de programme
+ function markActive(value) {
+ for (const el of programsEl.querySelectorAll('.pg')) el.classList.toggle('active', el.dataset.value === value);
+ }
// Cartouche (cart:) = autonome. Cassette BASIC (tape:) = on charge ExelBASIC puis la cassette,
// et la page tape elle-même la séquence de chargement de l'EXL 100 (LOAD"1", Échap, RUN).
function bootProgram(value) {
const token = ++bootToken;
+ markActive(value);
const args = ['--game', 'exl100'];
let cassette = false;
if (value.startsWith('cart:')) {
@@ -279,7 +310,6 @@
launch(args);
if (cassette) autoLoadCassette(token);
}
- programSel.onchange = () => bootProgram(programSel.value);
// Tape LOAD"1" ⏎, Échap, puis RUN ⏎ dans ExelBASIC, en événements clavier synthétiques sur le
// canevas (SDL écoute #canvas). Le " de l'EXL 100 = Shift+3 ; les modificateurs sont de vraies
@@ -307,22 +337,50 @@
await tap('Enter', 'Enter');
if (alive()) statusEl.textContent = 'Si rien ne démarre : au curseur, tapez LOAD "1" (Entrée), Échap, puis RUN.';
}
+ function sourceOf(m) {
+ const p = [];
+ if (m.by) p.push(m.by);
+ if (m.y) p.push(m.y);
+ return p.length ? p.join(', ') : 'source inconnue';
+ }
+ function addRow(value, title, source) {
+ const b = document.createElement('button');
+ b.className = 'pg';
+ b.dataset.value = value;
+ 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;
+ b.append(t, s);
+ b.onclick = () => bootProgram(value);
+ programsEl.appendChild(b);
+ }
function addGroup(label, path, prefix) {
let titles;
try { titles = JSON.parse(fs().readFile(path, { encoding: 'utf8' })); } catch (e) { return; }
- const g = document.createElement('optgroup');
- g.label = label;
- for (const [file, title] of Object.entries(titles).sort((a, b) => a[1].localeCompare(b[1], 'fr'))) {
- const opt = document.createElement('option');
- opt.value = prefix + file; opt.textContent = title;
- g.appendChild(opt);
- }
- if (g.children.length) programSel.appendChild(g);
+ const entries = Object.entries(titles).sort((a, b) => a[1].t.localeCompare(b[1].t, 'fr'));
+ 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));
}
function populatePrograms() {
+ programsEl.innerHTML = '';
+ addRow('', 'Logo Exelvision', 'aucune cartouche');
addGroup('Cartouches', '/roms/cartridges/titles.json', 'cart:');
- addGroup('Cassettes (BASIC)', '/roms/tapes/titles.json', 'tape:');
+ 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));
+ 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);
+ };
const Module = window.Module = {
canvas: canvas,
@@ -345,6 +403,7 @@
fillDrivers(d ? d.name : '');
statusEl.textContent = drivers.length + ' drivers';
populatePrograms();
+ markActive(''); // le démarrage par défaut, c'est le logo
if (args.length > 0) launch(args);
else if (Module.ccall('dsp_web_has_embedded', 'number', [], [])) launch([]); // media built in
},