new additions : What's new

 Rust PTY backend (portable-pty)
     - pty_spawn / pty_write / pty_resize / pty_kill commands
     - Output streamed via pty:data Tauri events (base64-encoded bytes)
     - Spawns /bin/bash (falls back to /bin/bash), starts in /home/code
   xterm.js terminal card (full ANSI + true color)
   Toolbar (top-left) with + Note / + Terminal
   Shared useDragHandle hook (notes + terminals)
   Terminal font-size scales with canvas zoom → crisp at any zoom
   Terminal auto-refits when card resizes or zoom changes
This commit is contained in:
Haapy
2026-05-14 22:22:18 +00:00
parent eaeb4c2d92
commit c3552d08b9
192 changed files with 44595 additions and 132 deletions

View File

@@ -7,20 +7,14 @@ const initialCards: Card[] = [
kind: "note",
x: 200,
y: 200,
width: 320,
height: 180,
z: 0,
text: "Welcome to Infinite.\n\nPan: middle-drag or space+drag.\nZoom: Ctrl+wheel.",
},
{
id: "todo",
kind: "note",
x: 600,
y: 320,
width: 260,
height: 140,
z: 0,
text: "Next: terminal cards, then X11 embedding.",
width: 340,
height: 200,
z: 1,
text:
"Welcome to Infinite.\n\n" +
"Pan: middle-drag or Space + drag\n" +
"Zoom: Ctrl + scroll\n\n" +
"Use the toolbar to add notes and terminals.",
},
];

View File

@@ -2,6 +2,7 @@ import { useEffect, useRef, useState, useCallback } from "react";
import type { Card, Viewport } from "./types";
import { ViewportContext } from "./viewport";
import { NoteCardView } from "./cards/NoteCardView";
import { TerminalCardView } from "./cards/TerminalCardView";
import "./canvas.css";
const MIN_SCALE = 0.1;
@@ -20,6 +21,9 @@ export function Canvas({ initialCards }: CanvasProps) {
const panState = useRef<{ startX: number; startY: number; vpX: number; vpY: number } | null>(
null,
);
const maxZRef = useRef(
initialCards.reduce((m, c) => Math.max(m, c.z), 0),
);
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
@@ -36,23 +40,17 @@ export function Canvas({ initialCards }: CanvasProps) {
};
}, []);
// Suppress WebKit/GTK middle-click defaults (autoscroll on some platforms,
// paste primary selection on Linux). Must be a native listener — React's
// synthetic onPointerDown.preventDefault doesn't reliably stop these.
useEffect(() => {
const el = containerRef.current;
if (!el) return;
const stopMiddle = (e: MouseEvent) => {
if (e.button === 1) e.preventDefault();
};
const stopAux = (e: MouseEvent) => {
if (e.button === 1) e.preventDefault();
};
el.addEventListener("mousedown", stopMiddle);
el.addEventListener("auxclick", stopAux);
el.addEventListener("auxclick", stopMiddle);
return () => {
el.removeEventListener("mousedown", stopMiddle);
el.removeEventListener("auxclick", stopAux);
el.removeEventListener("auxclick", stopMiddle);
};
}, []);
@@ -74,8 +72,6 @@ export function Canvas({ initialCards }: CanvasProps) {
const isPan = e.button === 1 || (e.button === 0 && spaceHeld);
if (!isPan) return;
e.preventDefault();
// Capture on the container, not e.target — survives card re-renders and
// avoids odd behavior when the click lands on a textarea or child element.
containerRef.current?.setPointerCapture(e.pointerId);
panState.current = { startX: e.clientX, startY: e.clientY, vpX: vp.x, vpY: vp.y };
};
@@ -93,7 +89,7 @@ export function Canvas({ initialCards }: CanvasProps) {
try {
containerRef.current?.releasePointerCapture(e.pointerId);
} catch {
// capture may already be released if pointercancel fired
// already released
}
panState.current = null;
}
@@ -103,6 +99,41 @@ export function Canvas({ initialCards }: CanvasProps) {
setCards((cs) => cs.map((c) => (c.id === id ? ({ ...c, ...patch } as Card) : c)));
};
const addCardAtCenter = (build: (id: string, x: number, y: number, z: number) => Card) => {
const w = window.innerWidth / 2;
const h = window.innerHeight / 2;
const cx = (w - vp.x) / vp.scale;
const cy = (h - vp.y) / vp.scale;
const id = crypto.randomUUID();
maxZRef.current += 1;
const card = build(id, cx, cy, maxZRef.current);
setCards((cs) => [...cs, card]);
};
const addNote = () =>
addCardAtCenter((id, cx, cy, z) => ({
id,
kind: "note",
x: cx - 160,
y: cy - 90,
width: 320,
height: 180,
z,
text: "",
}));
const addTerminal = () =>
addCardAtCenter((id, cx, cy, z) => ({
id,
kind: "terminal",
x: cx - 280,
y: cy - 180,
width: 560,
height: 360,
z,
ptyId: "",
}));
return (
<div
ref={containerRef}
@@ -125,9 +156,16 @@ export function Canvas({ initialCards }: CanvasProps) {
if (c.kind === "note") {
return <NoteCardView key={c.id} card={c} onUpdate={(p) => updateCard(c.id, p)} />;
}
if (c.kind === "terminal") {
return <TerminalCardView key={c.id} card={c} onUpdate={(p) => updateCard(c.id, p)} />;
}
return null;
})}
</ViewportContext.Provider>
<div className="toolbar">
<button onClick={addNote}>+ Note</button>
<button onClick={addTerminal}>+ Terminal</button>
</div>
<div className="canvas-hud">
<span>x {vp.x.toFixed(0)}</span>
<span>y {vp.y.toFixed(0)}</span>

