addition
This commit is contained in:
84
src/canvas/AppLauncher.tsx
Normal file
84
src/canvas/AppLauncher.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
83
src/canvas/cards/AppCardView.tsx
Normal file
83
src/canvas/cards/AppCardView.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
30
src/canvas/cards/ResizeHandles.tsx
Normal file
30
src/canvas/cards/ResizeHandles.tsx
Normal 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} />;
|
||||
}
|
||||
@@ -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
87
src/canvas/useResize.ts
Normal 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 };
|
||||
}
|
||||
17
src/ipc.ts
17
src/ipc.ts
@@ -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)),
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user