Implement Kitty graphics protocol for background image rendering

- Added `kitty_gfx` module to handle loading, scaling, and displaying background images using the Kitty graphics protocol.
- Integrated background image handling into the main application loop, allowing dynamic updates based on configuration.
- Enhanced terminal rendering to support transparent backgrounds when a Kitty image is active.
- Updated IPC server to manage client connections and messages, including handling background image settings.
- Modified `PtyTerminal` to accept additional environment variables during shell spawning.
- Improved rendering logic to support popup dialogs and terminal background color customization.
This commit is contained in:
2026-03-29 04:28:31 +02:00
parent 3a9e292088
commit 339ad9530e
14 changed files with 2667 additions and 59 deletions

View File

@@ -1,67 +1,469 @@
mod app;
mod client;
mod config;
mod ipc;
mod kitty_gfx;
mod log;
mod pty;
mod render;
mod server;
use app::App;
use app::{App, AppIpcOut, WindowContent};
use config::Config;
use crossterm::{
event::{self, DisableMouseCapture, EnableMouseCapture},
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 std::{io, time::Duration};
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),
}
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() {
"-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),
}
}
fn run_standalone() -> io::Result<()> {
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)?;
execute!(stdout, EnterAlternateScreen, EnableMouseCapture, EnableBracketedPaste, EnableFocusChange)?;
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(&mut terminal, &mut app);
let result = run_standalone_loop(&mut terminal, &mut app, ipc_rx, &socket_path);
disable_raw_mode()?;
execute!(
terminal.backend_mut(),
LeaveAlternateScreen,
DisableMouseCapture
)?;
execute!(terminal.backend_mut(), LeaveAlternateScreen, DisableMouseCapture, DisableBracketedPaste, DisableFocusChange)?;
terminal.show_cursor()?;
result
}
fn run(
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 {
// Uppdatera layout från aktuell terminalstorlek
let size = terminal.size()?;
app.update_layout(ratatui::layout::Rect::new(0, 0, size.width, size.height));
// Rendera
terminal.draw(|frame| render::render(frame, app))?;
// ── Kitty bakgrundsbild ──────────────────────────────────────────
update_background(terminal, app, &mut bg_state, size.width, size.height);
// Töm PTY-output och uppdatera terminalparsers
terminal.draw(|frame| {
let area = frame.area();
app.update_layout(area);
render::render(frame, app);
last_frame_area = area;
})?;
app.tick();
// Hantera inkommande events (kort timeout → snabb PTY-uppdatering)
// ── 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 {
tx.send(frame_msg.clone()).is_ok()
} 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,
) {
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<()> {
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 {
tx.send(frame_msg.clone()).is_ok()
} 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 } => {
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::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)])
}
};
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::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 { .. } => {}
}
}
}
}
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 });
}
}
}
}