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), Help, } fn parse_args() -> Mode { let args: Vec = 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::(); 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>, app: &mut App, ipc_rx: mpsc::Receiver, _socket_path: &str, ) -> io::Result<()> { // all_clients: client_id → (role, width, height, sender) let mut all_clients: HashMap)> = HashMap::new(); // Separate TestBackend for rendering to display clients let mut test_terminal: Option> = None; // Kitty bakgrundsbild let mut bg_state: Option = 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 = 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 = 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>, app: &App, bg_state: &mut Option, 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::(); 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)> = HashMap::new(); let mut test_terminal: Option> = 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 = 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)>, ) { 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 = 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)>, 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)>, ) { 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 }); } } } }