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:
440
src/main.rs
440
src/main.rs
@@ -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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user