Skip to content
Merged
3 changes: 3 additions & 0 deletions packages/@wterm/core/src/terminal-core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,9 @@ export interface TerminalCore {
cursorKeysApp(): boolean;
bracketedPaste(): boolean;
usingAltScreen(): boolean;
mouseTracking?(): 0 | 1000 | 1002;
mouseSgr?(): boolean;
focusEvents?(): boolean;

// -- Side outputs --
getTitle(): string | null;
Expand Down
13 changes: 13 additions & 0 deletions packages/@wterm/core/src/wasm-bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ interface WasmExports {
getCursorKeysApp(): number;
getBracketedPaste(): number;
getUsingAltScreen(): number;
getMouseTracking(): number;
getMouseSgr(): number;
getFocusEvents(): number;
getTitlePtr(): number;
getTitleLen(): number;
getTitleChanged(): number;
Expand Down Expand Up @@ -164,6 +167,16 @@ export class WasmBridge implements TerminalCore {
usingAltScreen(): boolean {
return this.exports.getUsingAltScreen() !== 0;
}
mouseTracking(): 0 | 1000 | 1002 {
const mode = this.exports.getMouseTracking();
return mode === 1000 || mode === 1002 ? mode : 0;
}
mouseSgr(): boolean {
return this.exports.getMouseSgr() !== 0;
}
focusEvents(): boolean {
return this.exports.getFocusEvents() !== 0;
}

getTitle(): string | null {
if (this.exports.getTitleChanged() === 0) return null;
Expand Down
Binary file modified packages/@wterm/core/wasm/wterm.wasm
Binary file not shown.
86 changes: 86 additions & 0 deletions packages/@wterm/dom/src/__tests__/input-mouse.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import type { TerminalCore } from "@wterm/core";
import { InputHandler } from "../input.js";

describe("InputHandler mouse and focus modes", () => {
let container: HTMLDivElement;
let received: string[];
let handler: InputHandler;
let core: TerminalCore;

beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
Object.defineProperty(container, "getBoundingClientRect", {
value: () => ({
left: 10,
top: 20,
width: 800,
height: 400,
}),
});
received = [];
core = {
getCols: () => 80,
getRows: () => 40,
mouseTracking: () => 1002,
mouseSgr: () => true,
focusEvents: () => true,
} as unknown as TerminalCore;
handler = new InputHandler(
container,
(data) => received.push(data),
() => core,
);
});

afterEach(() => {
handler.destroy();
container.remove();
});

it("encodes SGR press, drag, release, and wheel", () => {
container.dispatchEvent(
new MouseEvent("mousedown", {
button: 0,
buttons: 1,
clientX: 85,
clientY: 65,
}),
);
container.dispatchEvent(
new MouseEvent("mousemove", { buttons: 1, clientX: 105, clientY: 75 }),
);
container.dispatchEvent(
new MouseEvent("mouseup", { button: 0, clientX: 105, clientY: 75 }),
);
const wheel = new WheelEvent("wheel", {
deltaY: 100,
clientX: 105,
clientY: 75,
cancelable: true,
});
container.dispatchEvent(wheel);

expect(received).toEqual([
"\x1b[<0;8;5M",
"\x1b[<32;10;6M",
"\x1b[<0;10;6m",
"\x1b[<65;10;6M",
]);
expect(wheel.defaultPrevented).toBe(true);
});

it("emits focus reports only when mode 1004 is enabled", () => {
const textarea = container.querySelector("textarea")!;
textarea.dispatchEvent(new FocusEvent("focus"));
textarea.dispatchEvent(new FocusEvent("blur"));
expect(received).toEqual(["\x1b[I", "\x1b[O"]);

received = [];
core.focusEvents = () => false;
textarea.dispatchEvent(new FocusEvent("focus"));
textarea.dispatchEvent(new FocusEvent("blur"));
expect(received).toEqual([]);
});
});
75 changes: 73 additions & 2 deletions packages/@wterm/dom/src/input.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,10 @@ export class InputHandler {
private _onInput: () => void;
private _onFocus: () => void;
private _onBlur: () => void;
private _onMouseDown: (e: MouseEvent) => void;
private _onMouseMove: (e: MouseEvent) => void;
private _onMouseUp: (e: MouseEvent) => void;
private _onWheel: (e: WheelEvent) => void;

constructor(
element: HTMLElement,
Expand Down Expand Up @@ -97,8 +101,18 @@ export class InputHandler {
this._onCompositionStart = this.handleCompositionStart.bind(this);
this._onCompositionEnd = this.handleCompositionEnd.bind(this);
this._onInput = this.handleInput.bind(this);
this._onFocus = () => this.element.classList.add("focused");
this._onBlur = () => this.element.classList.remove("focused");
this._onFocus = () => {
this.element.classList.add("focused");
if (this.getBridge()?.focusEvents?.()) this.onData("\x1b[I");
};
this._onBlur = () => {
this.element.classList.remove("focused");
if (this.getBridge()?.focusEvents?.()) this.onData("\x1b[O");
};
this._onMouseDown = (event) => this.handleMouse(event, "press");
this._onMouseMove = (event) => this.handleMouse(event, "move");
this._onMouseUp = (event) => this.handleMouse(event, "release");
this._onWheel = (event) => this.handleMouse(event, "wheel");

this.textarea.addEventListener("keydown", this._onKeyDown);
this.textarea.addEventListener("paste", this._onPaste as EventListener);
Expand All @@ -113,6 +127,10 @@ export class InputHandler {
this.textarea.addEventListener("input", this._onInput);
this.textarea.addEventListener("focus", this._onFocus);
this.textarea.addEventListener("blur", this._onBlur);
this.element.addEventListener("mousedown", this._onMouseDown);
this.element.addEventListener("mousemove", this._onMouseMove);
this.element.addEventListener("mouseup", this._onMouseUp);
this.element.addEventListener("wheel", this._onWheel, { passive: false });
}

focus(): void {
Expand All @@ -133,6 +151,10 @@ export class InputHandler {
this.textarea.removeEventListener("input", this._onInput);
this.textarea.removeEventListener("focus", this._onFocus);
this.textarea.removeEventListener("blur", this._onBlur);
this.element.removeEventListener("mousedown", this._onMouseDown);
this.element.removeEventListener("mousemove", this._onMouseMove);
this.element.removeEventListener("mouseup", this._onMouseUp);
this.element.removeEventListener("wheel", this._onWheel);
this.element.classList.remove("focused");
this.textarea.remove();
}
Expand Down Expand Up @@ -205,6 +227,55 @@ export class InputHandler {
}
}

private handleMouse(
event: MouseEvent | WheelEvent,
kind: "press" | "move" | "release" | "wheel",
): void {
const bridge = this.getBridge();
const tracking = bridge?.mouseTracking?.() ?? 0;
if (!bridge || tracking === 0 || !bridge.mouseSgr?.()) return;
if (kind === "move" && (tracking !== 1002 || event.buttons === 0)) return;

const rect =
this.element
.querySelector<HTMLElement>(".term-grid")
?.getBoundingClientRect() ?? this.element.getBoundingClientRect();
if (rect.width <= 0 || rect.height <= 0) return;
const col = Math.max(
1,
Math.min(
bridge.getCols(),
Math.floor(
((event.clientX - rect.left) / rect.width) * bridge.getCols(),
) + 1,
),
);
const row = Math.max(
1,
Math.min(
bridge.getRows(),
Math.floor(
((event.clientY - rect.top) / rect.height) * bridge.getRows(),
) + 1,
),
);
const modifiers =
(event.shiftKey ? 4 : 0) |
(event.altKey ? 8 : 0) |
(event.ctrlKey ? 16 : 0);
let code: number;
let final = "M";
if (kind === "wheel") {
code = ((event as WheelEvent).deltaY < 0 ? 64 : 65) | modifiers;
Comment thread
vercel[bot] marked this conversation as resolved.
Outdated
} else {
const button = event.button === 1 ? 1 : event.button === 2 ? 2 : 0;
Comment thread
vercel[bot] marked this conversation as resolved.
Outdated
code = button | modifiers | (kind === "move" ? 32 : 0);
if (kind === "release") final = "m";
}
event.preventDefault();
this.onData(`\x1b[<${code};${col};${row}${final}`);
}

private keyToSequence(e: KeyboardEvent): string | null {
if (e.ctrlKey && !e.altKey && !e.metaKey) {
if (e.key.length === 1) {
Expand Down
36 changes: 36 additions & 0 deletions src/terminal.zig
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,9 @@ pub const Terminal = struct {
origin_mode: bool = false,
cursor_keys_app: bool = false,
bracketed_paste: bool = false,
mouse_tracking: u16 = 0,
mouse_sgr: bool = false,
focus_events: bool = false,
linefeed_mode: bool = false,

// Alternate screen buffer (pointer to avoid doubling struct size)
Expand Down Expand Up @@ -233,6 +236,9 @@ pub const Terminal = struct {
self.origin_mode = false;
self.cursor_keys_app = false;
self.bracketed_paste = false;
self.mouse_tracking = 0;
self.mouse_sgr = false;
self.focus_events = false;
self.linefeed_mode = false;
self.alt_saved_cursor_row = 0;
self.alt_saved_cursor_col = 0;
Expand Down Expand Up @@ -604,6 +610,10 @@ pub const Terminal = struct {
20 => self.linefeed_mode = enabled,
25 => self.cursor_visible = enabled,
47 => self.switchScreen(enabled, false),
1000 => self.setMouseTracking(1000, enabled),
1002 => self.setMouseTracking(1002, enabled),
1004 => self.focus_events = enabled,
1006 => self.mouse_sgr = enabled,
1047 => self.switchScreen(enabled, false),
1048 => {
if (enabled) self.saveCursor() else self.restoreCursor();
Expand All @@ -615,6 +625,14 @@ pub const Terminal = struct {
}
}

fn setMouseTracking(self: *Terminal, mode: u16, enabled: bool) void {
if (enabled) {
self.mouse_tracking = mode;
} else if (self.mouse_tracking == mode) {
self.mouse_tracking = 0;
}
}

fn switchScreen(self: *Terminal, alt: bool, save_cursor: bool) void {
if (alt == self.using_alt_screen) return;
const ag = self.alt_grid orelse return;
Expand Down Expand Up @@ -660,6 +678,9 @@ pub const Terminal = struct {
self.auto_wrap = true;
self.cursor_keys_app = false;
self.bracketed_paste = false;
self.mouse_tracking = 0;
self.mouse_sgr = false;
self.focus_events = false;
self.scroll_top = 0;
self.scroll_bottom = self.rows;
self.resetStyle();
Expand Down Expand Up @@ -1199,6 +1220,21 @@ test "alternate screen buffer" {
try testing.expectEqual(@as(u32, 'm'), t.grid.getCell(0, 0).char);
}

test "tracks mouse and focus modes across reset" {
const testing = @import("std").testing;
var t = Terminal.init(80, 24);
t.write("\x1b[?1000h\x1b[?1004h\x1b[?1006h");
try testing.expectEqual(@as(u16, 1000), t.mouse_tracking);
try testing.expect(t.mouse_sgr);
try testing.expect(t.focus_events);
t.write("\x1b[?1002h\x1b[?1000l");
try testing.expectEqual(@as(u16, 1002), t.mouse_tracking);
t.write("\x1b[!p");
try testing.expectEqual(@as(u16, 0), t.mouse_tracking);
try testing.expect(!t.mouse_sgr);
try testing.expect(!t.focus_events);
}

test "erase inherits current background color" {
const testing = @import("std").testing;
var t = Terminal.init(80, 24);
Expand Down
12 changes: 12 additions & 0 deletions src/wasm_api.zig
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,18 @@ export fn getUsingAltScreen() u32 {
return if (terminal.using_alt_screen) 1 else 0;
}

export fn getMouseTracking() u32 {
return terminal.mouse_tracking;
}

export fn getMouseSgr() u32 {
return if (terminal.mouse_sgr) 1 else 0;
}

export fn getFocusEvents() u32 {
return if (terminal.focus_events) 1 else 0;
}

// -- Title --

export fn getTitlePtr() [*]const u8 {
Expand Down