Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 56 additions & 1 deletion desktop/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,31 @@ const path = require("node:path");
if (process.platform === "linux") app.commandLine.appendSwitch("no-sandbox");
app.setName("hqterm");

// Speed: xterm 6 draws fast only through WebGL, so the GPU has to be on.
// Chromium blocklists many Linux GPU/driver combinations and then every pane
// falls back to the DOM renderer, where redraws and resizes crawl. Opt out of
// the blocklist and run natively on Wayland.
// `"gpu": false` in ~/.config/hqterm/desktop.json turns all of this off.
const GPU_OFF = (() => {
try {
const p = require("node:path").join(process.env.XDG_CONFIG_HOME || require("node:path").join(require("node:os").homedir(), ".config"), "hqterm", "desktop.json");
return JSON.parse(require("node:fs").readFileSync(p, "utf8")).gpu === false;
} catch {
return false;
}
})();
if (GPU_OFF) {
app.disableHardwareAcceleration();
} else {
// Only the blocklist: forcing GPU rasterization/zero-copy breaks the
// compositor where there is no real GPU (UnknownVizError under Xvfb).
app.commandLine.appendSwitch("ignore-gpu-blocklist");
if (process.platform === "linux" && (process.env.WAYLAND_DISPLAY || process.env.XDG_SESSION_TYPE === "wayland")) {
app.commandLine.appendSwitch("ozone-platform-hint", "auto");
app.commandLine.appendSwitch("enable-features", "WaylandWindowDecorations");
}
}

const VERSION = require("./package.json").version;
const HOST_RE = /^[A-Za-z0-9._@:\-\[\]]+$/;
const SESSION_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
Expand All @@ -33,6 +58,7 @@ function loadConfig() {
fontSize: Number.isFinite(c.fontSize) && c.fontSize >= 6 && c.fontSize <= 72 ? c.fontSize : 14,
theme: c.theme && typeof c.theme === "object" ? c.theme : undefined,
restore: c.restore !== false,
gpu: c.gpu !== false,
};
}

