@tmustier/pi-nes 0.2.25 → 0.2.26
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -1
- package/extensions/nes/config.ts +17 -0
- package/extensions/nes/index.ts +21 -12
- package/extensions/nes/native/nes-core/index.d.ts +1 -0
- package/extensions/nes/native/nes-core/index.node +0 -0
- package/extensions/nes/native/nes-core/native.d.ts +1 -0
- package/extensions/nes/native/nes-core/src/lib.rs +115 -2
- package/extensions/nes/nes-core.ts +13 -2
- package/extensions/nes/rom-selector.ts +142 -0
- package/package.json +1 -1
- package/spec.md +2 -0
- package/tests/config.test.ts +11 -0
package/README.md
CHANGED
|
@@ -20,7 +20,7 @@ pi install git:github.com/tmustier/pi-nes
|
|
|
20
20
|
/nes ~/roms/smb.nes # Load a specific ROM
|
|
21
21
|
```
|
|
22
22
|
|
|
23
|
-
On first run, you'll be prompted to set your ROM directory and display quality.
|
|
23
|
+
On first run, you'll be prompted to set your ROM directory and display quality. When launching `/nes` without a path, type to filter the ROM list while you navigate.
|
|
24
24
|
|
|
25
25
|
## Controls
|
|
26
26
|
|
|
@@ -62,6 +62,7 @@ Config is stored at `~/.pi/nes/config.json`. Use `/nes config` for quick setup.
|
|
|
62
62
|
"saveDir": "/roms/nes/saves",
|
|
63
63
|
"renderer": "image",
|
|
64
64
|
"imageQuality": "balanced",
|
|
65
|
+
"videoFilter": "off",
|
|
65
66
|
"pixelScale": 1.0,
|
|
66
67
|
"keybindings": {
|
|
67
68
|
"up": ["up", "w"],
|
|
@@ -84,8 +85,11 @@ Config is stored at `~/.pi/nes/config.json`. Use `/nes config` for quick setup.
|
|
|
84
85
|
| `saveDir` | `/roms/nes/saves` | Where to store battery saves (defaults to `<romDir>/saves`) |
|
|
85
86
|
| `renderer` | `"image"` | `"image"` (Kitty graphics) or `"text"` (ANSI) |
|
|
86
87
|
| `imageQuality` | `"balanced"` | `"balanced"` (30 fps) or `"high"` (60 fps) |
|
|
88
|
+
| `videoFilter` | `"off"` | `"off"`, `"ntsc-composite"`, `"ntsc-svideo"`, `"ntsc-rgb"` |
|
|
87
89
|
| `pixelScale` | `1.0` | Display scale (0.5–4.0) |
|
|
88
90
|
|
|
91
|
+
`videoFilter` applies a lightweight CRT/NTSC-inspired pass (horizontal bleed + scanlines). It runs in the native core and is optional.
|
|
92
|
+
|
|
89
93
|
## Saves
|
|
90
94
|
|
|
91
95
|
Battery-backed SRAM is saved to `<saveDir>/<rom-name>-<hash>.sav` where the hash is derived from the full ROM path to avoid collisions. Old `<rom-name>.sav` files are ignored.
|
package/extensions/nes/config.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { normalizePath } from "./paths.js";
|
|
|
6
6
|
|
|
7
7
|
export type RendererMode = "image" | "text";
|
|
8
8
|
export type ImageQuality = "balanced" | "high";
|
|
9
|
+
export type VideoFilter = "off" | "ntsc-composite" | "ntsc-svideo" | "ntsc-rgb";
|
|
9
10
|
|
|
10
11
|
export interface NesConfig {
|
|
11
12
|
romDir: string;
|
|
@@ -13,6 +14,7 @@ export interface NesConfig {
|
|
|
13
14
|
enableAudio: boolean;
|
|
14
15
|
renderer: RendererMode;
|
|
15
16
|
imageQuality: ImageQuality;
|
|
17
|
+
videoFilter: VideoFilter;
|
|
16
18
|
pixelScale: number;
|
|
17
19
|
keybindings: InputMapping;
|
|
18
20
|
}
|
|
@@ -29,6 +31,7 @@ export const DEFAULT_CONFIG: NesConfig = {
|
|
|
29
31
|
enableAudio: false,
|
|
30
32
|
renderer: "image",
|
|
31
33
|
imageQuality: "balanced",
|
|
34
|
+
videoFilter: "off",
|
|
32
35
|
pixelScale: 1.0,
|
|
33
36
|
keybindings: cloneMapping(DEFAULT_INPUT_MAPPING),
|
|
34
37
|
};
|
|
@@ -39,6 +42,7 @@ interface RawConfig {
|
|
|
39
42
|
enableAudio?: unknown;
|
|
40
43
|
renderer?: unknown;
|
|
41
44
|
imageQuality?: unknown;
|
|
45
|
+
videoFilter?: unknown;
|
|
42
46
|
pixelScale?: unknown;
|
|
43
47
|
keybindings?: unknown;
|
|
44
48
|
}
|
|
@@ -68,6 +72,7 @@ export function normalizeConfig(raw: unknown): NesConfig {
|
|
|
68
72
|
: saveDirFallback;
|
|
69
73
|
const saveDir = resolveConfigPath(normalizePath(saveDirInput, saveDirFallback));
|
|
70
74
|
const imageQuality = normalizeImageQuality(parsed.imageQuality);
|
|
75
|
+
const videoFilter = normalizeVideoFilter(parsed.videoFilter);
|
|
71
76
|
const pixelScale = normalizePixelScale(parsed.pixelScale);
|
|
72
77
|
return {
|
|
73
78
|
romDir,
|
|
@@ -75,6 +80,7 @@ export function normalizeConfig(raw: unknown): NesConfig {
|
|
|
75
80
|
enableAudio: typeof parsed.enableAudio === "boolean" ? parsed.enableAudio : DEFAULT_CONFIG.enableAudio,
|
|
76
81
|
renderer: parsed.renderer === "text" ? "text" : DEFAULT_CONFIG.renderer,
|
|
77
82
|
imageQuality,
|
|
83
|
+
videoFilter,
|
|
78
84
|
pixelScale,
|
|
79
85
|
keybindings: normalizeKeybindings(parsed.keybindings),
|
|
80
86
|
};
|
|
@@ -136,6 +142,17 @@ function normalizeImageQuality(raw: unknown): ImageQuality {
|
|
|
136
142
|
return raw === "high" ? "high" : "balanced";
|
|
137
143
|
}
|
|
138
144
|
|
|
145
|
+
function normalizeVideoFilter(raw: unknown): VideoFilter {
|
|
146
|
+
switch (raw) {
|
|
147
|
+
case "ntsc-composite":
|
|
148
|
+
case "ntsc-svideo":
|
|
149
|
+
case "ntsc-rgb":
|
|
150
|
+
return raw;
|
|
151
|
+
default:
|
|
152
|
+
return "off";
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
139
156
|
function normalizeKeybindings(raw: unknown): InputMapping {
|
|
140
157
|
const mapping = cloneMapping(DEFAULT_INPUT_MAPPING);
|
|
141
158
|
if (!raw || typeof raw !== "object") {
|
package/extensions/nes/index.ts
CHANGED
|
@@ -12,10 +12,12 @@ import {
|
|
|
12
12
|
loadConfig,
|
|
13
13
|
normalizeConfig,
|
|
14
14
|
saveConfig,
|
|
15
|
+
type VideoFilter,
|
|
15
16
|
} from "./config.js";
|
|
16
17
|
import { displayPath, resolvePathInput } from "./paths.js";
|
|
17
18
|
import { NesSession } from "./nes-session.js";
|
|
18
19
|
import { listRoms } from "./roms.js";
|
|
20
|
+
import { selectRomWithFilter } from "./rom-selector.js";
|
|
19
21
|
import { loadSram } from "./saves.js";
|
|
20
22
|
|
|
21
23
|
const IMAGE_RENDER_INTERVAL_BALANCED_MS = 1000 / 30;
|
|
@@ -44,17 +46,8 @@ async function selectRom(
|
|
|
44
46
|
return null;
|
|
45
47
|
}
|
|
46
48
|
|
|
47
|
-
const
|
|
48
|
-
|
|
49
|
-
if (!selection) {
|
|
50
|
-
return null;
|
|
51
|
-
}
|
|
52
|
-
const index = options.indexOf(selection);
|
|
53
|
-
if (index < 0) {
|
|
54
|
-
ctx.ui.notify("ROM selection failed. Please try again.", "error");
|
|
55
|
-
return null;
|
|
56
|
-
}
|
|
57
|
-
return roms[index]?.path ?? null;
|
|
49
|
+
const selection = await selectRomWithFilter(ctx, roms);
|
|
50
|
+
return selection;
|
|
58
51
|
} catch {
|
|
59
52
|
ctx.ui.notify(`Failed to read ROM directory: ${romDir}. Update ${configPath} to set romDir.`, "error");
|
|
60
53
|
return null;
|
|
@@ -177,6 +170,21 @@ async function configureWithWizard(
|
|
|
177
170
|
|
|
178
171
|
const isHighQuality = qualityChoice.startsWith("High");
|
|
179
172
|
const imageQuality = isHighQuality ? "high" : "balanced";
|
|
173
|
+
|
|
174
|
+
const filterOptions: Array<{ label: string; value: VideoFilter }> = [
|
|
175
|
+
{ label: "Off (default) — raw RGB", value: "off" },
|
|
176
|
+
{ label: "NTSC Composite — strong bleed + scanlines", value: "ntsc-composite" },
|
|
177
|
+
{ label: "NTSC S-Video — balanced bleed", value: "ntsc-svideo" },
|
|
178
|
+
{ label: "NTSC RGB — subtle blur", value: "ntsc-rgb" },
|
|
179
|
+
];
|
|
180
|
+
const filterChoice = await ctx.ui.select(
|
|
181
|
+
"Video filter",
|
|
182
|
+
filterOptions.map((option) => option.label),
|
|
183
|
+
);
|
|
184
|
+
if (!filterChoice) {
|
|
185
|
+
return false;
|
|
186
|
+
}
|
|
187
|
+
const videoFilter = filterOptions.find((option) => option.label === filterChoice)?.value ?? "off";
|
|
180
188
|
const pixelScale = config.pixelScale;
|
|
181
189
|
|
|
182
190
|
const defaultSaveDir = getDefaultSaveDir(config.romDir);
|
|
@@ -187,6 +195,7 @@ async function configureWithWizard(
|
|
|
187
195
|
romDir,
|
|
188
196
|
saveDir,
|
|
189
197
|
imageQuality,
|
|
198
|
+
videoFilter,
|
|
190
199
|
pixelScale,
|
|
191
200
|
});
|
|
192
201
|
await saveConfig(normalized);
|
|
@@ -237,7 +246,7 @@ async function createSession(romPath: string, ctx: ExtensionCommandContext, conf
|
|
|
237
246
|
|
|
238
247
|
let core;
|
|
239
248
|
try {
|
|
240
|
-
core = createNesCore({ enableAudio: config.enableAudio });
|
|
249
|
+
core = createNesCore({ enableAudio: config.enableAudio, videoFilter: config.videoFilter });
|
|
241
250
|
} catch (error) {
|
|
242
251
|
const message = error instanceof Error ? error.message : String(error);
|
|
243
252
|
ctx.ui.notify(`Failed to initialize NES core: ${message}`, "error");
|
|
Binary file
|
|
@@ -7,6 +7,23 @@ use nes_rust::display::{Display, SCREEN_HEIGHT, SCREEN_WIDTH};
|
|
|
7
7
|
use nes_rust::rom::Rom;
|
|
8
8
|
use nes_rust::Nes;
|
|
9
9
|
|
|
10
|
+
const FRAME_BYTE_LEN: usize = (SCREEN_WIDTH * SCREEN_HEIGHT * 3) as usize;
|
|
11
|
+
|
|
12
|
+
#[derive(Clone, Copy, PartialEq, Eq)]
|
|
13
|
+
enum VideoFilterMode {
|
|
14
|
+
Off,
|
|
15
|
+
NtscComposite,
|
|
16
|
+
NtscSvideo,
|
|
17
|
+
NtscRgb,
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
struct VideoFilterConfig {
|
|
21
|
+
luma: [f32; 3],
|
|
22
|
+
chroma: [f32; 3],
|
|
23
|
+
scanline_dim: f32,
|
|
24
|
+
chroma_gain: f32,
|
|
25
|
+
}
|
|
26
|
+
|
|
10
27
|
struct NativeDisplay {
|
|
11
28
|
pixels: Vec<u8>,
|
|
12
29
|
}
|
|
@@ -14,7 +31,7 @@ struct NativeDisplay {
|
|
|
14
31
|
impl NativeDisplay {
|
|
15
32
|
fn new() -> Self {
|
|
16
33
|
Self {
|
|
17
|
-
pixels: vec![0;
|
|
34
|
+
pixels: vec![0; FRAME_BYTE_LEN],
|
|
18
35
|
}
|
|
19
36
|
}
|
|
20
37
|
}
|
|
@@ -77,6 +94,8 @@ pub struct NesDebugState {
|
|
|
77
94
|
pub struct NativeNes {
|
|
78
95
|
nes: Nes,
|
|
79
96
|
framebuffer: Vec<u8>,
|
|
97
|
+
filter_buffer: Vec<u8>,
|
|
98
|
+
video_filter: VideoFilterMode,
|
|
80
99
|
}
|
|
81
100
|
|
|
82
101
|
#[napi]
|
|
@@ -89,7 +108,9 @@ impl NativeNes {
|
|
|
89
108
|
let nes = Nes::new(input, display, audio);
|
|
90
109
|
Self {
|
|
91
110
|
nes,
|
|
92
|
-
framebuffer: vec![0;
|
|
111
|
+
framebuffer: vec![0; FRAME_BYTE_LEN],
|
|
112
|
+
filter_buffer: vec![0; FRAME_BYTE_LEN],
|
|
113
|
+
video_filter: VideoFilterMode::Off,
|
|
93
114
|
}
|
|
94
115
|
}
|
|
95
116
|
|
|
@@ -112,6 +133,20 @@ impl NativeNes {
|
|
|
112
133
|
#[napi]
|
|
113
134
|
pub fn refresh_framebuffer(&mut self) {
|
|
114
135
|
self.nes.copy_pixels(&mut self.framebuffer);
|
|
136
|
+
if let Some(config) = video_filter_config(self.video_filter) {
|
|
137
|
+
self.filter_buffer.copy_from_slice(&self.framebuffer);
|
|
138
|
+
apply_video_filter(&self.filter_buffer, &mut self.framebuffer, &config);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
#[napi]
|
|
143
|
+
pub fn set_video_filter(&mut self, mode: u8) {
|
|
144
|
+
self.video_filter = match mode {
|
|
145
|
+
1 => VideoFilterMode::NtscComposite,
|
|
146
|
+
2 => VideoFilterMode::NtscSvideo,
|
|
147
|
+
3 => VideoFilterMode::NtscRgb,
|
|
148
|
+
_ => VideoFilterMode::Off,
|
|
149
|
+
};
|
|
115
150
|
}
|
|
116
151
|
|
|
117
152
|
#[napi]
|
|
@@ -201,3 +236,81 @@ fn map_button(button: u8) -> Option<Button> {
|
|
|
201
236
|
_ => None,
|
|
202
237
|
}
|
|
203
238
|
}
|
|
239
|
+
|
|
240
|
+
fn video_filter_config(mode: VideoFilterMode) -> Option<VideoFilterConfig> {
|
|
241
|
+
match mode {
|
|
242
|
+
VideoFilterMode::Off => None,
|
|
243
|
+
VideoFilterMode::NtscComposite => Some(VideoFilterConfig {
|
|
244
|
+
luma: [0.2, 0.6, 0.2],
|
|
245
|
+
chroma: [0.25, 0.5, 0.25],
|
|
246
|
+
scanline_dim: 0.85,
|
|
247
|
+
chroma_gain: 0.9,
|
|
248
|
+
}),
|
|
249
|
+
VideoFilterMode::NtscSvideo => Some(VideoFilterConfig {
|
|
250
|
+
luma: [0.15, 0.7, 0.15],
|
|
251
|
+
chroma: [0.2, 0.6, 0.2],
|
|
252
|
+
scanline_dim: 0.9,
|
|
253
|
+
chroma_gain: 0.95,
|
|
254
|
+
}),
|
|
255
|
+
VideoFilterMode::NtscRgb => Some(VideoFilterConfig {
|
|
256
|
+
luma: [0.1, 0.8, 0.1],
|
|
257
|
+
chroma: [0.1, 0.8, 0.1],
|
|
258
|
+
scanline_dim: 0.95,
|
|
259
|
+
chroma_gain: 1.0,
|
|
260
|
+
}),
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
fn apply_video_filter(source: &[u8], target: &mut [u8], config: &VideoFilterConfig) {
|
|
265
|
+
let width = SCREEN_WIDTH as usize;
|
|
266
|
+
let height = SCREEN_HEIGHT as usize;
|
|
267
|
+
if source.len() < FRAME_BYTE_LEN || target.len() < FRAME_BYTE_LEN {
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
for y in 0..height {
|
|
271
|
+
let scanline = if y % 2 == 0 { 1.0 } else { config.scanline_dim };
|
|
272
|
+
for x in 0..width {
|
|
273
|
+
let left_x = if x == 0 { 0 } else { x - 1 };
|
|
274
|
+
let right_x = if x + 1 >= width { width - 1 } else { x + 1 };
|
|
275
|
+
let center_x = x;
|
|
276
|
+
|
|
277
|
+
let left_idx = (y * width + left_x) * 3;
|
|
278
|
+
let center_idx = (y * width + center_x) * 3;
|
|
279
|
+
let right_idx = (y * width + right_x) * 3;
|
|
280
|
+
|
|
281
|
+
let (y0, i0, q0) = rgb_to_yiq(source[left_idx], source[left_idx + 1], source[left_idx + 2]);
|
|
282
|
+
let (y1, i1, q1) = rgb_to_yiq(source[center_idx], source[center_idx + 1], source[center_idx + 2]);
|
|
283
|
+
let (y2, i2, q2) = rgb_to_yiq(source[right_idx], source[right_idx + 1], source[right_idx + 2]);
|
|
284
|
+
|
|
285
|
+
let luma = config.luma[0] * y0 + config.luma[1] * y1 + config.luma[2] * y2;
|
|
286
|
+
let chroma_i = config.chroma_gain * (config.chroma[0] * i0 + config.chroma[1] * i1 + config.chroma[2] * i2);
|
|
287
|
+
let chroma_q = config.chroma_gain * (config.chroma[0] * q0 + config.chroma[1] * q1 + config.chroma[2] * q2);
|
|
288
|
+
|
|
289
|
+
let (r, g, b) = yiq_to_rgb(luma, chroma_i, chroma_q);
|
|
290
|
+
target[center_idx] = clamp_u8(r * scanline);
|
|
291
|
+
target[center_idx + 1] = clamp_u8(g * scanline);
|
|
292
|
+
target[center_idx + 2] = clamp_u8(b * scanline);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
fn rgb_to_yiq(r: u8, g: u8, b: u8) -> (f32, f32, f32) {
|
|
298
|
+
let r = r as f32;
|
|
299
|
+
let g = g as f32;
|
|
300
|
+
let b = b as f32;
|
|
301
|
+
let y = 0.299 * r + 0.587 * g + 0.114 * b;
|
|
302
|
+
let i = 0.596 * r - 0.274 * g - 0.322 * b;
|
|
303
|
+
let q = 0.211 * r - 0.523 * g + 0.312 * b;
|
|
304
|
+
(y, i, q)
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
fn yiq_to_rgb(y: f32, i: f32, q: f32) -> (f32, f32, f32) {
|
|
308
|
+
let r = y + 0.956 * i + 0.621 * q;
|
|
309
|
+
let g = y - 0.272 * i - 0.647 * q;
|
|
310
|
+
let b = y - 1.106 * i + 1.703 * q;
|
|
311
|
+
(r, g, b)
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
fn clamp_u8(value: f32) -> u8 {
|
|
315
|
+
value.max(0.0).min(255.0).round() as u8
|
|
316
|
+
}
|
|
@@ -3,6 +3,7 @@ import { createRequire } from "node:module";
|
|
|
3
3
|
const require = createRequire(import.meta.url);
|
|
4
4
|
|
|
5
5
|
export type NesButton = "up" | "down" | "left" | "right" | "a" | "b" | "start" | "select";
|
|
6
|
+
export type VideoFilterMode = "off" | "ntsc-composite" | "ntsc-svideo" | "ntsc-rgb";
|
|
6
7
|
|
|
7
8
|
export interface FrameBuffer {
|
|
8
9
|
data: Uint8Array;
|
|
@@ -51,6 +52,7 @@ export interface NesCore {
|
|
|
51
52
|
|
|
52
53
|
export interface CreateNesCoreOptions {
|
|
53
54
|
enableAudio?: boolean;
|
|
55
|
+
videoFilter?: VideoFilterMode;
|
|
54
56
|
}
|
|
55
57
|
|
|
56
58
|
interface NativeNesInstance {
|
|
@@ -58,6 +60,7 @@ interface NativeNesInstance {
|
|
|
58
60
|
bootup(): void;
|
|
59
61
|
stepFrame(): void;
|
|
60
62
|
refreshFramebuffer(): void;
|
|
63
|
+
setVideoFilter(mode: number): void;
|
|
61
64
|
pressButton(button: number): void;
|
|
62
65
|
releaseButton(button: number): void;
|
|
63
66
|
hasBatteryBackedRam(): boolean;
|
|
@@ -101,13 +104,20 @@ const NATIVE_BUTTON_MAP: Record<NesButton, number> = {
|
|
|
101
104
|
right: 7,
|
|
102
105
|
};
|
|
103
106
|
|
|
107
|
+
const NATIVE_VIDEO_FILTER_MAP: Record<VideoFilterMode, number> = {
|
|
108
|
+
off: 0,
|
|
109
|
+
"ntsc-composite": 1,
|
|
110
|
+
"ntsc-svideo": 2,
|
|
111
|
+
"ntsc-rgb": 3,
|
|
112
|
+
};
|
|
113
|
+
|
|
104
114
|
class NativeNesCore implements NesCore {
|
|
105
115
|
private readonly nes: NativeNesInstance;
|
|
106
116
|
private readonly audioWarning: string | null;
|
|
107
117
|
private readonly frameBuffer: Uint8Array;
|
|
108
118
|
private hasSram = false;
|
|
109
119
|
|
|
110
|
-
constructor(enableAudio: boolean) {
|
|
120
|
+
constructor(enableAudio: boolean, videoFilter: VideoFilterMode) {
|
|
111
121
|
this.audioWarning = enableAudio
|
|
112
122
|
? "Audio output is disabled (no safe dependency available)."
|
|
113
123
|
: null;
|
|
@@ -116,6 +126,7 @@ class NativeNesCore implements NesCore {
|
|
|
116
126
|
throw new Error("Native NES core addon is not available.");
|
|
117
127
|
}
|
|
118
128
|
this.nes = new module.NativeNes();
|
|
129
|
+
this.nes.setVideoFilter(NATIVE_VIDEO_FILTER_MAP[videoFilter]);
|
|
119
130
|
this.frameBuffer = this.nes.getFramebuffer();
|
|
120
131
|
}
|
|
121
132
|
|
|
@@ -186,5 +197,5 @@ class NativeNesCore implements NesCore {
|
|
|
186
197
|
}
|
|
187
198
|
|
|
188
199
|
export function createNesCore(options: CreateNesCoreOptions = {}): NesCore {
|
|
189
|
-
return new NativeNesCore(options.enableAudio ?? false);
|
|
200
|
+
return new NativeNesCore(options.enableAudio ?? false, options.videoFilter ?? "off");
|
|
190
201
|
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import type { ExtensionCommandContext, Theme } from "@mariozechner/pi-coding-agent";
|
|
2
|
+
import { DynamicBorder, getSelectListTheme } from "@mariozechner/pi-coding-agent";
|
|
3
|
+
import {
|
|
4
|
+
Container,
|
|
5
|
+
Input,
|
|
6
|
+
SelectList,
|
|
7
|
+
Spacer,
|
|
8
|
+
Text,
|
|
9
|
+
TUI,
|
|
10
|
+
type Component,
|
|
11
|
+
type Focusable,
|
|
12
|
+
type SelectItem,
|
|
13
|
+
getEditorKeybindings,
|
|
14
|
+
} from "@mariozechner/pi-tui";
|
|
15
|
+
import type { RomEntry } from "./roms.js";
|
|
16
|
+
|
|
17
|
+
const MAX_VISIBLE_ROMS = 10;
|
|
18
|
+
|
|
19
|
+
class RomSelectorDialog extends Container implements Focusable {
|
|
20
|
+
private readonly tui: TUI;
|
|
21
|
+
private readonly theme: Theme;
|
|
22
|
+
private readonly items: SelectItem[];
|
|
23
|
+
private readonly filterInput: Input;
|
|
24
|
+
private listComponent: Component;
|
|
25
|
+
private selectList: SelectList | null = null;
|
|
26
|
+
private onSelect: (value: string) => void;
|
|
27
|
+
private onCancel: () => void;
|
|
28
|
+
private _focused = false;
|
|
29
|
+
|
|
30
|
+
constructor(
|
|
31
|
+
roms: RomEntry[],
|
|
32
|
+
tui: TUI,
|
|
33
|
+
theme: Theme,
|
|
34
|
+
onSelect: (value: string) => void,
|
|
35
|
+
onCancel: () => void,
|
|
36
|
+
) {
|
|
37
|
+
super();
|
|
38
|
+
this.tui = tui;
|
|
39
|
+
this.theme = theme;
|
|
40
|
+
this.items = roms.map((rom) => ({ value: rom.path, label: rom.name }));
|
|
41
|
+
this.onSelect = onSelect;
|
|
42
|
+
this.onCancel = onCancel;
|
|
43
|
+
this.filterInput = new Input();
|
|
44
|
+
this.filterInput.setValue("");
|
|
45
|
+
this.listComponent = new Text("", 0, 0);
|
|
46
|
+
this.updateList();
|
|
47
|
+
this.buildLayout();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
get focused(): boolean {
|
|
51
|
+
return this._focused;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
set focused(value: boolean) {
|
|
55
|
+
this._focused = value;
|
|
56
|
+
this.filterInput.focused = value;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
invalidate(): void {
|
|
60
|
+
super.invalidate();
|
|
61
|
+
this.buildLayout();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
handleInput(data: string): void {
|
|
65
|
+
const kb = getEditorKeybindings();
|
|
66
|
+
if (
|
|
67
|
+
kb.matches(data, "selectUp") ||
|
|
68
|
+
kb.matches(data, "selectDown") ||
|
|
69
|
+
kb.matches(data, "selectConfirm") ||
|
|
70
|
+
kb.matches(data, "selectCancel") ||
|
|
71
|
+
kb.matches(data, "pageUp") ||
|
|
72
|
+
kb.matches(data, "pageDown")
|
|
73
|
+
) {
|
|
74
|
+
if (this.selectList) {
|
|
75
|
+
this.selectList.handleInput(data);
|
|
76
|
+
} else if (kb.matches(data, "selectCancel")) {
|
|
77
|
+
this.onCancel();
|
|
78
|
+
}
|
|
79
|
+
this.tui.requestRender();
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const before = this.filterInput.getValue();
|
|
84
|
+
this.filterInput.handleInput(data);
|
|
85
|
+
const after = this.filterInput.getValue();
|
|
86
|
+
if (after !== before) {
|
|
87
|
+
this.updateList();
|
|
88
|
+
this.buildLayout();
|
|
89
|
+
}
|
|
90
|
+
this.tui.requestRender();
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
private updateList(): void {
|
|
94
|
+
const filter = this.filterInput.getValue().trim().toLowerCase();
|
|
95
|
+
const filteredItems = filter.length
|
|
96
|
+
? this.items.filter((item) => (item.label ?? item.value).toLowerCase().includes(filter))
|
|
97
|
+
: this.items;
|
|
98
|
+
|
|
99
|
+
if (filteredItems.length === 0) {
|
|
100
|
+
this.selectList = null;
|
|
101
|
+
this.listComponent = new Text(this.theme.fg("warning", " No matching ROMs"), 1, 0);
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const list = new SelectList(filteredItems, Math.min(filteredItems.length, MAX_VISIBLE_ROMS), getSelectListTheme());
|
|
106
|
+
list.onSelect = (item) => this.onSelect(item.value);
|
|
107
|
+
list.onCancel = () => this.onCancel();
|
|
108
|
+
this.selectList = list;
|
|
109
|
+
this.listComponent = list;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
private buildLayout(): void {
|
|
113
|
+
this.clear();
|
|
114
|
+
this.addChild(new DynamicBorder((line) => this.theme.fg("accent", line)));
|
|
115
|
+
this.addChild(new Text(this.theme.fg("accent", this.theme.bold("Select a ROM")), 1, 0));
|
|
116
|
+
this.addChild(new Text(this.theme.fg("dim", "Type to filter · ↑↓ navigate · Enter select · Esc cancel"), 1, 0));
|
|
117
|
+
this.addChild(new Spacer(1));
|
|
118
|
+
this.addChild(new Text(this.theme.fg("muted", "Filter:"), 1, 0));
|
|
119
|
+
this.addChild(this.filterInput);
|
|
120
|
+
this.addChild(new Spacer(1));
|
|
121
|
+
this.addChild(this.listComponent);
|
|
122
|
+
this.addChild(new Spacer(1));
|
|
123
|
+
this.addChild(new DynamicBorder((line) => this.theme.fg("accent", line)));
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export async function selectRomWithFilter(
|
|
128
|
+
ctx: ExtensionCommandContext,
|
|
129
|
+
roms: RomEntry[],
|
|
130
|
+
): Promise<string | null> {
|
|
131
|
+
const result = await ctx.ui.custom<string | null>((tui, theme, _kb, done) => {
|
|
132
|
+
const dialog = new RomSelectorDialog(
|
|
133
|
+
roms,
|
|
134
|
+
tui,
|
|
135
|
+
theme,
|
|
136
|
+
(value) => done(value),
|
|
137
|
+
() => done(null),
|
|
138
|
+
);
|
|
139
|
+
return dialog;
|
|
140
|
+
});
|
|
141
|
+
return result ?? null;
|
|
142
|
+
}
|
package/package.json
CHANGED
package/spec.md
CHANGED
|
@@ -72,6 +72,7 @@ pi-nes/
|
|
|
72
72
|
- `enableAudio`
|
|
73
73
|
- `renderer` ("image" or "text")
|
|
74
74
|
- `imageQuality` ("balanced" or "high", controls render fps)
|
|
75
|
+
- `videoFilter` ("off", "ntsc-composite", "ntsc-svideo", "ntsc-rgb")
|
|
75
76
|
- `pixelScale` (float, e.g. 1.0)
|
|
76
77
|
- `keybindings` (button-to-keys map, e.g. `{ "a": ["z"] }`)
|
|
77
78
|
|
|
@@ -91,5 +92,6 @@ Note: audio output is currently disabled; setting `enableAudio` will show a warn
|
|
|
91
92
|
- Default ROM dir: `/roms/nes` (configurable).
|
|
92
93
|
- Default core: `native`.
|
|
93
94
|
- Default image quality: `balanced` (30 fps).
|
|
95
|
+
- Default video filter: `off`.
|
|
94
96
|
- Default pixel scale: `1.0`.
|
|
95
97
|
- Default save dir: `/roms/nes/saves` (configurable).
|
package/tests/config.test.ts
CHANGED
|
@@ -9,6 +9,7 @@ describe("config", () => {
|
|
|
9
9
|
assert.strictEqual(config.enableAudio, DEFAULT_CONFIG.enableAudio);
|
|
10
10
|
assert.strictEqual(config.renderer, DEFAULT_CONFIG.renderer);
|
|
11
11
|
assert.strictEqual(config.imageQuality, DEFAULT_CONFIG.imageQuality);
|
|
12
|
+
assert.strictEqual(config.videoFilter, DEFAULT_CONFIG.videoFilter);
|
|
12
13
|
assert.strictEqual(config.pixelScale, DEFAULT_CONFIG.pixelScale);
|
|
13
14
|
});
|
|
14
15
|
|
|
@@ -42,6 +43,16 @@ describe("config", () => {
|
|
|
42
43
|
assert.strictEqual(config.imageQuality, "balanced");
|
|
43
44
|
});
|
|
44
45
|
|
|
46
|
+
test("accepts valid videoFilter", () => {
|
|
47
|
+
const config = normalizeConfig({ videoFilter: "ntsc-composite" });
|
|
48
|
+
assert.strictEqual(config.videoFilter, "ntsc-composite");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("defaults invalid videoFilter to off", () => {
|
|
52
|
+
const config = normalizeConfig({ videoFilter: "crt" });
|
|
53
|
+
assert.strictEqual(config.videoFilter, "off");
|
|
54
|
+
});
|
|
55
|
+
|
|
45
56
|
test("clamps pixelScale to valid range", () => {
|
|
46
57
|
assert.strictEqual(normalizeConfig({ pixelScale: 0.1 }).pixelScale, 0.5);
|
|
47
58
|
assert.strictEqual(normalizeConfig({ pixelScale: 10 }).pixelScale, 4);
|