addition
This commit is contained in:
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
4
dist/index.html
vendored
@@ -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
58
src-tauri/Cargo.lock
generated
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
370
src-tauri/src/x11mod.rs
Normal 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(())
|
||||||
|
}
|
||||||
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 { 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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
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 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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
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 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
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 {
|
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)),
|
||||||
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user