Expand Down Expand Up @@ -194,10 +220,28 @@ ipcMain.handle("pty:spawn", (e, { spec, cols, rows }) => {
});
ptys.set(id, p);
const sender = e.sender;
// Coalesce output: one IPC message per pane every few ms, not one per read.
// A full-screen redraw arrives as dozens of small reads; sent one by one
// they each cost a renderer task and a partial repaint you can watch.
let pending = "";
let timer = null;
const flush = () => {
timer = null;
if (pending && !sender.isDestroyed()) sender.send("pty:data", id, pending);
pending = "";
};
p.onData((data) => {
if (!sender.isDestroyed()) sender.send("pty:data", id, data);
pending += data;
if (pending.length >= 256 * 1024) {
if (timer) clearTimeout(timer);
flush();
} else if (!timer) {
timer = setTimeout(flush, 4);
}
});
p.onExit(({ exitCode, signal }) => {
if (timer) clearTimeout(timer);
flush();
ptys.delete(id);
if (!sender.isDestroyed()) sender.send("pty:exit", id, exitCode, signal);
});
Expand Down Expand Up @@ -252,6 +296,17 @@ if (!app.requestSingleInstanceLock()) {
// No menu: Ctrl+W, Ctrl+R and friends belong to the shell.
Menu.setApplicationMenu(null);
createWindow();
// What the GPU is doing, for "why is it slow": ~/.config/hqterm/desktop.log.
try {
fs.mkdirSync(configDir(), { recursive: true });
const st = app.getGPUFeatureStatus();
fs.writeFileSync(
path.join(configDir(), "desktop.log"),
`${new Date().toISOString()} hqterm desktop ${VERSION}\n` +
`gpu: ${GPU_OFF ? "off (desktop.json)" : "on"}; webgl=${st.webgl} webgl2=${st.webgl2} rasterization=${st.gpu_compositing}/${st.rasterization}\n` +
`session: ${process.env.XDG_SESSION_TYPE || "?"} wayland=${process.env.WAYLAND_DISPLAY ? "yes" : "no"}\n`,
);
} catch {}
if (process.env.HQTERM_DESKTOP_SMOKE) {
// CI: prove the window and a PTY come up, draw emoji and an iTerm2
// inline image, optionally screenshot it ($HQTERM_DESKTOP_SMOKE_SHOT), exit.
Expand Down
4 changes: 2 additions & 2 deletions desktop/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion desktop/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "hqterm-desktop",
"productName": "hqterm",
"version": "0.2.3",
"version": "0.2.4",
"description": "hqterm desktop: a terminal window that shows hqtui/qc images and HD emoji (xterm.js + node-pty).",
"license": "MIT",
"author": "Profullstack, Inc. <hello@profullstack.com>",
Expand Down
75 changes: 56 additions & 19 deletions desktop/src/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,13 @@
if (rt.ptyId !== undefined) hq.write(rt.ptyId, d);
});
term.onBinary((d) => rt.ptyId !== undefined && hq.write(rt.ptyId, d));
term.onResize(({ cols, rows }) => rt.ptyId !== undefined && hq.resize(rt.ptyId, cols, rows));
// Tell the shell the new size only once it has settled: every PTY resize
// makes a full-screen program (and hqsh's remote side) redraw everything,
// so resizing per frame while dragging floods it with redraws.
term.onResize(({ cols, rows }) => {
clearTimeout(rt.ptyResizeTimer);
rt.ptyResizeTimer = setTimeout(() => rt.ptyId !== undefined && hq.resize(rt.ptyId, cols, rows), 80);
});
term.onTitleChange((t) => {
rt.title = t;
renderBar();
Expand All @@ -124,20 +130,7 @@
if (rt.opened) return;
rt.opened = true;
rt.term.open(rt.el);
if (webglCount < MAX_WEBGL) {
try {
const gl = new WebglAddon.WebglAddon();
gl.onContextLoss(() => {
gl.dispose();
webglCount--;
});
rt.term.loadAddon(gl);
webglCount++;
rt.webgl = gl;
} catch {
// DOM renderer it is.
}
}
loadWebgl(rt);
// After the renderer, so images draw on top of it.
const images = new ImageAddon.ImageAddon({ iipSupport: true, sixelSupport: true, enableSizeReports: true });
rt.term.loadAddon(images);
Expand All @@ -146,6 +139,35 @@
spawn(rt);
}

/**
* The fast renderer. Without it xterm falls back to the DOM, where redraws
* are visibly slow, so say so once instead of silently crawling, and after a
* lost GPU context try WebGL again rather than staying on the slow path.
*/
function loadWebgl(rt) {
if (rt.disposed || webglCount >= MAX_WEBGL) return slowRenderer(rt);
try {
const gl = new WebglAddon.WebglAddon();
gl.onContextLoss(() => {
gl.dispose();
webglCount--;
rt.webgl = undefined;
setTimeout(() => loadWebgl(rt), 1000);
});
rt.term.loadAddon(gl);
webglCount++;
rt.webgl = gl;
} catch {
slowRenderer(rt);
}
}

function slowRenderer(rt) {
if (rt.warnedSlow) return;
rt.warnedSlow = true;
rt.term.write("\x1b[2mhqterm: GPU rendering is unavailable here, so this pane uses the slower renderer (see ~/.config/hqterm/desktop.log).\x1b[0m\r\n");
}

/**
* iTerm2, WezTerm and kitty leave the cursor just right of an inline image,
* and hqtui's emoji art relies on it ("hi " + art + " there"). addon-image
Expand Down Expand Up @@ -182,17 +204,32 @@
} catch {}
}

/**
* Refit after a size change, at most every FIT_EVERY ms while a window or
* divider is being dragged, plus once when it stops. A fit reflows the whole
* scrollback; doing it on every frame is what made resizing judder.
*/
const FIT_EVERY = 50;
function fitSoon(rt) {
if (rt.fitQueued) return;
rt.fitQueued = true;
requestAnimationFrame(() => {
rt.fitQueued = false;
fit(rt);
});
const wait = Math.max(0, FIT_EVERY - (performance.now() - (rt.lastFit || 0)));
setTimeout(() => {
requestAnimationFrame(() => {
rt.fitQueued = false;
rt.lastFit = performance.now();
fit(rt);
// The trailing fit, in case the size kept changing after this one.
clearTimeout(rt.fitTrail);
rt.fitTrail = setTimeout(() => fit(rt), FIT_EVERY * 2);
});
}, wait);
}

function disposeRuntime(rt) {
rt.disposed = true;
clearTimeout(rt.ptyResizeTimer);
clearTimeout(rt.fitTrail);
if (rt.ptyId !== undefined) {
hq.kill(rt.ptyId);
byPty.delete(rt.ptyId);
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "hqterm",
"version": "0.2.3",
"version": "0.2.4",
"description": "Terminal session manager: persistent hqsh sessions with images and HD emoji, built on hqtui.",
"license": "MIT",
"private": true,
Expand Down
Loading