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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

4
dist/index.html vendored
View File

@@ -4,8 +4,8 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Infinite</title> <title>Infinite</title>
<script type="module" crossorigin src="/assets/index-BS64ttyv.js"></script> <script type="module" crossorigin src="/assets/index-D8BvfjIZ.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BuGxqy5-.css"> <link rel="stylesheet" crossorigin href="/assets/index-fBlcXAv5.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

58
src-tauri/Cargo.lock generated
View File

@@ -84,10 +84,12 @@ dependencies = [
"portable-pty", "portable-pty",
"serde", "serde",
"serde_json", "serde_json",
"shell-words",
"tauri", "tauri",
"tauri-build", "tauri-build",
"tauri-plugin-log", "tauri-plugin-log",
"uuid", "uuid",
"x11rb",
] ]
[[package]] [[package]]
@@ -860,6 +862,16 @@ dependencies = [
"typeid", "typeid",
] ]
[[package]]
name = "errno"
version = "0.3.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.61.2",
]
[[package]] [[package]]
name = "fastrand" name = "fastrand"
version = "2.4.1" version = "2.4.1"
@@ -1161,6 +1173,16 @@ dependencies = [
"version_check", "version_check",
] ]
[[package]]
name = "gethostname"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8"
dependencies = [
"rustix",
"windows-link 0.2.1",
]
[[package]] [[package]]
name = "getrandom" name = "getrandom"
version = "0.2.17" version = "0.2.17"
@@ -1863,6 +1885,12 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "linux-raw-sys"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
[[package]] [[package]]
name = "litemap" name = "litemap"
version = "0.8.2" version = "0.8.2"
@@ -2819,6 +2847,19 @@ dependencies = [
"semver", "semver",
] ]
[[package]]
name = "rustix"
version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
dependencies = [
"bitflags 2.11.1",
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.61.2",
]
[[package]] [[package]]
name = "rustversion" name = "rustversion"
version = "1.0.22" version = "1.0.22"
@@ -4998,6 +5039,23 @@ dependencies = [
"pkg-config", "pkg-config",
] ]
[[package]]
name = "x11rb"
version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414"
dependencies = [
"gethostname",
"rustix",
"x11rb-protocol",
]
[[package]]
name = "x11rb-protocol"
version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd"
[[package]] [[package]]
name = "yoke" name = "yoke"
version = "0.8.2" version = "0.8.2"

View File

@@ -26,3 +26,5 @@ tauri-plugin-log = "2"
portable-pty = "0.8" portable-pty = "0.8"
uuid = { version = "1", features = ["v4"] } uuid = { version = "1", features = ["v4"] }
base64 = "0.22" base64 = "0.22"
x11rb = "0.13"
shell-words = "1.1"

View File

