Files
TUI-WM/src/main.rs
Bjorn Blomberg a621fa10d7 File-association list view in settings + fix ghost glyphs after window moves
- New 'Filformat' row in the settings dialog opens a list of all
  [open_with] associations (.ext → command) plus the default editor;
  Delete removes an association (persisted to config.toml), Esc goes
  back
- Ghost fix: some terminals leave the right half of wide glyphs
  (emoji) behind when the diff only rewrites part of them — windows
  being dragged/resized/closed/snapped now request one full terminal
  repaint on completion, clearing any leftovers. Verified the
  compositor itself is clean (headless frames have no ghosts)
- Integration suite: 41/41

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 23:54:27 +02:00

698 lines
27 KiB
Rust

mod app;
mod client;
mod config;
mod ipc;
mod kitty_gfx;
mod log;
mod pty;
mod render;
mod server;
mod theme;
use app::{App, AppIpcOut, WindowContent};
use config::Config;
use crossterm::{
event::{self, DisableMouseCapture, EnableMouseCapture, EnableBracketedPaste, DisableBracketedPaste, EnableFocusChange, DisableFocusChange},
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use ipc::{ClientMessage, ClientRole, ServerMessage, WindowInfo};
use ratatui::{backend::CrosstermBackend, Terminal};
use server::IpcEvent;
use std::collections::HashMap;
use std::io::{self, Write as _};
use std::sync::mpsc;
use std::time::Duration;
enum Mode {
Standalone,
Daemon,
Client(String),
Init(Option<String>),
Help,
}
fn parse_args() -> Mode {
let args: Vec<String> = std::env::args().collect();
let mut i = 1;
while i < args.len() {
match args[i].as_str() {
"-h" | "--help" => return Mode::Help,
"--init" => {
let dir = args.get(i + 1).filter(|s| !s.starts_with('-')).cloned();
return Mode::Init(dir);
}
"-d" | "--daemon" => return Mode::Daemon,
"-c" | "--connect" => {
let path = args
.get(i + 1)
.filter(|s| !s.starts_with('-'))
.cloned()
.unwrap_or_else(ipc::default_socket_path);
return Mode::Client(path);
}
_ => {}
}
i += 1;
}
Mode::Standalone
}
fn main() -> io::Result<()> {
match parse_args() {
Mode::Standalone => run_standalone(),
Mode::Daemon => run_daemon_mode(),
Mode::Client(path) => client::run(&path),
Mode::Init(dir) => run_init(dir.as_deref()),
Mode::Help => {
print_help();
Ok(())
}
}
}
/// Standardkatalog för konfiguration: $XDG_CONFIG_HOME/tui-wm
/// (~/.config/tui-wm), på Windows %APPDATA%\tui-wm.
fn default_config_dir() -> std::path::PathBuf {
if let Ok(x) = std::env::var("XDG_CONFIG_HOME") {
return std::path::PathBuf::from(x).join("tui-wm");
}
#[cfg(windows)]
if let Ok(a) = std::env::var("APPDATA") {
return std::path::PathBuf::from(a).join("tui-wm");
}
if let Ok(h) = std::env::var("HOME") {
return std::path::PathBuf::from(h).join(".config").join("tui-wm");
}
std::path::PathBuf::from(".")
}
/// `tui-wm --init [KATALOG]` — skriv ut standardkonfiguration
/// (config.toml, panels/, themes/). Befintliga filer rörs inte.
fn run_init(dir: Option<&str>) -> io::Result<()> {
let base = dir
.map(std::path::PathBuf::from)
.unwrap_or_else(default_config_dir);
let files: &[(&str, &str)] = &[
("config.toml", include_str!("../assets/default-config.toml")),
("panels/topbar.toml", include_str!("../assets/default-topbar.toml")),
("themes/dark.toml", include_str!("../themes/dark.toml")),
("themes/light.toml", include_str!("../themes/light.toml")),
];
for (rel, content) in files {
let path = base.join(rel);
if path.exists() {
println!(" finns redan: {}", path.display());
continue;
}
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(&path, content)?;
println!(" skapade: {}", path.display());
}
println!("\nKlart. Starta med `tui-wm` — konfigurationen hittas automatiskt");
println!("i {} när ./config.toml saknas.", base.display());
Ok(())
}
/// Hitta konfigurationskatalogen: ./config.toml vinner, annars
/// XDG-katalogen. Byter arbetskatalog dit så att alla relativa
/// sökvägar (panels/, themes/, config-sparningar, hot-reload) fungerar
/// enhetligt. Ursprungliga arbetskatalogen sparas för PTY-spawns.
fn enter_config_dir() {
if let Ok(cwd) = std::env::current_dir() {
crate::pty::set_spawn_cwd(cwd.clone());
if cwd.join("config.toml").exists() {
return;
}
}
let xdg = default_config_dir();
if xdg.join("config.toml").exists() {
if let Err(e) = std::env::set_current_dir(&xdg) {
eprintln!("Kunde inte byta till {}: {}", xdg.display(), e);
}
}
}
fn print_help() {
println!(
"\
TUI-WM — fönsterhanterare i terminalen (tmux-liknande, med mus)
ANVÄNDNING:
tui-wm [FLAGGA]
FLAGGOR:
(ingen) Standalone: kör fönsterhanteraren i den här
terminalen och starta samtidigt socket-servern.
-d, --daemon Daemon: kör headless utan lokal rendering.
Anslut med `tui-wm -c`. Stoppas med `pkill tui-wm`
eller via systemd — INTE av att klienter kopplar ner.
-c, --connect [SOCKET]
Klient: anslut till en körande server (även över
SSH). Tangentbord, mus (klick/scroll/drag) och
resize vidarebefordras. Koppla ner med Ctrl+Shift+Q
— servern och dess program fortsätter köra.
--init [KATALOG] Skriv standardkonfiguration (config.toml, panels/,
themes/) till KATALOG — default ~/.config/tui-wm.
Befintliga filer rörs inte.
-h, --help Visa den här hjälpen.
KONFIGURATION (./config.toml, annars ~/.config/tui-wm/config.toml):
default_shell = \"/bin/bash\" # skal för nya terminaler
default_window_size = [82, 26] # [bredd, höjd] för nya fönster
scrollback_lines = 1000 # historikrader per terminal
show_scrollbar = true # scrollbar på fönstrens högerkant
background_image = \"bild.png\" # bakgrund (Kitty graphics)
terminal_bg_color = \"#1e1e2e\" # terminalbakgrund
TANGENTER (standard, ändras via [[keybind]] i config.toml):
ctrl+space Kommandodialog — skriv t.ex. `htop` och tryck Enter
alt+enter Ny terminal
alt+w Fönsterväxlare (MRU) — Tab stegar, Enter väljer
alt+h/alt+l Fäst fönster som vänster/höger halvskärm
alt+m Maximera/återställ (även dubbelklick på titelraden)
alt+c Copy-mode: bläddra/söka i historiken (/ n N q)
ctrl+q Avsluta (från en ansluten klient: kopplar bara ner den)
alt+tab och alt+pilar är också bundna men fångas ofta av
skrivbordsmiljön/terminalen — bokstavsvarianterna fungerar överallt.
I terminaler med kitty keyboard-protokoll (Konsole 23.08+, kitty,
foot, wezterm, ghostty) levereras även pil-varianterna korrekt.
TEMA & INSTÄLLNINGAR:
theme = \"themes/dark.toml\" i config.toml väljer tema (egna teman =
egna TOML-filer). Kugghjulet ≡ i panelen öppnar inbyggda
inställningar (tema, panel, skugga, scrollbar). config.toml,
panel- och temafiler hot-reloadas när de ändras.
MUS:
Dra titelraden = flytta fönster Dra kant/hörn = ändra storlek
Scroll = historik (skal) eller vidarebefordras (appar med mus-stöd)
Klick/hover vidarebefordras till appar som vim, htop, mc ...
SOCKET-API:
Program i fönstren får $TUI_WM_SOCKET och $TUI_WM_WINDOW_ID och kan
öppna fönster/popups via socketen — se SOCKET_API.md."
);
}
fn run_standalone() -> io::Result<()> {
enter_config_dir();
log::init();
let socket_path = ipc::default_socket_path();
let (ipc_tx, ipc_rx) = mpsc::channel::<IpcEvent>();
server::start(&socket_path, ipc_tx);
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen, EnableMouseCapture, EnableBracketedPaste, EnableFocusChange)?;
// Kitty keyboard-protokoll (om terminalen stödjer det): gör att
// alt+pilar, alt+tab m.fl. levereras entydigt i stället för att
// kollidera med legacy-escape-sekvenser.
let keyboard_enhanced = crossterm::terminal::supports_keyboard_enhancement().unwrap_or(false);
if keyboard_enhanced {
let _ = execute!(
io::stdout(),
crossterm::event::PushKeyboardEnhancementFlags(
crossterm::event::KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES
)
);
}
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
let config = Config::load_or_default("config.toml");
let mut app = App::new(config);
app.socket_path = Some(socket_path.clone());
let result = run_standalone_loop(&mut terminal, &mut app, ipc_rx, &socket_path);
if keyboard_enhanced {
let _ = execute!(io::stdout(), crossterm::event::PopKeyboardEnhancementFlags);
}
disable_raw_mode()?;
execute!(terminal.backend_mut(), LeaveAlternateScreen, DisableMouseCapture, DisableBracketedPaste, DisableFocusChange)?;
terminal.show_cursor()?;
result
}
fn run_standalone_loop(
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
app: &mut App,
ipc_rx: mpsc::Receiver<IpcEvent>,
_socket_path: &str,
) -> io::Result<()> {
// all_clients: client_id → (role, width, height, sender)
let mut all_clients: HashMap<usize, (ClientRole, u16, u16, mpsc::SyncSender<ServerMessage>)> =
HashMap::new();
// Separate TestBackend for rendering to display clients
let mut test_terminal: Option<ratatui::Terminal<ratatui::backend::TestBackend>> = None;
// Kitty bakgrundsbild
let mut bg_state: Option<kitty_gfx::BgImage> = None;
let mut last_frame_area = ratatui::layout::Rect::default();
loop {
let size = terminal.size()?;
// ── Kitty bakgrundsbild ──────────────────────────────────────────
update_background(terminal, app, &mut bg_state, size.width, size.height);
// Full omritning efter fönsterflytt/-stängning: rensar halva
// emoji-glyfer ("spöken") som terminal-diffen annars lämnar kvar.
if app.needs_full_redraw {
app.needs_full_redraw = false;
terminal.clear()?;
}
terminal.draw(|frame| {
let area = frame.area();
app.update_layout(area);
render::render(frame, app);
last_frame_area = area;
})?;
app.tick();
// ── Vidarebefordra Kitty graphics från virtuella terminaler ──────
{
let mut stdout = io::stdout();
for window in &mut app.windows {
if window.pending_graphics.is_empty() {
continue;
}
let cr = window.content_rect();
for gfx in window.pending_graphics.drain(..) {
let host_row = cr.y + gfx.cursor_row.min(cr.height.saturating_sub(1));
let host_col = cr.x + gfx.cursor_col.min(cr.width.saturating_sub(1));
// Positionera markören på värdterminalen
let _ = write!(stdout, "\x1b[{};{}H", host_row + 1, host_col + 1);
let _ = stdout.write_all(&gfx.raw);
}
}
let _ = stdout.flush();
}
// Process IPC events
while let Ok(ev) = ipc_rx.try_recv() {
handle_ipc_event(ev, app, &mut all_clients);
}
// Drain app IPC output (popup results, window opened notifications)
let out_events: Vec<AppIpcOut> = app.ipc_out.drain(..).collect();
for out_ev in out_events {
send_app_ipc_out(out_ev, &all_clients);
}
// Send frames to display clients
let display_clients: Vec<_> = all_clients
.values()
.filter(|(role, ..)| *role == ClientRole::Display)
.collect();
if !display_clients.is_empty() {
let min_w = display_clients.iter().map(|(_, w, _, _)| *w).min().unwrap_or(size.width);
let min_h = display_clients.iter().map(|(_, _, h, _)| *h).min().unwrap_or(size.height);
let render_w = min_w.min(size.width);
let render_h = min_h.min(size.height);
// Render to TestBackend at min size
let tt = test_terminal.get_or_insert_with(|| {
ratatui::Terminal::new(ratatui::backend::TestBackend::new(render_w, render_h))
.expect("TestBackend")
});
// Resize if needed
let cur_size = tt.size().unwrap_or_default();
if cur_size.width != render_w || cur_size.height != render_h {
*tt = ratatui::Terminal::new(
ratatui::backend::TestBackend::new(render_w, render_h),
)
.expect("TestBackend resize");
}
let render_area = ratatui::layout::Rect::new(0, 0, render_w, render_h);
app.update_layout(render_area);
let _ = tt.draw(|frame| render::render(frame, &*app));
// Restore layout for local render
app.update_layout(last_frame_area);
let ansi = ipc::buffer_to_ansi(tt.backend().buffer());
let hex_data = ipc::to_hex(&ansi);
let frame_msg = ServerMessage::Frame {
width: render_w,
height: render_h,
min_width: render_w,
min_height: render_h,
data: hex_data,
};
all_clients.retain(|_, (role, _, _, tx)| {
if *role == ClientRole::Display {
// try_send: en långsam/hängd display-klient får INTE
// blockera hela WM:et — droppa framen till den klienten
// och koppla bara bort vid stängd kanal.
match tx.try_send(frame_msg.clone()) {
Ok(()) => true,
Err(std::sync::mpsc::TrySendError::Full(_)) => true,
Err(std::sync::mpsc::TrySendError::Disconnected(_)) => false,
}
} else {
true
}
});
}
if event::poll(Duration::from_millis(16))? {
app.handle_event(event::read()?);
}
if app.should_quit {
// Radera kitty bakgrundsbild vid avslut
if bg_state.is_some() {
kitty_gfx::delete_bg(&mut io::stdout());
}
// Radera alla vidarebefordrade Kitty-bilder
let _ = io::stdout().write_all(b"\x1b_Ga=d,d=a;\x1b\\");
let _ = io::stdout().flush();
break;
}
}
Ok(())
}
/// Hämta terminalens pixelstorlek via TIOCGWINSZ ioctl.
#[cfg(unix)]
fn get_terminal_pixel_size() -> (u16, u16) {
use std::mem::MaybeUninit;
unsafe {
let mut ws: MaybeUninit<libc::winsize> = MaybeUninit::uninit();
if libc::ioctl(libc::STDOUT_FILENO, libc::TIOCGWINSZ, ws.as_mut_ptr()) == 0 {
let ws = ws.assume_init();
(ws.ws_xpixel, ws.ws_ypixel)
} else {
(0, 0)
}
}
}
#[cfg(not(unix))]
fn get_terminal_pixel_size() -> (u16, u16) {
(0, 0)
}
/// Uppdatera Kitty-bakgrundsbild om det behövs (ny bild, storleksändring, borttagen).
fn update_background(
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
app: &App,
bg_state: &mut Option<kitty_gfx::BgImage>,
cols: u16,
rows: u16,
) {
// Kitty graphics-protokollet stöds inte av Windows Terminal/conhost —
// att skriva APC-sekvenser dit ger bara skräptecken.
if cfg!(windows) {
return;
}
let wanted_path = app.config.background_image.as_deref();
// Kolla om vi behöver göra något
let needs_update = match (wanted_path, bg_state.as_ref()) {
(None, None) => false,
(None, Some(_)) => true, // ta bort
(Some(_p), None) => true, // ny bild
(Some(p), Some(bg)) => {
p != bg.path || cols != bg.rendered_cols || rows != bg.rendered_rows
}
};
if !needs_update {
return;
}
let stdout = terminal.backend_mut();
match wanted_path {
None => {
// Radera bakgrund
kitty_gfx::delete_bg(stdout);
*bg_state = None;
}
Some(path) => {
let (pixel_w, pixel_h) = get_terminal_pixel_size();
if let Some(bg) = kitty_gfx::show_bg(stdout, path, cols, rows, pixel_w, pixel_h) {
*bg_state = Some(bg);
}
}
}
}
fn run_daemon_mode() -> io::Result<()> {
enter_config_dir();
log::init();
let socket_path = ipc::default_socket_path();
let (ipc_tx, ipc_rx) = mpsc::channel::<IpcEvent>();
server::start(&socket_path, ipc_tx);
let config = Config::load_or_default("config.toml");
let mut app = App::new(config);
app.socket_path = Some(socket_path.clone());
let mut all_clients: HashMap<usize, (ClientRole, u16, u16, mpsc::SyncSender<ServerMessage>)> =
HashMap::new();
let mut test_terminal: Option<ratatui::Terminal<ratatui::backend::TestBackend>> = None;
eprintln!("TUI-WM daemon startad. Socket: {}", socket_path);
loop {
// Process IPC events
while let Ok(ev) = ipc_rx.try_recv() {
handle_ipc_event(ev, &mut app, &mut all_clients);
}
// Drain app IPC output
let out_events: Vec<AppIpcOut> = app.ipc_out.drain(..).collect();
for out_ev in out_events {
send_app_ipc_out(out_ev, &all_clients);
}
app.tick();
// Render and send frames to display clients
let display_clients: Vec<_> = all_clients
.values()
.filter(|(role, ..)| *role == ClientRole::Display)
.collect();
if !display_clients.is_empty() {
let min_w = display_clients.iter().map(|(_, w, _, _)| *w).min().unwrap_or(80);
let min_h = display_clients.iter().map(|(_, _, h, _)| *h).min().unwrap_or(24);
let render_w = min_w.max(20);
let render_h = min_h.max(6);
let tt = test_terminal.get_or_insert_with(|| {
ratatui::Terminal::new(ratatui::backend::TestBackend::new(render_w, render_h))
.expect("TestBackend")
});
let cur_size = tt.size().unwrap_or_default();
if cur_size.width != render_w || cur_size.height != render_h {
*tt = ratatui::Terminal::new(
ratatui::backend::TestBackend::new(render_w, render_h),
)
.expect("TestBackend resize");
}
let render_area = ratatui::layout::Rect::new(0, 0, render_w, render_h);
app.update_layout(render_area);
let _ = tt.draw(|frame| render::render(frame, &app));
let ansi = ipc::buffer_to_ansi(tt.backend().buffer());
let hex_data = ipc::to_hex(&ansi);
let frame_msg = ServerMessage::Frame {
width: render_w,
height: render_h,
min_width: render_w,
min_height: render_h,
data: hex_data,
};
all_clients.retain(|_, (role, _, _, tx)| {
if *role == ClientRole::Display {
// try_send: en långsam/hängd display-klient får INTE
// blockera hela WM:et — droppa framen till den klienten
// och koppla bara bort vid stängd kanal.
match tx.try_send(frame_msg.clone()) {
Ok(()) => true,
Err(std::sync::mpsc::TrySendError::Full(_)) => true,
Err(std::sync::mpsc::TrySendError::Disconnected(_)) => false,
}
} else {
true
}
});
}
if app.should_quit {
break;
}
std::thread::sleep(Duration::from_millis(16));
}
Ok(())
}
fn handle_ipc_event(
ev: IpcEvent,
app: &mut App,
all_clients: &mut HashMap<usize, (ClientRole, u16, u16, mpsc::SyncSender<ServerMessage>)>,
) {
match ev {
IpcEvent::ClientConnected { client_id, role, width, height, tx } => {
all_clients.insert(client_id, (role, width, height, tx));
}
IpcEvent::ClientDisconnected { client_id } => {
all_clients.remove(&client_id);
}
IpcEvent::Message { client_id, msg } => {
// Ignorera meddelanden från klienter som redan kopplats ner
// (t.ex. efter bye) men vars socket ännu inte hunnit stängas.
if !all_clients.contains_key(&client_id) {
return;
}
match msg {
ClientMessage::Input { data } => {
let bytes = ipc::from_hex(&data);
if let Some(id) = app.focused_id {
if let Some(w) = app.windows.iter_mut().find(|w| w.id == id) {
if let WindowContent::Terminal { pty, .. } = &mut w.content {
let _ = pty.write_input(&bytes);
}
}
}
}
ClientMessage::KeyEvent { event } => {
app.handle_event(crossterm::event::Event::Key(event));
disconnect_instead_of_quit(app, all_clients, client_id);
}
ClientMessage::MouseEvent { event } => {
app.handle_event(crossterm::event::Event::Mouse(event));
disconnect_instead_of_quit(app, all_clients, client_id);
}
ClientMessage::Resize { width, height } => {
if let Some(client) = all_clients.get_mut(&client_id) {
client.1 = width;
client.2 = height;
}
}
ClientMessage::SpawnWindow { command, request_id } => {
let window_id = app.next_id;
app.spawn_terminal(&command);
app.ipc_out.push(AppIpcOut::WindowOpened {
client_id,
request_id,
window_id,
});
}
ClientMessage::SpawnPopup { message, buttons, request_id } => {
app.spawn_popup_dialog(message, buttons, client_id, request_id);
}
ClientMessage::ListWindows { request_id } => {
let windows: Vec<WindowInfo> = app
.windows
.iter()
.filter_map(|w| {
let title = match &w.content {
WindowContent::Terminal { title, .. } => title.clone(),
WindowContent::RunDialog { .. } => "[Kommandodialog]".to_string(),
WindowContent::PopupDialog { message, .. } => {
format!("[Popup: {}]", &message[..message.len().min(20)])
}
WindowContent::Settings { .. } => "[Inställningar]".to_string(),
WindowContent::OpenWith { path, .. } => {
format!("[Öppna: {}]", path)
}
};
Some(WindowInfo {
id: w.id,
x: w.x,
y: w.y,
width: w.width,
height: w.height,
title,
})
})
.collect();
if let Some((_, _, _, tx)) = all_clients.get(&client_id) {
let _ = tx.send(ServerMessage::WindowList { windows, request_id });
}
}
ClientMessage::CloseWindow { window_id, .. } => {
app.close_window_pub(window_id);
}
ClientMessage::OpenFile { path, request_id } => {
let window_id = app.open_file(&path);
app.ipc_out.push(AppIpcOut::WindowOpened {
client_id,
request_id,
window_id,
});
}
ClientMessage::SetBackground { path, save, request_id } => {
app.config.background_image = path.clone();
if save {
if let Err(e) = Config::save_background("config.toml", path.as_deref()) {
log::log(&format!("Kunde inte spara bakgrund till config: {}", e));
if let Some((_, _, _, tx)) = all_clients.get(&client_id) {
let _ = tx.send(ServerMessage::Error {
message: format!("Kunde inte spara config: {}", e),
request_id,
});
}
} else if let Some((_, _, _, tx)) = all_clients.get(&client_id) {
let _ = tx.send(ServerMessage::HelloOk {
version: ipc::VERSION.to_string(),
socket_path: String::new(),
});
}
}
}
ClientMessage::Hello { .. } => {}
}
}
}
}
/// Exit utlöst av en ansluten klient (t.ex. ctrl+q eller panelens
/// Avsluta-knapp) ska bara koppla ner DEN klienten — servern/daemonen
/// och alla program fortsätter köra. Lokal Exit i standalone-läget
/// går inte via IPC och stänger som vanligt hela WM:et.
fn disconnect_instead_of_quit(
app: &mut App,
all_clients: &mut HashMap<usize, (ClientRole, u16, u16, mpsc::SyncSender<ServerMessage>)>,
client_id: usize,
) {
if app.should_quit {
app.should_quit = false;
// Skicka bye så klienten avslutar och stänger socketen — att bara
// droppa sändaren räcker inte, eftersom serverns läsartråd håller
// anslutningen öppen och klienten annars fryser i väntan på frames.
if let Some((_, _, _, tx)) = all_clients.get(&client_id) {
let _ = tx.try_send(ServerMessage::Bye);
}
all_clients.remove(&client_id);
}
}
fn send_app_ipc_out(
out_ev: AppIpcOut,
all_clients: &HashMap<usize, (ClientRole, u16, u16, mpsc::SyncSender<ServerMessage>)>,
) {
match out_ev {
AppIpcOut::PopupResult { client_id, request_id, button, button_index } => {
if let Some((_, _, _, tx)) = all_clients.get(&client_id) {
let _ = tx.send(ServerMessage::PopupResult { button, button_index, request_id });
}
}
AppIpcOut::WindowOpened { client_id, request_id, window_id } => {
if let Some((_, _, _, tx)) = all_clients.get(&client_id) {
let _ = tx.send(ServerMessage::WindowOpened { id: window_id, request_id });
}
}
}
}