This commit is contained in:
Haapy
2026-05-14 22:51:36 +00:00
parent c3552d08b9
commit b496914b3c
16 changed files with 988 additions and 27 deletions

View File

@@ -0,0 +1,84 @@
import { useEffect, useRef, useState } from "react";
import { xapp, type AppLaunched } from "../ipc";
const PRESETS = [
{ label: "VSCode", cmd: "code --new-window" },
{ label: "Firefox", cmd: "firefox --new-instance" },
{ label: "Files", cmd: "nautilus --new-window" },
{ label: "Calculator", cmd: "gnome-calculator" },
{ label: "xterm", cmd: "xterm" },
];
interface Props {
open: boolean;
onClose: () => void;
onLaunched: (launched: AppLaunched & { command: string }) => void;
}
export function AppLauncher({ open, onClose, onLaunched }: Props) {
const [cmd, setCmd] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (open) {
setError(null);
setCmd("");
setBusy(false);
setTimeout(() => inputRef.current?.focus(), 0);
}
}, [open]);
if (!open) return null;
const launch = async (command: string) => {
if (!command.trim()) return;
setBusy(true);
setError(null);
try {
const res = await xapp.launch(command);
onLaunched({ ...res, command });
onClose();
} catch (e: unknown) {
setError(String(e));
setBusy(false);
}
};
return (
<>
<div className="launch-dialog-backdrop" onClick={onClose} />
<div className="launch-dialog" onPointerDown={(e) => e.stopPropagation()}>
<h3>Launch app</h3>
<input
ref={inputRef}
value={cmd}
onChange={(e) => setCmd(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") launch(cmd);
if (e.key === "Escape") onClose();
}}
placeholder="e.g. code --new-window /path/to/repo"
disabled={busy}
/>
<div className="launch-dialog-presets">
{PRESETS.map((p) => (
<button key={p.label} onClick={() => launch(p.cmd)} disabled={busy}>
{p.label}
</button>
))}
</div>
{error && <div className="launch-dialog-error">{error}</div>}
<div className="launch-dialog-actions">
<button onClick={onClose} disabled={busy}>
Cancel
</button>
<button className="primary" onClick={() => launch(cmd)} disabled={busy || !cmd.trim()}>
{busy ? "Launching…" : "Launch"}
</button>
</div>
</div>
</>
);
}

View File