View File

@@ -78,3 +78,43 @@
font-size: 1em;
padding: 0.77em;
}
.terminal-body {
background: #1d1d23;
padding: calc(4px * var(--scale, 1));
height: 100%;
}
.terminal-body .xterm,
.terminal-body .xterm-viewport,
.terminal-body .xterm-screen {
height: 100% !important;
width: 100% !important;
}
.toolbar {
position: fixed;
top: 12px;
left: 12px;
display: flex;
gap: 6px;
padding: 6px;
background: rgba(0, 0, 0, 0.5);
border-radius: 8px;
z-index: 1000;
}
.toolbar button {
background: var(--card-bg);
color: var(--text);
border: 1px solid var(--card-border);
border-radius: 5px;
padding: 6px 10px;
font-size: 12px;
cursor: pointer;
font-family: inherit;
}
.toolbar button:hover {
border-color: var(--accent);
}

View File

@@ -1,6 +1,6 @@
import { useRef } from "react";
import type { NoteCard } from "../types";
import { useViewport } from "../viewport";
import { useDragHandle } from "../useDragHandle";
interface Props {
card: NoteCard;
@@ -11,63 +11,24 @@ const BODY_FONT_BASE = 13;
export function NoteCardView({ card, onUpdate }: Props) {
const vp = useViewport();
const dragState = useRef<{ startX: number; startY: number; cardX: number; cardY: number } | null>(
null,
);
const onHeaderPointerDown = (e: React.PointerEvent) => {
if (e.button !== 0) return;
e.stopPropagation();
(e.target as Element).setPointerCapture(e.pointerId);
dragState.current = { startX: e.clientX, startY: e.clientY, cardX: card.x, cardY: card.y };
};
const onHeaderPointerMove = (e: React.PointerEvent) => {
const ds = dragState.current;
if (!ds) return;
const dx = (e.clientX - ds.startX) / vp.scale;
const dy = (e.clientY - ds.startY) / vp.scale;
onUpdate({ x: ds.cardX + dx, y: ds.cardY + dy });
};
const onHeaderPointerUp = (e: React.PointerEvent) => {
if (dragState.current) {
try {
(e.target as Element).releasePointerCapture(e.pointerId);
} catch {
// already released
}
dragState.current = null;
}
};
const screenLeft = vp.x + card.x * vp.scale;
const screenTop = vp.y + card.y * vp.scale;
const screenW = card.width * vp.scale;
const screenH = card.height * vp.scale;
const drag = useDragHandle(card, (p) => onUpdate(p));
return (
<div
className="card note-card"
style={
{
left: screenLeft,
top: screenTop,
width: screenW,
height: screenH,
left: vp.x + card.x * vp.scale,
top: vp.y + card.y * vp.scale,
width: card.width * vp.scale,
height: card.height * vp.scale,
zIndex: card.z,
fontSize: BODY_FONT_BASE * vp.scale,
"--scale": vp.scale,
} as React.CSSProperties
}
>
<div
className="card-header"
onPointerDown={onHeaderPointerDown}
onPointerMove={onHeaderPointerMove}
onPointerUp={onHeaderPointerUp}
onPointerCancel={onHeaderPointerUp}
>
<div className="card-header" {...drag}>
note
</div>
<div className="card-body">

View File

@@ -0,0 +1,130 @@
import { useEffect, useRef, useState } from "react";
import { Terminal } from "@xterm/xterm";
import { FitAddon } from "@xterm/addon-fit";
import "@xterm/xterm/css/xterm.css";
import type { TerminalCard } from "../types";
import { useViewport } from "../viewport";
import { useDragHandle } from "../useDragHandle";
import { pty, base64ToBytes, stringToBase64 } from "../../ipc";
const BASE_FONT_SIZE = 13;
interface Props {
card: TerminalCard;
onUpdate: (patch: Partial<TerminalCard>) => void;
}
export function TerminalCardView({ card, onUpdate }: Props) {
const vp = useViewport();
const drag = useDragHandle(card, (p) => onUpdate(p));
const containerRef = useRef<HTMLDivElement>(null);
const termRef = useRef<Terminal | null>(null);
const fitRef = useRef<FitAddon | null>(null);
const ptyIdRef = useRef<string>(card.ptyId);
const [ready, setReady] = useState(false);
useEffect(() => {
let unlistenData: (() => void) | undefined;
let unlistenExit: (() => void) | undefined;
let disposed = false;
const term = new Terminal({
fontSize: BASE_FONT_SIZE * vp.scale,
fontFamily: 'Menlo, "Cascadia Code", Consolas, monospace',
theme: {
background: "#1d1d23",
foreground: "#e8e8ec",
cursor: "#e8e8ec",
},
cursorBlink: true,
allowProposedApi: true,
});
const fit = new FitAddon();
term.loadAddon(fit);
term.open(containerRef.current!);
fit.fit();
termRef.current = term;
fitRef.current = fit;
(async () => {
let id = ptyIdRef.current;
if (!id) {
id = await pty.spawn({ cols: term.cols, rows: term.rows });
if (disposed) {
await pty.kill(id);
return;
}
ptyIdRef.current = id;
onUpdate({ ptyId: id });
}
unlistenData = await pty.onData((e) => {
if (e.id !== id) return;
term.write(base64ToBytes(e.data));
});
unlistenExit = await pty.onExit((e) => {
if (e.id !== id) return;
term.write("\r\n\x1b[2m[process exited]\x1b[0m\r\n");
});
term.onData((data) => {
pty.write(id!, stringToBase64(data)).catch((err) => console.error("pty_write", err));
});
term.onResize(({ cols, rows }) => {
pty.resize(id!, cols, rows).catch(() => {});
});
setReady(true);
})();
return () => {
disposed = true;
unlistenData?.();
unlistenExit?.();
term.dispose();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Update xterm font size when canvas zoom changes so text stays crisp.
useEffect(() => {
if (!ready) return;
const term = termRef.current;
const fit = fitRef.current;
if (!term || !fit) return;
term.options.fontSize = BASE_FONT_SIZE * vp.scale;
fit.fit();
}, [vp.scale, ready]);
// Refit on card resize.
useEffect(() => {
if (!ready) return;
fitRef.current?.fit();
}, [card.width, card.height, ready]);
return (
<div
className="card terminal-card"
style={
{
left: vp.x + card.x * vp.scale,
top: vp.y + card.y * vp.scale,
width: card.width * vp.scale,
height: card.height * vp.scale,
zIndex: card.z,
"--scale": vp.scale,
} as React.CSSProperties
}
>
<div className="card-header" {...drag}>
terminal
</div>
<div
className="card-body terminal-body"
ref={containerRef}
onPointerDown={(e) => e.stopPropagation()}
onWheel={(e) => e.stopPropagation()}
/>
</div>
);
}

View File

@@ -0,0 +1,47 @@
import { useRef } from "react";
import { useViewport } from "./viewport";
interface Pos {
x: number;
y: number;
}
export function useDragHandle(card: Pos, onUpdate: (p: Pos) => void) {
const vp = useViewport();
const dragRef = useRef<{ startX: number; startY: number; cardX: number; cardY: number } | null>(
null,
);
const onPointerDown = (e: React.PointerEvent) => {
if (e.button !== 0) return;
e.stopPropagation();
(e.target as Element).setPointerCapture(e.pointerId);
dragRef.current = { startX: e.clientX, startY: e.clientY, cardX: card.x, cardY: card.y };
};
const onPointerMove = (e: React.PointerEvent) => {
const d = dragRef.current;
if (!d) return;
const dx = (e.clientX - d.startX) / vp.scale;
const dy = (e.clientY - d.startY) / vp.scale;
onUpdate({ x: d.cardX + dx, y: d.cardY + dy });
};
const onPointerUp = (e: React.PointerEvent) => {
if (dragRef.current) {
try {
(e.target as Element).releasePointerCapture(e.pointerId);
} catch {
// already released
}
dragRef.current = null;
}
};
return {
onPointerDown,
onPointerMove,
onPointerUp,
onPointerCancel: onPointerUp,
};
}

50
src/ipc.ts Normal file
View File

@@ -0,0 +1,50 @@
import { invoke } from "@tauri-apps/api/core";
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
export interface PtySpawnOpts {
cols: number;
rows: number;
shell?: string;
cwd?: string;
}
export interface PtyDataEvent {
id: string;
data: string;
}
export interface PtyExitEvent {
id: string;
status: number | null;
}
export const pty = {
spawn: (opts: PtySpawnOpts) => invoke<string>("pty_spawn", { opts }),
write: (id: string, dataBase64: string) =>
invoke<void>("pty_write", { id, data: dataBase64 }),
resize: (id: string, cols: number, rows: number) =>
invoke<void>("pty_resize", { id, cols, rows }),
kill: (id: string) => invoke<void>("pty_kill", { id }),
onData: (handler: (e: PtyDataEvent) => void): Promise<UnlistenFn> =>
listen<PtyDataEvent>("pty:data", (e) => handler(e.payload)),
onExit: (handler: (e: PtyExitEvent) => void): Promise<UnlistenFn> =>
listen<PtyExitEvent>("pty:exit", (e) => handler(e.payload)),
};
export function bytesToBase64(bytes: Uint8Array): string {
let bin = "";
for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);
return btoa(bin);
}
export function base64ToBytes(b64: string): Uint8Array {
const bin = atob(b64);
const out = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
return out;
}
export function stringToBase64(s: string): string {
return bytesToBase64(new TextEncoder().encode(s));
}