buninu-linux 0.4.0 → 0.5.1

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/initramfs/init.js CHANGED
@@ -94,6 +94,79 @@ const mountFilesystems = () => {
94
94
  mountFilesystems();
95
95
  console.log("init.js: mounted /proc, /sys, /dev, /tmp, /dev/pts");
96
96
 
97
+ // Num Lock. The keypad's lock state lives per virtual console in the
98
+ // kernel's keyboard table, and the kernel starts every console with it off,
99
+ // so the keypad types arrows instead of digits. KDSKBLED carries the current
100
+ // flags in its low nibble and the ones a console is reset to in its high
101
+ // nibble; KDSETLED with a value above 7 puts the physical LED back to
102
+ // following those flags. A console allocated later (Ctrl-Alt-F3 for the
103
+ // first time) starts from the kernel default again, so numlock() is left in
104
+ // the REPL for that case.
105
+ const KDSETLED = 0x4b32;
106
+ const KDGKBLED = 0x4b64;
107
+ const KDSKBLED = 0x4b65;
108
+ const LED_NUM = 0x02;
109
+
110
+ // Nothing here is essential to booting, so a console that is not a virtual
111
+ // terminal, a missing device node or a rejected ioctl is skipped rather than
112
+ // reported: the remaining consoles are still set, and the system comes up
113
+ // either way.
114
+ const setNumLock = (enabled = true) => {
115
+ const consoles = [];
116
+ const paths = ["/dev/tty0", "/dev/console", "/dev/tty"];
117
+ for (let index = 1; index <= 12; index++) paths.push(`/dev/tty${index}`);
118
+ for (const path of paths) {
119
+ let fd = -1;
120
+ try {
121
+ fd = libc.symbols.open(cString(path), 2, 0);
122
+ if (fd < 0) continue;
123
+ const state = new Uint8Array(1);
124
+ if (libc.symbols.ioctl(fd, KDGKBLED, ptr(state)) !== 0) continue;
125
+ const current = enabled ? state[0] | LED_NUM : state[0] & ~LED_NUM;
126
+ const fallback = enabled ? (state[0] >> 4) | LED_NUM : (state[0] >> 4) & ~LED_NUM;
127
+ const flags = (current & 7) | ((fallback & 7) << 4);
128
+ if (libc.symbols.ioctl(fd, KDSKBLED, flags) !== 0) continue;
129
+ libc.symbols.ioctl(fd, KDSETLED, 0x80);
130
+ consoles.push(path);
131
+ } catch {
132
+ // This console keeps whatever state it had; the others still get set.
133
+ } finally {
134
+ if (fd >= 0) {
135
+ try { libc.symbols.close(fd); } catch {}
136
+ }
137
+ }
138
+ }
139
+ return consoles;
140
+ };
141
+
142
+ globalThis.numlock = setNumLock;
143
+ try {
144
+ const consoles = setNumLock(true);
145
+ console.log(consoles.length
146
+ ? `init.js: num lock on (${consoles.join(" ")})`
147
+ : "init.js: no virtual console accepted a num lock setting");
148
+ } catch (error) {
149
+ console.error(`init.js: num lock: ${error?.message ?? error}`);
150
+ }
151
+
152
+ // The input layer's character devices, so a pointing device has a
153
+ // /dev/input/event* node for `bunterm --mouse` to read. Loading them only
154
+ // creates those nodes: the console keyboard is unaffected, and nothing reads
155
+ // a pointer unless it is asked to. A USB mouse also needs the usbhid stack,
156
+ // which `cfg.all` or a --real boot brings in.
157
+ try {
158
+ const { tryModprobe } = await import("/lib/modprobe.js");
159
+ const loaded = ["evdev", "psmouse"].filter((module) => tryModprobe(module) !== null);
160
+ const { readdirSync } = await import("node:fs");
161
+ const devices = (() => {
162
+ try { return readdirSync("/dev/input").filter((name) => name.startsWith("event")); } catch { return []; }
163
+ })();
164
+ console.log(`init.js: input modules ${loaded.length ? loaded.join(" ") : "none"}`
165
+ + ` (${devices.length} event device${devices.length === 1 ? "" : "s"})`);
166
+ } catch (error) {
167
+ console.error(`init.js: input modules: ${error?.message ?? error}`);
168
+ }
169
+
97
170
  const { default: repl } = await import("node:repl");
98
171
  const { mkdirSync, writeFileSync } = await import("node:fs");
99
172
 
@@ -354,7 +427,7 @@ Type bunterm in bunmsh: Graphical Terminal
354
427
  ], {
355
428
  env: {
356
429
  ...process.env,
357
- PATH:"/bin:/usr/bin:/buninu/bin",
430
+ PATH:"/bin:/sbin:/usr/bin:/usr/sbin:/buninu/.bun/bin:/buninu/bin",
358
431
  HOME:"/buninu"
359
432
  },
360
433
  stdin: "inherit",
@@ -0,0 +1,222 @@
1
+ // /lib/bunterm/mouse.js — pointing devices through the Linux input layer.
2
+ //
3
+ // Read from /dev/input/event*, where the kernel reports one 24-byte
4
+ // input_event per axis, button or wheel notch, ending each batch with an
5
+ // EV_SYN. Relative devices (a mouse) move the pointer by their deltas;
6
+ // absolute ones (QEMU's USB tablet, a touchscreen) place it directly, which
7
+ // needs the axis range from EVIOCGABS. This module is only loaded when
8
+ // bunterm is asked for a mouse.
9
+
10
+ import { closeSync, existsSync, openSync, read, readFileSync, readdirSync } from "node:fs";
11
+ import { libc, ptr } from "../dlopen.js";
12
+
13
+ const EVENT_SIZE = 24;
14
+ const EV_SYN = 0x00;
15
+ const EV_KEY = 0x01;
16
+ const EV_REL = 0x02;
17
+ const EV_ABS = 0x03;
18
+ const REL_X = 0x00;
19
+ const REL_Y = 0x01;
20
+ const REL_WHEEL = 0x08;
21
+ const REL_HWHEEL = 0x06;
22
+ const ABS_X = 0x00;
23
+ const ABS_Y = 0x01;
24
+ const BTN_LEFT = 0x110;
25
+ const BTN_RIGHT = 0x111;
26
+ const BTN_MIDDLE = 0x112;
27
+ const BTN_TOUCH = 0x14a;
28
+
29
+ // struct input_absinfo { s32 value, minimum, maximum, fuzz, flat, resolution }
30
+ const EVIOCGABS = (axis) => 0x80184540 + axis;
31
+
32
+ const buttonNames = {
33
+ [BTN_LEFT]: "left",
34
+ [BTN_RIGHT]: "right",
35
+ [BTN_MIDDLE]: "middle",
36
+ [BTN_TOUCH]: "left",
37
+ };
38
+
39
+ const capability = (directory, name) => {
40
+ try {
41
+ // A capability file is a space-separated list of 64-bit hex words, most
42
+ // significant first, so the last word holds bits 0-63.
43
+ const words = readFileSync(`${directory}/${name}`, "utf8").trim().split(/\s+/u).reverse();
44
+ return (bit) => {
45
+ const word = words[Math.floor(bit / 64)];
46
+ return word ? (BigInt(`0x${word}`) >> BigInt(bit % 64)) & 1n ? true : false : false;
47
+ };
48
+ } catch {
49
+ return () => false;
50
+ }
51
+ };
52
+
53
+ // Every /dev/input/event* that looks like a pointer, with how it reports
54
+ // position. A device with both relative and absolute axes counts as absolute.
55
+ export const findPointers = (directory = "/dev/input") => {
56
+ const found = [];
57
+ let names = [];
58
+ try {
59
+ names = readdirSync(directory).filter((name) => /^event\d+$/u.test(name));
60
+ } catch {
61
+ return found;
62
+ }
63
+ for (const name of names.sort()) {
64
+ const sysfs = `/sys/class/input/${name}/device/capabilities`;
65
+ if (!existsSync(sysfs)) continue;
66
+ const relative = capability(sysfs, "rel");
67
+ const absolute = capability(sysfs, "abs");
68
+ const keys = capability(sysfs, "key");
69
+ const hasRelative = relative(REL_X) && relative(REL_Y);
70
+ const hasAbsolute = absolute(ABS_X) && absolute(ABS_Y);
71
+ const hasButton = keys(BTN_LEFT) || keys(BTN_TOUCH);
72
+ if (!hasButton || (!hasRelative && !hasAbsolute)) continue;
73
+ let label = name;
74
+ try {
75
+ label = readFileSync(`/sys/class/input/${name}/device/name`, "utf8").trim() || name;
76
+ } catch {}
77
+ found.push({
78
+ path: `${directory}/${name}`,
79
+ name: label,
80
+ kind: hasAbsolute ? "absolute" : "relative",
81
+ wheel: relative(REL_WHEEL),
82
+ });
83
+ }
84
+ return found;
85
+ };
86
+
87
+ const absoluteRange = (fd, axis) => {
88
+ const info = new Int32Array(6);
89
+ if (libc.symbols.ioctl(fd, EVIOCGABS(axis), ptr(info)) === -1) return null;
90
+ const [, minimum, maximum] = info;
91
+ return maximum > minimum ? { minimum, maximum } : null;
92
+ };
93
+
94
+ // Opens every pointer in `devices` and reports the pointer state after each
95
+ // EV_SYN: { x, y, buttons: { left, middle, right }, wheel, hwheel, moved,
96
+ // pressed, released }. Coordinates are pixels clamped to width x height.
97
+ // `speed` scales relative motion. close() releases the devices.
98
+ export const openPointer = ({
99
+ devices = findPointers(),
100
+ width,
101
+ height,
102
+ speed = 1,
103
+ x = Math.floor(width / 2),
104
+ y = Math.floor(height / 2),
105
+ onSync,
106
+ onError = () => {},
107
+ }) => {
108
+ const state = {
109
+ x, y,
110
+ buttons: { left: false, middle: false, right: false },
111
+ wheel: 0,
112
+ hwheel: 0,
113
+ moved: false,
114
+ pressed: [],
115
+ released: [],
116
+ };
117
+ const open = [];
118
+
119
+ for (const device of devices) {
120
+ let fd = -1;
121
+ try {
122
+ fd = openSync(device.path, "r");
123
+ const ranges = device.kind === "absolute"
124
+ ? { x: absoluteRange(fd, ABS_X), y: absoluteRange(fd, ABS_Y) }
125
+ : null;
126
+ // An absolute device without a usable range is read as relative.
127
+ const kind = ranges?.x && ranges?.y ? "absolute" : "relative";
128
+ open.push({ ...device, fd, kind, ranges, buffer: Buffer.alloc(EVENT_SIZE * 64), reading: true });
129
+ } catch (error) {
130
+ if (fd >= 0) {
131
+ try { closeSync(fd); } catch {}
132
+ }
133
+ onError(new Error(`${device.path}: ${error.message}`));
134
+ }
135
+ }
136
+
137
+ const clamp = (value, maximum) => Math.max(0, Math.min(maximum - 1, value));
138
+
139
+ const apply = (entry, type, code, value) => {
140
+ if (type === EV_REL) {
141
+ if (code === REL_X) { state.x = clamp(Math.round(state.x + value * speed), width); state.moved = true; }
142
+ else if (code === REL_Y) { state.y = clamp(Math.round(state.y + value * speed), height); state.moved = true; }
143
+ else if (code === REL_WHEEL) state.wheel += value;
144
+ else if (code === REL_HWHEEL) state.hwheel += value;
145
+ return;
146
+ }
147
+ if (type === EV_ABS && entry.kind === "absolute") {
148
+ const range = code === ABS_X ? entry.ranges.x : code === ABS_Y ? entry.ranges.y : null;
149
+ if (!range) return;
150
+ const span = range.maximum - range.minimum;
151
+ const fraction = (value - range.minimum) / span;
152
+ if (code === ABS_X) state.x = clamp(Math.round(fraction * width), width);
153
+ else state.y = clamp(Math.round(fraction * height), height);
154
+ state.moved = true;
155
+ return;
156
+ }
157
+ if (type === EV_KEY) {
158
+ const button = buttonNames[code];
159
+ if (!button) return;
160
+ const down = value !== 0;
161
+ if (state.buttons[button] === down) return; // key repeat
162
+ state.buttons[button] = down;
163
+ (down ? state.pressed : state.released).push(button);
164
+ }
165
+ };
166
+
167
+ const flush = () => {
168
+ const report = {
169
+ x: state.x,
170
+ y: state.y,
171
+ buttons: { ...state.buttons },
172
+ wheel: state.wheel,
173
+ hwheel: state.hwheel,
174
+ moved: state.moved,
175
+ pressed: state.pressed,
176
+ released: state.released,
177
+ };
178
+ state.wheel = 0;
179
+ state.hwheel = 0;
180
+ state.moved = false;
181
+ state.pressed = [];
182
+ state.released = [];
183
+ if (report.moved || report.wheel || report.hwheel || report.pressed.length || report.released.length) {
184
+ try { onSync(report); } catch (error) { onError(error); }
185
+ }
186
+ };
187
+
188
+ const pump = (entry) => {
189
+ if (!entry.reading) return;
190
+ read(entry.fd, entry.buffer, 0, entry.buffer.length, null, (error, count) => {
191
+ if (!entry.reading) return;
192
+ if (error) {
193
+ if (error.code === "EAGAIN") { setTimeout(() => pump(entry), 10); return; }
194
+ entry.reading = false;
195
+ onError(new Error(`${entry.path}: ${error.message}`));
196
+ return;
197
+ }
198
+ for (let offset = 0; offset + EVENT_SIZE <= count; offset += EVENT_SIZE) {
199
+ const type = entry.buffer.readUInt16LE(offset + 16);
200
+ const code = entry.buffer.readUInt16LE(offset + 18);
201
+ const value = entry.buffer.readInt32LE(offset + 20);
202
+ if (type === EV_SYN) flush();
203
+ else apply(entry, type, code, value);
204
+ }
205
+ pump(entry);
206
+ });
207
+ };
208
+
209
+ for (const entry of open) pump(entry);
210
+
211
+ return {
212
+ devices: open.map(({ path, name, kind }) => ({ path, name, kind })),
213
+ state,
214
+ close() {
215
+ for (const entry of open) {
216
+ entry.reading = false;
217
+ try { closeSync(entry.fd); } catch {}
218
+ }
219
+ open.length = 0;
220
+ },
221
+ };
222
+ };
@@ -105,6 +105,7 @@ export class Renderer {
105
105
  this.forcedRows = new Set();
106
106
  this.dirtyAll = true;
107
107
  this.lastCursor = { x: -1, y: -1, visible: false };
108
+ this.lastPointer = null;
108
109
  }
109
110
 
110
111
  fontFor(typeface, size, { bold = false, italic = false } = {}) {
@@ -355,11 +356,46 @@ export class Renderer {
355
356
  }
356
357
  }
357
358
 
359
+ // Draws the mouse pointer at (x, y) in pixels: an arrow outlined in the
360
+ // background colour so it stays visible over any cell.
361
+ drawPointer({ x, y }) {
362
+ const { CanvasKit, canvas } = this;
363
+ const size = Math.max(10, Math.round(this.fontSize * 0.9));
364
+ const builder = new CanvasKit.PathBuilder();
365
+ builder.moveTo(x, y);
366
+ builder.lineTo(x, y + size);
367
+ builder.lineTo(x + size * 0.28, y + size * 0.74);
368
+ builder.lineTo(x + size * 0.46, y + size * 1.08);
369
+ builder.lineTo(x + size * 0.63, y + size * 1.0);
370
+ builder.lineTo(x + size * 0.45, y + size * 0.66);
371
+ builder.lineTo(x + size * 0.72, y + size * 0.66);
372
+ builder.close();
373
+ const path = builder.detach();
374
+ builder.delete();
375
+ const outline = this.paintFor(this.background).copy();
376
+ outline.setStyle(CanvasKit.PaintStyle.Stroke);
377
+ outline.setStrokeWidth(Math.max(1, size / 8));
378
+ canvas.drawPath(path, outline);
379
+ canvas.drawPath(path, this.paintFor(this.cursorColor));
380
+ outline.delete();
381
+ path.delete();
382
+ }
383
+
384
+ // The rows an arrow at (x, y) covers, so they are repainted when it moves.
385
+ pointerRows({ x, y }) {
386
+ void x;
387
+ const size = Math.max(10, Math.round(this.fontSize * 0.9));
388
+ const first = Math.floor((y - this.offsetY) / this.cellHeight);
389
+ const last = Math.ceil((y + size * 1.1 - this.offsetY) / this.cellHeight);
390
+ return [first, last];
391
+ }
392
+
358
393
  // Repaints what changed and flushes to the framebuffer. `cursor` is
359
394
  // { x, y, visible }; `images` is a list of { image, rect } placements in
360
- // screen pixels, from images.js, drawn over the text. Returns true when
361
- // anything was painted.
362
- render(term, { cursor, images = [] } = {}) {
395
+ // screen pixels, from images.js, drawn over the text; `pointer` is the
396
+ // mouse position in pixels, drawn last. Returns true when anything was
397
+ // painted.
398
+ render(term, { cursor, images = [], pointer = null } = {}) {
363
399
  const buffer = term.buffer.active;
364
400
  const top = buffer.viewportY;
365
401
  const dirty = [];
@@ -375,6 +411,13 @@ export class Renderer {
375
411
  const cursorRows = new Set();
376
412
  if (this.lastCursor.visible) cursorRows.add(this.lastCursor.y);
377
413
  if (cursor?.visible) cursorRows.add(cursor.y);
414
+ // Wherever the pointer was and is has to be repainted: the text under it
415
+ // has not changed, so no row hash would say so.
416
+ for (const position of [this.lastPointer, pointer]) {
417
+ if (!position) continue;
418
+ const [first, last] = this.pointerRows(position);
419
+ for (let row = Math.max(0, first); row < Math.min(this.rows, last); row++) cursorRows.add(row);
420
+ }
378
421
  for (const row of cursorRows) {
379
422
  if (row >= 0 && row < this.rows && !dirty.includes(row)) dirty.push(row);
380
423
  }
@@ -390,6 +433,8 @@ export class Renderer {
390
433
  }
391
434
  this.lastCursor = { x: cursor?.x ?? -1, y: cursor?.y ?? -1, visible: Boolean(cursor?.visible) };
392
435
  if (images.length) this.drawImages(images, dirty);
436
+ if (pointer) this.drawPointer(pointer);
437
+ this.lastPointer = pointer ? { x: pointer.x, y: pointer.y } : null;
393
438
  this.screen.flush();
394
439
  return true;
395
440
  }
@@ -50,6 +50,7 @@ export const createSession = ({
50
50
  theme = defaultTheme,
51
51
  scrollback = 1000,
52
52
  cursorBlink = true,
53
+ mouse = true,
53
54
  env = {},
54
55
  onError = (error) => console.error(`bunterm: ${error?.stack ?? error}`),
55
56
  }) => {
@@ -82,13 +83,24 @@ export const createSession = ({
82
83
  let frameTimer = null;
83
84
  let blinkOn = true;
84
85
  const cursorHidden = () => Boolean(term._core?.coreService?.isCursorHidden);
86
+ // Only set when a pointer is attached; without one the renderer is called
87
+ // exactly as before.
88
+ let pointer = null;
85
89
  const paint = () => {
86
90
  if (frameTimer) clearTimeout(frameTimer);
87
91
  frameTimer = null;
88
92
  const buffer = term.buffer.active;
93
+ // cursorY counts from the buffer's base, so a scrolled-back viewport
94
+ // moves the cursor down the screen and eventually off it.
95
+ const row = buffer.baseY + buffer.cursorY - buffer.viewportY;
89
96
  return renderer.render(term, {
90
- cursor: { x: buffer.cursorX, y: buffer.cursorY, visible: blinkOn && !cursorHidden() },
97
+ cursor: {
98
+ x: buffer.cursorX,
99
+ y: row,
100
+ visible: blinkOn && !cursorHidden() && row >= 0 && row < renderer.rows,
101
+ },
91
102
  images: images.visible(),
103
+ pointer,
92
104
  });
93
105
  };
94
106
  const requestFrame = () => {
@@ -137,10 +149,102 @@ export const createSession = ({
137
149
  const applicationCursorKeys = Boolean(term._core?.coreService?.decPrivateModes?.applicationCursorKeys);
138
150
  const text = translateKeys(bytes.toString("latin1"), { applicationCursorKeys });
139
151
  backend.write(Buffer.from(text, "latin1"));
152
+ // Typing returns to the live screen, as every terminal does.
153
+ if (term.buffer.active.viewportY !== term.buffer.active.baseY) {
154
+ term.scrollToBottom();
155
+ renderer.invalidate();
156
+ }
140
157
  resetBlink();
141
158
  requestFrame();
142
159
  };
143
160
 
161
+ // Turns a pointer report from mouse.js into the mouse sequences the
162
+ // program asked for. xterm.js's CoreMouseService owns the protocol: it
163
+ // knows which events the program enabled (DECSET 1000/1002/1003), encodes
164
+ // them (SGR 1006 or the default form) and sends them through onData, so
165
+ // nothing here builds an escape sequence. Reports arrive only while a
166
+ // pointer is open, and this is the only place the terminal sees one.
167
+ const CoreMouseButton = { LEFT: 0, MIDDLE: 1, RIGHT: 2, NONE: 3, WHEEL: 4 };
168
+ const CoreMouseAction = { UP: 0, DOWN: 1, LEFT: 2, RIGHT: 3, MOVE: 32 };
169
+ const heldButton = (buttons) => (buttons.left ? CoreMouseButton.LEFT
170
+ : buttons.middle ? CoreMouseButton.MIDDLE
171
+ : buttons.right ? CoreMouseButton.RIGHT
172
+ : CoreMouseButton.NONE);
173
+
174
+ // Scrolls the viewport when the program is not reading the mouse itself:
175
+ // through the scrollback on the normal screen, and as arrow keys on the
176
+ // alternate screen, which has no scrollback and is what a pager expects.
177
+ const wheelScroll = (notches) => {
178
+ const lines = notches * 3;
179
+ if (term.buffer.active.type === "alternate") {
180
+ const application = Boolean(term._core?.coreService?.decPrivateModes?.applicationCursorKeys);
181
+ const key = `\u001b${application ? "O" : "["}${lines > 0 ? "A" : "B"}`;
182
+ backend.write(Buffer.from(key.repeat(Math.abs(lines)), "latin1"));
183
+ return;
184
+ }
185
+ const before = term.buffer.active.viewportY;
186
+ term.scrollLines(-lines);
187
+ if (term.buffer.active.viewportY !== before) {
188
+ // Every row on screen is different now, and identical text at the same
189
+ // row would otherwise hash the same as before.
190
+ renderer.invalidate();
191
+ requestFrame();
192
+ }
193
+ };
194
+
195
+ const reportMouse = (report) => {
196
+ pointer = { x: report.x, y: report.y };
197
+ requestFrame();
198
+ const service = term._core?.coreMouseService;
199
+ if (!service?.areMouseEventsActive) {
200
+ if (report.wheel) wheelScroll(report.wheel);
201
+ return;
202
+ }
203
+ const column = Math.floor((report.x - renderer.offsetX) / renderer.cellWidth);
204
+ const row = Math.floor((report.y - renderer.offsetY) / renderer.cellHeight);
205
+ const send = (button, action) => {
206
+ try {
207
+ service.triggerMouseEvent({ col: column, row, button, action, ctrl: false, alt: false, shift: false });
208
+ } catch (error) {
209
+ onError(error);
210
+ }
211
+ };
212
+ for (const name of report.released) {
213
+ send(CoreMouseButton[name.toUpperCase()], CoreMouseAction.UP);
214
+ }
215
+ for (const name of report.pressed) {
216
+ send(CoreMouseButton[name.toUpperCase()], CoreMouseAction.DOWN);
217
+ }
218
+ if (report.moved) send(heldButton(report.buttons), CoreMouseAction.MOVE);
219
+ for (let notch = 0; notch < Math.abs(report.wheel); notch++) {
220
+ send(CoreMouseButton.WHEEL, report.wheel > 0 ? CoreMouseAction.UP : CoreMouseAction.DOWN);
221
+ }
222
+ for (let notch = 0; notch < Math.abs(report.hwheel); notch++) {
223
+ send(CoreMouseButton.WHEEL, report.hwheel > 0 ? CoreMouseAction.RIGHT : CoreMouseAction.LEFT);
224
+ }
225
+ };
226
+
227
+ // The pointer is opened only when asked for, by a module that is imported
228
+ // only then, so a machine without one — or a broken input layer — cannot
229
+ // affect a plain session.
230
+ let pointerDevice = null;
231
+ const attachPointer = async () => {
232
+ if (!mouse) return null;
233
+ const { openPointer, findPointers } = await import("./mouse.js");
234
+ const devices = findPointers();
235
+ if (!devices.length) throw new Error("no pointing device in /dev/input (is the evdev module loaded?)");
236
+ pointerDevice = openPointer({
237
+ devices,
238
+ width: screen.width,
239
+ height: screen.height,
240
+ onSync: reportMouse,
241
+ onError,
242
+ });
243
+ pointer = { x: pointerDevice.state.x, y: pointerDevice.state.y };
244
+ paint();
245
+ return pointerDevice;
246
+ };
247
+
144
248
  // Waits until every byte received so far has been parsed, then paints.
145
249
  const settle = async () => {
146
250
  await queue;
@@ -154,13 +258,18 @@ export const createSession = ({
154
258
  if (frameTimer) clearTimeout(frameTimer);
155
259
  frameTimer = null;
156
260
  backend.close();
261
+ pointerDevice?.close();
262
+ pointerDevice = null;
157
263
  images.delete();
158
264
  renderer.delete();
159
265
  term.dispose();
160
266
  };
161
267
 
162
268
  paint();
163
- return { term, backend, renderer, images, kitty, paint, requestFrame, settle, input, exited, close };
269
+ return {
270
+ term, backend, renderer, images, kitty,
271
+ paint, requestFrame, settle, input, exited, close, attachPointer, reportMouse, wheelScroll,
272
+ };
164
273
  };
165
274
 
166
275
  // Runs a session on the framebuffer with the console in graphics mode and
@@ -193,6 +302,18 @@ export const runTerminal = async ({ console: consoleDevice = null, device = "/de
193
302
  },
194
303
  });
195
304
  onRestore(() => keyboard.close());
305
+ if (options.mouse !== false) {
306
+ // A pointer that cannot be opened is reported and the session
307
+ // continues as a keyboard-only terminal.
308
+ try {
309
+ const attached = await session.attachPointer();
310
+ if (attached?.devices.length) {
311
+ console.error(`bunterm: mouse: ${attached.devices.map((device) => device.name).join(", ")}`);
312
+ }
313
+ } catch (error) {
314
+ console.error(`bunterm: mouse: ${error.message}`);
315
+ }
316
+ }
196
317
  return await session.exited;
197
318
  } finally {
198
319
  keyboard?.close();
@@ -85,6 +85,10 @@ export const libc = dlopen(libcPath, {
85
85
  args: [FFIType.i32, FFIType.ptr, FFIType.i32],
86
86
  returns: FFIType.i32,
87
87
  },
88
+ chroot: {
89
+ args: [FFIType.ptr],
90
+ returns: FFIType.i32,
91
+ },
88
92
  });
89
93
 
90
94
  export const cString = (value) => new TextEncoder().encode(`${value}\0`);
@@ -1,6 +1,10 @@
1
1
  // Shared renderer for /usr/share/doc/buninu-linux/<name>.md command manuals.
2
+ import { resolve } from "node:path";
3
+
2
4
  export const showDocument = async (name) => {
3
- const path = `${import.meta.dir}/../usr/share/doc/buninu-linux/${name}.md`;
5
+ const path = resolve(`${import.meta.dir}/../usr/share/doc/buninu-linux/${name}.md`);
4
6
  const rendered = Bun.markdown.ansi(await Bun.file(path).text(), { hyperlinks: true });
5
7
  process.stdout.write(rendered.endsWith("\n") ? rendered : `${rendered}\n`);
8
+ // Where the page lives, for anyone who wants to read or edit the source.
9
+ process.stdout.write(`\u001b[2m${path}\u001b[0m\n`);
6
10
  };
@@ -10,6 +10,7 @@ bunterm # run /bin/sh on the console you are on
10
10
  bunterm /dev/tty1 # use virtual console 1 for graphics and keys
11
11
  bunterm --font-size 20
12
12
  bunterm -e bun /buninu/apps/jsmdcui/src/index.js --demo
13
+ bunterm --no-mouse # keyboard only
13
14
  bunterm -h
14
15
  ```
15
16
 
@@ -28,6 +29,7 @@ text mode and `bunterm` exits with its status.
28
29
  | `--line-height F` | Multiplies the cell height (default 1). |
29
30
  | `--fb /dev/fbN` | The framebuffer device (default `/dev/fb0`). |
30
31
  | `--no-blink` | A steady cursor. |
32
+ | `--no-mouse` | Do not attach a pointing device; nothing then reads `/dev/input`. |
31
33
 
32
34
  ## What is drawn
33
35
 
@@ -59,13 +61,40 @@ screen. A few console-specific
59
61
  sequences are rewritten to xterm's (`F1`–`F5`, `Home`, `End`), and arrow keys
60
62
  follow the program's application cursor mode.
61
63
 
64
+ ## Mouse
65
+
66
+ Every pointing device under `/dev/input/event*` is opened and an arrow is
67
+ drawn on the screen. Clicks, drags and wheel notches reach the program as
68
+ the xterm mouse sequences it asked for: nothing is sent unless the program
69
+ enabled reporting (`DECSET 1000`, `1002` or `1003`), and what is sent follows
70
+ the mode it chose, SGR (`1006`) included. `jsmdcui`, `micro`, `htop`, `vim`
71
+ and anything else that speaks the protocol work with no configuration.
72
+
73
+ When the program is not reading the mouse, the wheel scrolls instead: back
74
+ through the scrollback on the normal screen, three lines per notch, and as
75
+ arrow keys on the alternate screen, which has no scrollback and is what a
76
+ pager expects. Typing returns to the live screen, and the text cursor is
77
+ hidden while it is scrolled out of view.
78
+
79
+ `--no-mouse` turns all of this off: the terminal then never looks at
80
+ `/dev/input` and never loads the mouse module.
81
+
82
+ `init.js` loads `evdev` and `psmouse` at boot, so a PS/2 mouse — which is
83
+ what QEMU's q35 machine and most PCs present — has a device node ready. A USB
84
+ mouse also needs the `usbhid` stack, which a `--real` image carries and
85
+ `cfg.all` loads. The pointer follows relative devices (a mouse) and absolute
86
+ ones (a tablet or touchscreen) alike; a device that cannot be opened is
87
+ reported and the session continues without it.
88
+
62
89
  ## Limits
63
90
 
64
91
  - Needs a framebuffer: `linux-lts` has one built in, while the default
65
92
  `linux-virt` builds it as modules the image does not ship, so build with
66
93
  `--linux-lts` or `--real` (efifb/simpledrm from UEFI).
67
- - No mouse, no selection, no scrollback viewing (the buffer keeps 1000
68
- lines for programs that query it).
94
+ - No selection, and no keyboard way to scroll back: the wheel is it. The
95
+ buffer keeps 1000 lines. A program that does not enable mouse reporting
96
+ sees no clicks — the arrow still moves, so the pointer is visibly alive.
97
+ `--no-mouse` removes the pointer entirely.
69
98
  - A program that prints a terminal reply while the tty echoes (that is, one
70
99
  not in raw mode) will see the reply echoed, as on any terminal.
71
100
  - Kitty images are drawn above text; `z` ordering below text and animation