@@ -3,6 +3,9 @@ import type { Card, Viewport } from "./types";
import { ViewportContext } from "./viewport";
import { NoteCardView } from "./cards/NoteCardView";
import { TerminalCardView } from "./cards/TerminalCardView";
import { AppCardView } from "./cards/AppCardView";
import { AppLauncher } from "./AppLauncher";
import { xapp } from "../ipc";
import "./canvas.css";
const MIN_SCALE = 0.1;
@@ -17,6 +20,7 @@ export function Canvas({ initialCards }: CanvasProps) {
const [cards, setCards] = useState<Card[]>(initialCards);
const [vp, setVp] = useState<Viewport>({ x: 0, y: 0, scale: 1 });
const [spaceHeld, setSpaceHeld] = useState(false);
const [launcherOpen, setLauncherOpen] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const panState = useRef<{ startX: number; startY: number; vpX: number; vpY: number } | null>(
null,
@@ -54,6 +58,14 @@ export function Canvas({ initialCards }: CanvasProps) {
};
}, []);
useEffect(() => {
let unlisten: (() => void) | undefined;
xapp.onDestroyed((xid) => {
setCards((cs) => cs.filter((c) => !(c.kind === "app" && c.xWindowId === xid)));
}).then((u) => (unlisten = u));
return () => unlisten?.();
}, []);
const onWheel = useCallback((e: React.WheelEvent) => {
if (!e.ctrlKey && !e.metaKey) return;
e.preventDefault();
@@ -134,6 +146,20 @@ export function Canvas({ initialCards }: CanvasProps) {
ptyId: "",
}));
const onAppLaunched = (l: { xid: number; title: string; command: string }) =>
addCardAtCenter((id, cx, cy, z) => ({
id,
kind: "app",
x: cx - 400,
y: cy - 260,
width: 800,
height: 520,
z,
xWindowId: l.xid,
command: l.command,
title: l.title,
}));
return (
<div
ref={containerRef}
@@ -159,13 +185,22 @@ export function Canvas({ initialCards }: CanvasProps) {
if (c.kind === "terminal") {
return <TerminalCardView key={c.id} card={c} onUpdate={(p) => updateCard(c.id, p)} />;
}
if (c.kind === "app") {
return <AppCardView 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>
<button onClick={() => setLauncherOpen(true)}>+ App</button>
</div>
<AppLauncher
open={launcherOpen}
onClose={() => setLauncherOpen(false)}
onLaunched={onAppLaunched}
/>
<div className="canvas-hud">
<span>x {vp.x.toFixed(0)}</span>
<span>y {vp.y.toFixed(0)}</span>

View File

@@ -85,6 +85,125 @@
height: 100%;
}
.app-body {
background: transparent;
position: relative;
}
.app-placeholder {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6px;
background: #2a2a32;
color: var(--text);
}
.app-placeholder-title {
font-size: 1.1em;
opacity: 0.9;
}
.app-placeholder-hint {
font-size: 0.85em;
opacity: 0.5;
}
.launch-dialog {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: var(--card-bg);
border: 1px solid var(--card-border);
border-radius: 10px;
padding: 18px;
min-width: 380px;
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.5);
z-index: 2000;
}
.launch-dialog-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.4);
z-index: 1999;
}
.launch-dialog h3 {
margin-bottom: 12px;
font-weight: 500;
font-size: 14px;
}
.launch-dialog input {
width: 100%;
padding: 8px 10px;
background: var(--bg);
color: var(--text);
border: 1px solid var(--card-border);
border-radius: 6px;
font-family: ui-monospace, Menlo, monospace;
font-size: 13px;
outline: none;
}
.launch-dialog input:focus {
border-color: var(--accent);
}
.launch-dialog-presets {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin: 10px 0;
}
.launch-dialog-presets button {
background: rgba(255, 255, 255, 0.04);
color: var(--text);
border: 1px solid var(--card-border);
border-radius: 5px;
padding: 4px 9px;
font-size: 11px;
cursor: pointer;
}
.launch-dialog-presets button:hover {
border-color: var(--accent);
}
.launch-dialog-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 10px;
}
.launch-dialog-actions button {
background: var(--card-bg);
color: var(--text);
border: 1px solid var(--card-border);
border-radius: 5px;
padding: 6px 14px;
font-size: 12px;
cursor: pointer;
}
.launch-dialog-actions button.primary {
background: var(--accent);
border-color: var(--accent);
}
.launch-dialog-error {
color: #ff6464;
font-size: 12px;
margin-top: 8px;
}
.terminal-body .xterm,
.terminal-body .xterm-viewport,
.terminal-body .xterm-screen {
@@ -92,6 +211,69 @@
width: 100% !important;
}
.resize-handle {
position: absolute;
z-index: 5;
background: transparent;
}
.resize-n,
.resize-s {
left: 0;
right: 0;
height: 8px;
cursor: ns-resize;
}
.resize-n {
top: -4px;
}
.resize-s {
bottom: -4px;
}
.resize-e,
.resize-w {
top: 0;
bottom: 0;
width: 8px;
cursor: ew-resize;
}
.resize-e {
right: -4px;
}
.resize-w {
left: -4px;
}
.resize-ne,
.resize-nw,
.resize-se,
.resize-sw {
width: 14px;
height: 14px;
z-index: 6;
}
.resize-ne {
top: -4px;
right: -4px;
cursor: nesw-resize;
}
.resize-nw {
top: -4px;
left: -4px;
cursor: nwse-resize;
}
.resize-se {
bottom: -4px;
right: -4px;
cursor: nwse-resize;
}
.resize-sw {
bottom: -4px;
left: -4px;
cursor: nesw-resize;
}
.toolbar {
position: fixed;
top: 12px;

View File

@@ -0,0 +1,83 @@
import { useEffect, useRef } from "react";
import type { AppCard } from "../types";
import { useViewport } from "../viewport";
import { useDragHandle } from "../useDragHandle";
import { ResizeHandles } from "./ResizeHandles";
import { xapp } from "../../ipc";
const HEADER_BASE_PX = 26;
const HIDE_THRESHOLD = 0.4;
interface Props {
card: AppCard;
onUpdate: (patch: Partial<AppCard>) => void;
}
export function AppCardView({ card, onUpdate }: Props) {
const vp = useViewport();
const drag = useDragHandle(card, (p) => onUpdate(p));
const lastVisible = useRef<boolean | null>(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 headerH = HEADER_BASE_PX * vp.scale;
const shouldShow = vp.scale >= HIDE_THRESHOLD;
useEffect(() => {
if (!card.xWindowId) return;
if (lastVisible.current !== shouldShow) {
xapp.setVisible(card.xWindowId, shouldShow).catch(() => {});
lastVisible.current = shouldShow;
}
if (!shouldShow) return;
xapp
.setGeometry(
card.xWindowId,
screenLeft,
screenTop + headerH,
screenW,
Math.max(1, screenH - headerH),
)
.catch(() => {});
}, [card.xWindowId, screenLeft, screenTop, screenW, screenH, headerH, shouldShow]);
useEffect(() => {
return () => {
if (card.xWindowId) xapp.close(card.xWindowId).catch(() => {});
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<div
className="card app-card"
style={
{
left: screenLeft,
top: screenTop,
width: screenW,
height: screenH,
zIndex: card.z,
fontSize: 13 * vp.scale,
"--scale": vp.scale,
} as React.CSSProperties
}
>
<div className="card-header" {...drag}>
{card.title || "app"}
</div>
<div className="card-body app-body">
{!shouldShow && (
<div className="app-placeholder">
<div className="app-placeholder-title">{card.title || card.command}</div>
<div className="app-placeholder-hint">zoom in to view</div>
</div>
)}
</div>
<ResizeHandles card={card} onUpdate={(p) => onUpdate(p as Partial<AppCard>)} />
</div>
);
}

View File

@@ -1,6 +1,7 @@
import type { NoteCard } from "../types";
import { useViewport } from "../viewport";
import { useDragHandle } from "../useDragHandle";
import { ResizeHandles } from "./ResizeHandles";
interface Props {
card: NoteCard;
@@ -38,6 +39,7 @@ export function NoteCardView({ card, onUpdate }: Props) {
onPointerDown={(e) => e.stopPropagation()}
/>
</div>
<ResizeHandles card={card} onUpdate={(p) => onUpdate(p as Partial<NoteCard>)} />
</div>
);
}

View File

@@ -0,0 +1,30 @@
import { useResize, type ResizeDir } from "../useResize";
interface Box {
x: number;
y: number;
width: number;
height: number;
}
interface Props {
card: Box;
onUpdate: (p: Partial<Box>) => void;
}
const DIRS: ResizeDir[] = ["n", "s", "e", "w", "ne", "nw", "se", "sw"];
export function ResizeHandles({ card, onUpdate }: Props) {
return (
<>
{DIRS.map((dir) => (
<Handle key={dir} dir={dir} card={card} onUpdate={onUpdate} />
))}
</>
);
}
function Handle({ dir, card, onUpdate }: { dir: ResizeDir; card: Box; onUpdate: Props["onUpdate"] }) {
const handlers = useResize(card, dir, onUpdate);
return <div className={`resize-handle resize-${dir}`} {...handlers} />;
}

View File

@@ -5,6 +5,7 @@ import "@xterm/xterm/css/xterm.css";
import type { TerminalCard } from "../types";
import { useViewport } from "../viewport";
import { useDragHandle } from "../useDragHandle";
import { ResizeHandles } from "./ResizeHandles";
import { pty, base64ToBytes, stringToBase64 } from "../../ipc";
const BASE_FONT_SIZE = 13;
@@ -125,6 +126,7 @@ export function TerminalCardView({ card, onUpdate }: Props) {
onPointerDown={(e) => e.stopPropagation()}
onWheel={(e) => e.stopPropagation()}
/>
<ResizeHandles card={card} onUpdate={(p) => onUpdate(p as Partial<TerminalCard>)} />
</div>
);
}

87
src/canvas/useResize.ts Normal file
View File

@@ -0,0 +1,87 @@
import { useRef } from "react";
import { useViewport } from "./viewport";
export type ResizeDir = "n" | "s" | "e" | "w" | "ne" | "nw" | "se" | "sw";
interface Box {
x: number;
y: number;
width: number;
height: number;
}
const MIN_W = 120;
const MIN_H = 80;
export function useResize(card: Box, dir: ResizeDir, onUpdate: (p: Partial<Box>) => void) {
const vp = useViewport();
const ref = useRef<{
startX: number;
startY: number;
cardX: number;
cardY: number;
cardW: number;
cardH: number;
} | null>(null);
const onPointerDown = (e: React.PointerEvent) => {
if (e.button !== 0) return;
e.stopPropagation();
(e.target as Element).setPointerCapture(e.pointerId);
ref.current = {
startX: e.clientX,
startY: e.clientY,
cardX: card.x,
cardY: card.y,
cardW: card.width,
cardH: card.height,
};
};
const onPointerMove = (e: React.PointerEvent) => {
const r = ref.current;
if (!r) return;
const dx = (e.clientX - r.startX) / vp.scale;
const dy = (e.clientY - r.startY) / vp.scale;
let x = r.cardX;
let y = r.cardY;
let w = r.cardW;
let h = r.cardH;
if (dir.includes("e")) w = r.cardW + dx;
if (dir.includes("w")) {
x = r.cardX + dx;
w = r.cardW - dx;
}
if (dir.includes("s")) h = r.cardH + dy;
if (dir.includes("n")) {
y = r.cardY + dy;
h = r.cardH - dy;
}
if (w < MIN_W) {
if (dir.includes("w")) x -= MIN_W - w;
w = MIN_W;
}
if (h < MIN_H) {
if (dir.includes("n")) y -= MIN_H - h;
h = MIN_H;
}
onUpdate({ x, y, width: w, height: h });
};
const onPointerUp = (e: React.PointerEvent) => {
if (ref.current) {
try {
(e.target as Element).releasePointerCapture(e.pointerId);
} catch {
// already released
}
ref.current = null;
}
};
return { onPointerDown, onPointerMove, onPointerUp, onPointerCancel: onPointerUp };
}

View File

@@ -48,3 +48,20 @@ export function base64ToBytes(b64: string): Uint8Array {
export function stringToBase64(s: string): string {
return bytesToBase64(new TextEncoder().encode(s));
}
export interface AppLaunched {
xid: number;
title: string;
}
export const xapp = {
launch: (command: string) =>
invoke<AppLaunched>("app_launch", { opts: { command } }),
setGeometry: (xid: number, x: number, y: number, width: number, height: number) =>
invoke<void>("app_set_geometry", { xid, x, y, width, height }),
setVisible: (xid: number, visible: boolean) =>
invoke<void>("app_set_visible", { xid, visible }),
close: (xid: number) => invoke<void>("app_close", { xid }),
onDestroyed: (handler: (xid: number) => void): Promise<UnlistenFn> =>
listen<number>("app:destroyed", (e) => handler(e.payload)),
};