@@ -1,11 +1,14 @@
mod pty; mod pty;
mod x11mod;
use pty::{pty_kill, pty_resize, pty_spawn, pty_write, PtyState}; use pty::{pty_kill, pty_resize, pty_spawn, pty_write, PtyState};
use x11mod::{app_close, app_launch, app_set_geometry, app_set_visible, X11State};
#[cfg_attr(mobile, tauri::mobile_entry_point)] #[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() { pub fn run() {
tauri::Builder::default() tauri::Builder::default()
.manage(PtyState::default()) .manage(PtyState::default())
.manage(X11State::new())
.setup(|app| { .setup(|app| {
if cfg!(debug_assertions) { if cfg!(debug_assertions) {
app.handle().plugin( app.handle().plugin(
@@ -21,6 +24,10 @@ pub fn run() {
pty_write, pty_write,
pty_resize, pty_resize,
pty_kill, pty_kill,
app_launch,
app_set_geometry,
app_set_visible,
app_close,
]) ])
.run(tauri::generate_context!()) .run(tauri::generate_context!())
.expect("error while running tauri application"); .expect("error while running tauri application");

370
src-tauri/src/x11mod.rs Normal file
View File

@@ -0,0 +1,370 @@
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use tauri::{AppHandle, Emitter, State};
use x11rb::atom_manager;
use x11rb::connection::Connection;
use x11rb::protocol::xproto::{
AtomEnum, ChangeWindowAttributesAux, ClientMessageEvent, ConfigureWindowAux,
ConnectionExt as XprotoConnectionExt, EventMask, PropMode,
};
use x11rb::protocol::Event;
use x11rb::rust_connection::RustConnection;
use x11rb::wrapper::ConnectionExt as WrapperConnectionExt;
atom_manager! {
pub Atoms: AtomsCookie {
_NET_CLIENT_LIST,
_NET_WM_PID,
_NET_WM_NAME,
_NET_WM_STATE,
_NET_WM_STATE_ABOVE,
_NET_WM_STATE_SKIP_TASKBAR,
_NET_WM_STATE_SKIP_PAGER,
_MOTIF_WM_HINTS,
WM_PROTOCOLS,
WM_DELETE_WINDOW,
UTF8_STRING,
}
}
#[derive(Clone)]
struct X11Conn {
conn: Arc<RustConnection>,
atoms: Atoms,
root: u32,
}
#[allow(dead_code)]
#[derive(Debug, Clone)]
pub struct AppInfo {
pub xid: u32,
pub pid: u32,
pub command: String,
}
pub struct X11State {
conn: Mutex<Option<X11Conn>>,
windows: Arc<Mutex<HashMap<u32, AppInfo>>>,
}
impl X11State {
pub fn new() -> Self {
Self {
conn: Mutex::new(None),
windows: Arc::new(Mutex::new(HashMap::new())),
}
}
fn ensure(&self, app: &AppHandle) -> Result<X11Conn, String> {
let mut guard = self.conn.lock().unwrap();
if let Some(c) = guard.as_ref() {
return Ok(c.clone());
}
let (raw, screen_num) = RustConnection::connect(None).map_err(|e| e.to_string())?;
let conn = Arc::new(raw);
let atoms = Atoms::new(&*conn)
.map_err(|e| e.to_string())?
.reply()
.map_err(|e| e.to_string())?;
let root = conn.setup().roots[screen_num].root;
// Subscribe to destroy notifications for embedded windows.
conn.change_window_attributes(
root,
&ChangeWindowAttributesAux::new().event_mask(EventMask::SUBSTRUCTURE_NOTIFY),
)
.map_err(|e| e.to_string())?;
conn.flush().map_err(|e| e.to_string())?;
// Background event thread for DestroyNotify.
let conn_t = conn.clone();
let windows_t = self.windows.clone();
let app_t = app.clone();
std::thread::spawn(move || event_loop(conn_t, windows_t, app_t));
let c = X11Conn { conn, atoms, root };
*guard = Some(c.clone());
Ok(c)
}
}
fn event_loop(
conn: Arc<RustConnection>,
windows: Arc<Mutex<HashMap<u32, AppInfo>>>,
app: AppHandle,
) {
loop {
match conn.wait_for_event() {
Ok(Event::DestroyNotify(e)) => {
let removed = windows.lock().unwrap().remove(&e.window);
if removed.is_some() {
let _ = app.emit("app:destroyed", e.window);
}
}
Ok(_) => {}
Err(_) => break,
}
}
}
#[derive(Deserialize)]
pub struct LaunchOpts {
pub command: String,
}
#[derive(Serialize, Clone)]
pub struct LaunchedApp {
pub xid: u32,
pub title: String,
}
#[tauri::command]
pub async fn app_launch(
app: AppHandle,
state: State<'_, X11State>,
opts: LaunchOpts,
) -> Result<LaunchedApp, String> {
let c = state.ensure(&app)?;
let parts = shell_words::split(&opts.command).map_err(|e| e.to_string())?;
if parts.is_empty() {
return Err("empty command".into());
}
let (program, args) = parts.split_first().unwrap();
let child = std::process::Command::new(program)
.args(args)
.stdin(std::process::Stdio::null())
.spawn()
.map_err(|e| format!("spawn failed: {}", e))?;
let root_pid = child.id();
// We don't wait()—the child lives independently. Tauri will SIGHUP it on exit.
std::mem::forget(child);
let xid = wait_for_window_by_pid(&c, root_pid, Duration::from_secs(20))
.ok_or_else(|| "could not find window for launched process".to_string())?;
set_embedded_window_props(&c, xid)?;
let title = read_window_title(&c, xid).unwrap_or_else(|| opts.command.clone());
state.windows.lock().unwrap().insert(
xid,
AppInfo {
xid,
pid: root_pid,
command: opts.command.clone(),
},
);
Ok(LaunchedApp { xid, title })
}
#[tauri::command]
pub fn app_set_geometry(
app: AppHandle,
state: State<'_, X11State>,
window: tauri::WebviewWindow,
xid: u32,
x: f64,
y: f64,
width: f64,
height: f64,
) -> Result<(), String> {
let c = state.ensure(&app)?;
let sf = window.scale_factor().map_err(|e| e.to_string())?;
let pos = window.inner_position().map_err(|e| e.to_string())?;
let root_x = pos.x + (x * sf).round() as i32;
let root_y = pos.y + (y * sf).round() as i32;
let w = ((width * sf).round() as i32).max(1) as u32;
let h = ((height * sf).round() as i32).max(1) as u32;
c.conn
.configure_window(
xid,
&ConfigureWindowAux::new()
.x(root_x)
.y(root_y)
.width(w)
.height(h)
.stack_mode(x11rb::protocol::xproto::StackMode::ABOVE),
)
.map_err(|e| e.to_string())?;
c.conn.flush().map_err(|e| e.to_string())?;
Ok(())
}
#[tauri::command]
pub fn app_set_visible(
app: AppHandle,
state: State<'_, X11State>,
xid: u32,
visible: bool,
) -> Result<(), String> {
let c = state.ensure(&app)?;
if visible {
c.conn.map_window(xid).map_err(|e| e.to_string())?;
} else {
c.conn.unmap_window(xid).map_err(|e| e.to_string())?;
}
c.conn.flush().map_err(|e| e.to_string())?;
Ok(())
}
#[tauri::command]
pub fn app_close(app: AppHandle, state: State<'_, X11State>, xid: u32) -> Result<(), String> {
let c = state.ensure(&app)?;
// Send WM_DELETE_WINDOW to politely ask the app to close.
let event = ClientMessageEvent::new(
32,
xid,
c.atoms.WM_PROTOCOLS,
[c.atoms.WM_DELETE_WINDOW, 0, 0, 0, 0],
);
let _ = c.conn.send_event(false, xid, EventMask::NO_EVENT, event);
c.conn.flush().map_err(|e| e.to_string())?;
state.windows.lock().unwrap().remove(&xid);
Ok(())
}
fn wait_for_window_by_pid(c: &X11Conn, root_pid: u32, timeout: Duration) -> Option<u32> {
let deadline = Instant::now() + timeout;
while Instant::now() < deadline {
let pids = collect_descendant_pids(root_pid);
if let Some(xid) = find_window_for_pids(c, &pids) {
return Some(xid);
}
std::thread::sleep(Duration::from_millis(150));
}
None
}
fn collect_descendant_pids(root: u32) -> Vec<u32> {
// Walk /proc to find all processes whose ancestor chain includes root.
let mut parents: HashMap<u32, u32> = HashMap::new();
let Ok(entries) = std::fs::read_dir("/proc") else {
return vec![root];
};
for entry in entries.flatten() {
let name = entry.file_name();
let Some(name_str) = name.to_str() else {
continue;
};
let Ok(pid) = name_str.parse::<u32>() else {
continue;
};
let stat_path = entry.path().join("stat");
let Ok(stat) = std::fs::read_to_string(&stat_path) else {
continue;
};
// Format: "pid (comm) state ppid ..."
if let Some(rparen) = stat.rfind(')') {
let rest = &stat[rparen + 1..];
let fields: Vec<&str> = rest.split_whitespace().collect();
if let Some(ppid_str) = fields.get(1) {
if let Ok(ppid) = ppid_str.parse::<u32>() {
parents.insert(pid, ppid);
}
}
}
}
let mut result = vec![root];
let mut grew = true;
while grew {
grew = false;
for (&pid, &ppid) in &parents {
if result.contains(&ppid) && !result.contains(&pid) {
result.push(pid);
grew = true;
}
}
}
result
}
fn find_window_for_pids(c: &X11Conn, pids: &[u32]) -> Option<u32> {
let reply = c
.conn
.get_property(false, c.root, c.atoms._NET_CLIENT_LIST, AtomEnum::WINDOW, 0, 1024)
.ok()?
.reply()
.ok()?;
let windows: Vec<u32> = reply.value32()?.collect();
for xid in windows {
if let Some(wpid) = read_window_pid(c, xid) {
if pids.contains(&wpid) {
return Some(xid);
}
}
}
None
}
fn read_window_pid(c: &X11Conn, xid: u32) -> Option<u32> {
let reply = c
.conn
.get_property(false, xid, c.atoms._NET_WM_PID, AtomEnum::CARDINAL, 0, 1)
.ok()?
.reply()
.ok()?;
reply.value32().and_then(|mut it| it.next())
}
fn read_window_title(c: &X11Conn, xid: u32) -> Option<String> {
let reply = c
.conn
.get_property(false, xid, c.atoms._NET_WM_NAME, c.atoms.UTF8_STRING, 0, 1024)
.ok()?
.reply()
.ok()?;
let s = String::from_utf8_lossy(&reply.value).to_string();
if s.is_empty() {
// fallback to WM_NAME
let r2 = c
.conn
.get_property(false, xid, AtomEnum::WM_NAME, AtomEnum::STRING, 0, 1024)
.ok()?
.reply()
.ok()?;
Some(String::from_utf8_lossy(&r2.value).to_string())
} else {
Some(s)
}
}
fn set_embedded_window_props(c: &X11Conn, xid: u32) -> Result<(), String> {
// _MOTIF_WM_HINTS — flags=DECORATIONS (1<<1), decorations=0 (none).
let motif = [2u32, 0, 0, 0, 0];
c.conn
.change_property32(
PropMode::REPLACE,
xid,
c.atoms._MOTIF_WM_HINTS,
c.atoms._MOTIF_WM_HINTS,
&motif,
)
.map_err(|e| e.to_string())?;
// _NET_WM_STATE_ADD: ABOVE + SKIP_TASKBAR + SKIP_PAGER.
for state_atom in [
c.atoms._NET_WM_STATE_ABOVE,
c.atoms._NET_WM_STATE_SKIP_TASKBAR,
c.atoms._NET_WM_STATE_SKIP_PAGER,
] {
let event = ClientMessageEvent::new(
32,
xid,
c.atoms._NET_WM_STATE,
[1, state_atom, 0, 0, 0], // _NET_WM_STATE_ADD = 1
);
let _ = c.conn.send_event(
false,
c.root,
EventMask::SUBSTRUCTURE_REDIRECT | EventMask::SUBSTRUCTURE_NOTIFY,
event,
);
}
c.conn.flush().map_err(|e| e.to_string())?;
Ok(())
}

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 { ViewportContext } from "./viewport";
import { NoteCardView } from "./cards/NoteCardView"; import { NoteCardView } from "./cards/NoteCardView";
import { TerminalCardView } from "./cards/TerminalCardView"; import { TerminalCardView } from "./cards/TerminalCardView";
import { AppCardView } from "./cards/AppCardView";
import { AppLauncher } from "./AppLauncher";
import { xapp } from "../ipc";
import "./canvas.css"; import "./canvas.css";
const MIN_SCALE = 0.1; const MIN_SCALE = 0.1;
@@ -17,6 +20,7 @@ export function Canvas({ initialCards }: CanvasProps) {
const [cards, setCards] = useState<Card[]>(initialCards); const [cards, setCards] = useState<Card[]>(initialCards);
const [vp, setVp] = useState<Viewport>({ x: 0, y: 0, scale: 1 }); const [vp, setVp] = useState<Viewport>({ x: 0, y: 0, scale: 1 });
const [spaceHeld, setSpaceHeld] = useState(false); const [spaceHeld, setSpaceHeld] = useState(false);
const [launcherOpen, setLauncherOpen] = useState(false);
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const panState = useRef<{ startX: number; startY: number; vpX: number; vpY: number } | null>( const panState = useRef<{ startX: number; startY: number; vpX: number; vpY: number } | null>(
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) => { const onWheel = useCallback((e: React.WheelEvent) => {
if (!e.ctrlKey && !e.metaKey) return; if (!e.ctrlKey && !e.metaKey) return;
e.preventDefault(); e.preventDefault();
@@ -134,6 +146,20 @@ export function Canvas({ initialCards }: CanvasProps) {
ptyId: "", 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 ( return (
<div <div
ref={containerRef} ref={containerRef}
@@ -159,13 +185,22 @@ export function Canvas({ initialCards }: CanvasProps) {
if (c.kind === "terminal") { if (c.kind === "terminal") {
return <TerminalCardView key={c.id} card={c} onUpdate={(p) => updateCard(c.id, p)} />; 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; return null;
})} })}
</ViewportContext.Provider> </ViewportContext.Provider>
<div className="toolbar"> <div className="toolbar">
<button onClick={addNote}>+ Note</button> <button onClick={addNote}>+ Note</button>
<button onClick={addTerminal}>+ Terminal</button> <button onClick={addTerminal}>+ Terminal</button>
<button onClick={() => setLauncherOpen(true)}>+ App</button>
</div> </div>
<AppLauncher
open={launcherOpen}
onClose={() => setLauncherOpen(false)}
onLaunched={onAppLaunched}
/>
<div className="canvas-hud"> <div className="canvas-hud">
<span>x {vp.x.toFixed(0)}</span> <span>x {vp.x.toFixed(0)}</span>
<span>y {vp.y.toFixed(0)}</span> <span>y {vp.y.toFixed(0)}</span>

View File

@@ -85,6 +85,125 @@
height: 100%; 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,
.terminal-body .xterm-viewport, .terminal-body .xterm-viewport,
.terminal-body .xterm-screen { .terminal-body .xterm-screen {
@@ -92,6 +211,69 @@
width: 100% !important; 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 { .toolbar {
position: fixed; position: fixed;
top: 12px; 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 type { NoteCard } from "../types";
import { useViewport } from "../viewport"; import { useViewport } from "../viewport";
import { useDragHandle } from "../useDragHandle"; import { useDragHandle } from "../useDragHandle";
import { ResizeHandles } from "./ResizeHandles";
interface Props { interface Props {
card: NoteCard; card: NoteCard;
@@ -38,6 +39,7 @@ export function NoteCardView({ card, onUpdate }: Props) {
onPointerDown={(e) => e.stopPropagation()} onPointerDown={(e) => e.stopPropagation()}
/> />
</div> </div>
<ResizeHandles card={card} onUpdate={(p) => onUpdate(p as Partial<NoteCard>)} />
</div> </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 type { TerminalCard } from "../types";
import { useViewport } from "../viewport"; import { useViewport } from "../viewport";
import { useDragHandle } from "../useDragHandle"; import { useDragHandle } from "../useDragHandle";
import { ResizeHandles } from "./ResizeHandles";
import { pty, base64ToBytes, stringToBase64 } from "../../ipc"; import { pty, base64ToBytes, stringToBase64 } from "../../ipc";
const BASE_FONT_SIZE = 13; const BASE_FONT_SIZE = 13;
@@ -125,6 +126,7 @@ export function TerminalCardView({ card, onUpdate }: Props) {
onPointerDown={(e) => e.stopPropagation()} onPointerDown={(e) => e.stopPropagation()}
onWheel={(e) => e.stopPropagation()} onWheel={(e) => e.stopPropagation()}
/> />
<ResizeHandles card={card} onUpdate={(p) => onUpdate(p as Partial<TerminalCard>)} />
</div> </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 { export function stringToBase64(s: string): string {
return bytesToBase64(new TextEncoder().encode(s)); 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)),
};