- 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>
698 lines
27 KiB
Rust
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 });
|
|
}
|
|
}
|
|
}
|
|
}
|