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:
704
src/app.rs
704
src/app.rs
@@ -79,6 +79,22 @@ pub struct StatusWidgetState {
|
||||
pub last_run: Option<Instant>, // None = aldrig kört, kör direkt
|
||||
}
|
||||
|
||||
// ─── App IPC output ───────────────────────────────────────────────────────────
|
||||
|
||||
pub enum AppIpcOut {
|
||||
PopupResult {
|
||||
client_id: usize,
|
||||
request_id: Option<String>,
|
||||
button: String,
|
||||
button_index: usize,
|
||||
},
|
||||
WindowOpened {
|
||||
client_id: usize,
|
||||
request_id: Option<String>,
|
||||
window_id: usize,
|
||||
},
|
||||
}
|
||||
|
||||
// ─── App ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
pub struct App {
|
||||
@@ -112,6 +128,10 @@ pub struct App {
|
||||
// TUI-WM logo-knapp rects (en per panel), för klick och hover
|
||||
pub tui_wm_btn_rects: Vec<Rect>,
|
||||
pub hovered_tui_btn: bool,
|
||||
|
||||
// IPC
|
||||
pub socket_path: Option<String>,
|
||||
pub ipc_out: Vec<AppIpcOut>,
|
||||
}
|
||||
|
||||
pub struct DropdownState {
|
||||
@@ -142,6 +162,93 @@ pub struct FloatingWindow {
|
||||
pub mouse_encoding: vt100::MouseProtocolEncoding,
|
||||
/// Delvis mottagen escape-sekvens från PTY (för sekvenser som klippts tvärs genom en chunk-gräns).
|
||||
mouse_seq_carry: Vec<u8>,
|
||||
/// Aktiv text-markering (selection) i terminalen
|
||||
pub selection: Option<Selection>,
|
||||
/// Carry-buffer för ofullständiga APC (Kitty graphics) sekvenser
|
||||
pub kitty_gfx_carry: Vec<u8>,
|
||||
/// Köade Kitty graphics-kommandon att vidarebefordra till värdterminalen
|
||||
pub pending_graphics: Vec<PendingGraphic>,
|
||||
}
|
||||
|
||||
/// En Kitty graphics-sekvens extraherad från PTY-data, redo att vidarebefordras.
|
||||
pub struct PendingGraphic {
|
||||
/// Rå APC-sekvens (ESC _ G ... ESC \)
|
||||
pub raw: Vec<u8>,
|
||||
/// Virtuell terminal-markörposition vid tidpunkten för kommandot
|
||||
pub cursor_row: u16,
|
||||
pub cursor_col: u16,
|
||||
}
|
||||
|
||||
/// Text-markering i en terminal: start- och slut-cell (i terminalkoordinater, 0-baserat).
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Selection {
|
||||
pub start_row: u16,
|
||||
pub start_col: u16,
|
||||
pub end_row: u16,
|
||||
pub end_col: u16,
|
||||
}
|
||||
|
||||
impl Selection {
|
||||
/// Returnera normaliserad (start <= end)
|
||||
fn normalized(&self) -> (u16, u16, u16, u16) {
|
||||
if self.start_row < self.end_row
|
||||
|| (self.start_row == self.end_row && self.start_col <= self.end_col)
|
||||
{
|
||||
(self.start_row, self.start_col, self.end_row, self.end_col)
|
||||
} else {
|
||||
(self.end_row, self.end_col, self.start_row, self.start_col)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn contains(&self, row: u16, col: u16) -> bool {
|
||||
let (sr, sc, er, ec) = self.normalized();
|
||||
if row < sr || row > er {
|
||||
return false;
|
||||
}
|
||||
if sr == er {
|
||||
return col >= sc && col <= ec;
|
||||
}
|
||||
if row == sr {
|
||||
return col >= sc;
|
||||
}
|
||||
if row == er {
|
||||
return col <= ec;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Extrahera markerad text från en vt100-screen
|
||||
pub fn extract_text(&self, screen: &vt100::Screen) -> String {
|
||||
let (sr, sc, er, ec) = self.normalized();
|
||||
let (_screen_rows, screen_cols) = screen.size();
|
||||
let mut result = String::new();
|
||||
for row in sr..=er {
|
||||
let col_start = if row == sr { sc } else { 0 };
|
||||
let col_end = if row == er { ec } else { screen_cols.saturating_sub(1) };
|
||||
for col in col_start..=col_end {
|
||||
if let Some(cell) = screen.cell(row, col) {
|
||||
let s = cell.contents();
|
||||
if s.is_empty() {
|
||||
result.push(' ');
|
||||
} else {
|
||||
result.push_str(&s);
|
||||
}
|
||||
} else {
|
||||
result.push(' ');
|
||||
}
|
||||
}
|
||||
if row != er {
|
||||
// Trim trailing spaces on each line
|
||||
let trimmed = result.trim_end_matches(' ');
|
||||
result.truncate(trimmed.len());
|
||||
result.push('\n');
|
||||
}
|
||||
}
|
||||
// Trim trailing spaces on last line
|
||||
let trimmed = result.trim_end_matches(' ');
|
||||
result.truncate(trimmed.len());
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
pub enum WindowContent {
|
||||
@@ -156,6 +263,13 @@ pub enum WindowContent {
|
||||
input: String,
|
||||
cursor_pos: usize,
|
||||
},
|
||||
PopupDialog {
|
||||
message: String,
|
||||
buttons: Vec<String>,
|
||||
selected: usize,
|
||||
client_id: usize,
|
||||
request_id: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
enum RunDialogResult {
|
||||
@@ -339,6 +453,8 @@ impl App {
|
||||
parsed_keybinds,
|
||||
tui_wm_btn_rects: Vec::new(),
|
||||
hovered_tui_btn: false,
|
||||
socket_path: None,
|
||||
ipc_out: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -413,7 +529,7 @@ impl App {
|
||||
pub fn tick(&mut self) {
|
||||
// ── PTY-data → vt100-parser + mus-tracking-skanning ──────────────────────────────────────
|
||||
for window in &mut self.windows {
|
||||
let WindowContent::Terminal { rx, parser, alive, .. } = &mut window.content else { continue };
|
||||
let WindowContent::Terminal { rx, parser, pty, alive, title, .. } = &mut window.content else { continue };
|
||||
if !*alive {
|
||||
continue;
|
||||
}
|
||||
@@ -421,14 +537,27 @@ impl App {
|
||||
match rx.try_recv() {
|
||||
Ok(data) => {
|
||||
// Scanna för mus-escape-sekvenser FÖRE vi ger data till vt100-parsern.
|
||||
// På så sätt överlever mus-läget parser-återskapning vid resize.
|
||||
scan_mouse_tracking(
|
||||
&data,
|
||||
&mut window.mouse_mode,
|
||||
&mut window.mouse_encoding,
|
||||
&mut window.mouse_seq_carry,
|
||||
);
|
||||
parser.process(&data);
|
||||
// Scanna för terminal-queries (DA1, CPR) och skicka svar
|
||||
let responses = scan_terminal_queries(&data, parser);
|
||||
for resp in responses {
|
||||
let _ = pty.write_input(&resp);
|
||||
}
|
||||
// Scanna för OSC-sekvenser (clipboard, etc.)
|
||||
scan_osc_sequences(&data);
|
||||
// Extrahera Kitty graphics-sekvenser och bearbeta resten genom parsern
|
||||
let gfx = process_pty_with_kitty_gfx(
|
||||
&data,
|
||||
parser,
|
||||
pty,
|
||||
&mut window.kitty_gfx_carry,
|
||||
);
|
||||
window.pending_graphics.extend(gfx);
|
||||
}
|
||||
Err(mpsc::TryRecvError::Empty) => break,
|
||||
Err(mpsc::TryRecvError::Disconnected) => {
|
||||
@@ -437,6 +566,11 @@ impl App {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Uppdatera fönsterrubrik från OSC 0/2 om programmet satte en
|
||||
let osc_title = parser.screen().title();
|
||||
if !osc_title.is_empty() {
|
||||
*title = osc_title.to_string();
|
||||
}
|
||||
}
|
||||
|
||||
// ── PTY resize om fönsterstorlek ändrats ─────────────────────────────
|
||||
@@ -461,6 +595,7 @@ impl App {
|
||||
self.windows.retain(|w| match &w.content {
|
||||
WindowContent::Terminal { alive, .. } => *alive,
|
||||
WindowContent::RunDialog { .. } => true,
|
||||
WindowContent::PopupDialog { .. } => true,
|
||||
});
|
||||
if let Some(fid) = prev_focused {
|
||||
if !self.windows.iter().any(|w| w.id == fid) {
|
||||
@@ -492,13 +627,52 @@ impl App {
|
||||
pub fn handle_event(&mut self, event: Event) {
|
||||
match event {
|
||||
Event::Key(key) => self.handle_key(key),
|
||||
Event::Mouse(m) => self.handle_mouse(m.column, m.row, m.kind),
|
||||
Event::Mouse(m) => self.handle_mouse(m.column, m.row, m.kind, m.modifiers),
|
||||
Event::Paste(text) => self.handle_paste(&text),
|
||||
Event::FocusGained | Event::FocusLost => {
|
||||
// Vidarebefordra fokus-events till alla terminaler som begärt det
|
||||
// (via DEC mode 1004)
|
||||
}
|
||||
Event::Resize(_, _) => {}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_paste(&mut self, text: &str) {
|
||||
let Some(id) = self.focused_id else { return };
|
||||
if let Some(w) = self.windows.iter_mut().find(|w| w.id == id) {
|
||||
match &mut w.content {
|
||||
WindowContent::Terminal { pty, parser, alive, .. } => {
|
||||
if !*alive { return; }
|
||||
let bp = parser.screen().bracketed_paste();
|
||||
let mut data = Vec::new();
|
||||
if bp {
|
||||
data.extend_from_slice(b"\x1b[200~");
|
||||
}
|
||||
data.extend_from_slice(text.as_bytes());
|
||||
if bp {
|
||||
data.extend_from_slice(b"\x1b[201~");
|
||||
}
|
||||
let _ = pty.write_input(&data);
|
||||
}
|
||||
WindowContent::RunDialog { input, cursor_pos } => {
|
||||
// Klistra in text i köra-dialogen
|
||||
input.insert_str(*cursor_pos, text);
|
||||
*cursor_pos += text.len();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_key(&mut self, key: KeyEvent) {
|
||||
// 0. Ctrl+Shift+C → kopiera markerad text
|
||||
if key.modifiers.contains(KeyModifiers::CONTROL | KeyModifiers::SHIFT)
|
||||
&& key.code == KeyCode::Char('C')
|
||||
{
|
||||
self.copy_selection();
|
||||
return;
|
||||
}
|
||||
|
||||
// 1. Globala keybinds – alltid, oavsett fokus
|
||||
if let Some(action) = self.match_keybind(key, KeybindScope::Global) {
|
||||
self.execute_action(action);
|
||||
@@ -516,7 +690,16 @@ impl App {
|
||||
RunDialogResult::Execute(cmd) => {
|
||||
self.close_window(id);
|
||||
if !cmd.is_empty() {
|
||||
self.spawn_terminal(&cmd);
|
||||
let shell = self.config.default_shell.clone()
|
||||
.unwrap_or_else(default_shell);
|
||||
let win_id = self.spawn_terminal(&shell);
|
||||
// Skriv kommandot + Enter till den nya terminalens PTY
|
||||
if let Some(w) = self.windows.iter_mut().find(|w| w.id == win_id) {
|
||||
if let WindowContent::Terminal { pty, .. } = &mut w.content {
|
||||
let input = format!("{}\n", cmd);
|
||||
let _ = pty.write_input(input.as_bytes());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
RunDialogResult::None => {}
|
||||
@@ -525,11 +708,35 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
// 2b. PopupDialog hanterar sina egna tangenter
|
||||
if let Some(id) = self.focused_id {
|
||||
let is_popup = self
|
||||
.windows
|
||||
.iter()
|
||||
.any(|w| w.id == id && matches!(w.content, WindowContent::PopupDialog { .. }));
|
||||
if is_popup {
|
||||
if let Some((button, button_index, client_id, request_id)) =
|
||||
self.handle_popup_key(id, key)
|
||||
{
|
||||
self.close_window(id);
|
||||
self.ipc_out.push(AppIpcOut::PopupResult {
|
||||
client_id,
|
||||
request_id,
|
||||
button,
|
||||
button_index,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Vidarebefordra till fokuserat terminalfönster
|
||||
if let Some(id) = self.focused_id {
|
||||
if let Some(bytes) = key_to_bytes(key) {
|
||||
if let Some(w) = self.windows.iter_mut().find(|w| w.id == id) {
|
||||
if let WindowContent::Terminal { pty, .. } = &mut w.content {
|
||||
if let Some(w) = self.windows.iter_mut().find(|w| w.id == id) {
|
||||
if let WindowContent::Terminal { pty, parser, .. } = &mut w.content {
|
||||
let app_cursor = parser.screen().application_cursor();
|
||||
let app_keypad = parser.screen().application_keypad();
|
||||
if let Some(bytes) = key_to_bytes(key, app_cursor, app_keypad) {
|
||||
let _ = pty.write_input(&bytes);
|
||||
}
|
||||
return;
|
||||
@@ -626,11 +833,111 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_mouse(&mut self, col: u16, row: u16, kind: MouseEventKind) {
|
||||
fn handle_popup_key(
|
||||
&mut self,
|
||||
id: usize,
|
||||
key: KeyEvent,
|
||||
) -> Option<(String, usize, usize, Option<String>)> {
|
||||
let window = self.windows.iter_mut().find(|w| w.id == id)?;
|
||||
let WindowContent::PopupDialog { buttons, selected, client_id, request_id, .. } =
|
||||
&mut window.content
|
||||
else {
|
||||
return None;
|
||||
};
|
||||
match key.code {
|
||||
KeyCode::Left | KeyCode::Tab => {
|
||||
if *selected > 0 {
|
||||
*selected -= 1;
|
||||
}
|
||||
None
|
||||
}
|
||||
KeyCode::Right => {
|
||||
if *selected + 1 < buttons.len() {
|
||||
*selected += 1;
|
||||
}
|
||||
None
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
let btn = buttons[*selected].clone();
|
||||
let idx = *selected;
|
||||
let cid = *client_id;
|
||||
let rid = request_id.clone();
|
||||
Some((btn, idx, cid, rid))
|
||||
}
|
||||
KeyCode::Esc => {
|
||||
let idx = buttons.len().saturating_sub(1);
|
||||
let btn = buttons.get(idx).cloned().unwrap_or_default();
|
||||
let cid = *client_id;
|
||||
let rid = request_id.clone();
|
||||
Some((btn, idx, cid, rid))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_mouse(&mut self, col: u16, row: u16, kind: MouseEventKind, modifiers: KeyModifiers) {
|
||||
// Logga alla mushändelser utom Moved (för mycker brus)
|
||||
if !matches!(kind, MouseEventKind::Moved) {
|
||||
crate::log::log(&format!("MOUSE {:?} col={} row={}", kind, col, row));
|
||||
crate::log::log(&format!("MOUSE {:?} col={} row={} mods={:?}", kind, col, row, modifiers));
|
||||
}
|
||||
|
||||
// ── Shift+mus → textmarkering i terminal ─────────────────────────────
|
||||
if modifiers.contains(KeyModifiers::SHIFT) {
|
||||
match kind {
|
||||
MouseEventKind::Down(MouseButton::Left) => {
|
||||
// Hitta terminal under muspekaren
|
||||
let hit = self.windows.iter().rev()
|
||||
.find(|w| w.in_content(col, row))
|
||||
.and_then(|w| {
|
||||
if matches!(&w.content, WindowContent::Terminal { alive, .. } if *alive) {
|
||||
Some((w.id, w.content_rect()))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
if let Some((id, cr)) = hit {
|
||||
self.focus_window(id);
|
||||
let term_row = row.saturating_sub(cr.y);
|
||||
let term_col = col.saturating_sub(cr.x);
|
||||
if let Some(w) = self.windows.iter_mut().find(|w| w.id == id) {
|
||||
w.selection = Some(Selection {
|
||||
start_row: term_row,
|
||||
start_col: term_col,
|
||||
end_row: term_row,
|
||||
end_col: term_col,
|
||||
});
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
MouseEventKind::Drag(MouseButton::Left) => {
|
||||
// Utöka markering
|
||||
if let Some(id) = self.focused_id {
|
||||
let cr = self.windows.iter().find(|w| w.id == id).map(|w| w.content_rect());
|
||||
if let Some(cr) = cr {
|
||||
let term_row = row.saturating_sub(cr.y);
|
||||
let term_col = col.saturating_sub(cr.x);
|
||||
if let Some(w) = self.windows.iter_mut().find(|w| w.id == id) {
|
||||
if let Some(sel) = &mut w.selection {
|
||||
sel.end_row = term_row;
|
||||
sel.end_col = term_col;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// Vanligt klick (utan Shift) rensar markering i alla fönster
|
||||
if matches!(kind, MouseEventKind::Down(MouseButton::Left)) {
|
||||
for w in &mut self.windows {
|
||||
w.selection = None;
|
||||
}
|
||||
}
|
||||
|
||||
match kind {
|
||||
MouseEventKind::Moved => {
|
||||
self.update_hover(col, row);
|
||||
@@ -945,11 +1252,13 @@ impl App {
|
||||
match action {
|
||||
MenuAction::Exit => self.should_quit = true,
|
||||
MenuAction::SpawnTerminal { shell } => {
|
||||
let shell = shell.unwrap_or_else(default_shell);
|
||||
let shell = shell
|
||||
.or_else(|| self.config.default_shell.clone())
|
||||
.unwrap_or_else(default_shell);
|
||||
self.spawn_terminal(&shell);
|
||||
}
|
||||
MenuAction::RunScript { path } => self.spawn_terminal(&path),
|
||||
MenuAction::RunProgram { command, .. } => self.spawn_terminal(&command),
|
||||
MenuAction::RunScript { path } => { self.spawn_terminal(&path); }
|
||||
MenuAction::RunProgram { command, .. } => { self.spawn_terminal(&command); }
|
||||
MenuAction::SpawnRunDialog => self.spawn_run_dialog(),
|
||||
MenuAction::Submenu { .. } => {}
|
||||
MenuAction::NoOp => {}
|
||||
@@ -978,10 +1287,57 @@ impl App {
|
||||
mouse_mode: vt100::MouseProtocolMode::None,
|
||||
mouse_encoding: vt100::MouseProtocolEncoding::Default,
|
||||
mouse_seq_carry: Vec::new(),
|
||||
selection: None,
|
||||
kitty_gfx_carry: Vec::new(),
|
||||
pending_graphics: Vec::new(),
|
||||
});
|
||||
self.focus_window(id);
|
||||
}
|
||||
|
||||
pub fn spawn_popup_dialog(
|
||||
&mut self,
|
||||
message: String,
|
||||
buttons: Vec<String>,
|
||||
client_id: usize,
|
||||
request_id: Option<String>,
|
||||
) {
|
||||
let ca = self.content_area;
|
||||
let w = 52u16.min(ca.width.saturating_sub(4));
|
||||
let h = 7u16;
|
||||
let x = ca.x as i32 + (ca.width as i32 - w as i32) / 2;
|
||||
let y = ca.y as i32 + (ca.height as i32 - h as i32) / 2;
|
||||
let id = self.next_id;
|
||||
self.next_id += 1;
|
||||
self.windows.push(FloatingWindow {
|
||||
id,
|
||||
x: x.max(0),
|
||||
y: y.max(0),
|
||||
width: w,
|
||||
height: h,
|
||||
content: WindowContent::PopupDialog {
|
||||
message,
|
||||
buttons,
|
||||
selected: 0,
|
||||
client_id,
|
||||
request_id,
|
||||
},
|
||||
dragging: None,
|
||||
resizing: None,
|
||||
resizable: false,
|
||||
mouse_mode: vt100::MouseProtocolMode::None,
|
||||
mouse_encoding: vt100::MouseProtocolEncoding::Default,
|
||||
mouse_seq_carry: Vec::new(),
|
||||
selection: None,
|
||||
kitty_gfx_carry: Vec::new(),
|
||||
pending_graphics: Vec::new(),
|
||||
});
|
||||
self.focus_window(id);
|
||||
}
|
||||
|
||||
pub fn close_window_pub(&mut self, id: usize) {
|
||||
self.close_window(id);
|
||||
}
|
||||
|
||||
fn open_dropdown(&mut self, panel_idx: usize, item_idx: usize, items: Vec<MenuItem>) {
|
||||
let pr = match self.panel_rects.get(panel_idx) {
|
||||
Some(r) => *r,
|
||||
@@ -1051,7 +1407,32 @@ impl App {
|
||||
}).collect()
|
||||
}
|
||||
|
||||
pub fn spawn_terminal(&mut self, shell: &str) {
|
||||
/// Kopierar markerad text från fokuserat fönster till urklipp via OSC 52.
|
||||
fn copy_selection(&mut self) {
|
||||
let Some(id) = self.focused_id else { return };
|
||||
let w = match self.windows.iter().find(|w| w.id == id) {
|
||||
Some(w) => w,
|
||||
None => return,
|
||||
};
|
||||
let selection = match &w.selection {
|
||||
Some(s) => s,
|
||||
None => return,
|
||||
};
|
||||
let WindowContent::Terminal { parser, .. } = &w.content else { return };
|
||||
let text = selection.extract_text(parser.screen());
|
||||
if text.is_empty() {
|
||||
return;
|
||||
}
|
||||
crate::log::log(&format!("COPY selection: {} chars", text.len()));
|
||||
// Skicka via OSC 52 till värdterminalen
|
||||
use std::io::Write;
|
||||
let b64 = base64_encode(text.as_bytes());
|
||||
let osc = format!("\x1b]52;c;{}\x07", b64);
|
||||
let _ = std::io::stdout().write_all(osc.as_bytes());
|
||||
let _ = std::io::stdout().flush();
|
||||
}
|
||||
|
||||
pub fn spawn_terminal(&mut self, shell: &str) -> usize {
|
||||
let ca = self.content_area;
|
||||
let offset = (self.windows.len() as i32) * 2;
|
||||
let w = 82u16.min(ca.width.saturating_sub(4));
|
||||
@@ -1061,10 +1442,16 @@ impl App {
|
||||
let rows = h.saturating_sub(2).max(1);
|
||||
let cols = w.saturating_sub(2).max(1);
|
||||
|
||||
match PtyTerminal::spawn(shell, rows, cols) {
|
||||
let id = self.next_id;
|
||||
let mut env_vars: Vec<(String, String)> = Vec::new();
|
||||
if let Some(sp) = &self.socket_path {
|
||||
env_vars.push((crate::ipc::SOCKET_ENV.to_string(), sp.clone()));
|
||||
env_vars.push((crate::ipc::WINDOW_ID_ENV.to_string(), id.to_string()));
|
||||
}
|
||||
|
||||
match PtyTerminal::spawn(shell, rows, cols, &env_vars) {
|
||||
Ok((pty, rx)) => {
|
||||
let parser = vt100::Parser::new(rows, cols, 0);
|
||||
let id = self.next_id;
|
||||
self.next_id += 1;
|
||||
let title = shell.split('/').last().unwrap_or(shell).to_string();
|
||||
self.windows.push(FloatingWindow {
|
||||
@@ -1080,11 +1467,15 @@ impl App {
|
||||
mouse_mode: vt100::MouseProtocolMode::None,
|
||||
mouse_encoding: vt100::MouseProtocolEncoding::Default,
|
||||
mouse_seq_carry: Vec::new(),
|
||||
selection: None,
|
||||
kitty_gfx_carry: Vec::new(),
|
||||
pending_graphics: Vec::new(),
|
||||
});
|
||||
self.focus_window(id);
|
||||
}
|
||||
Err(e) => eprintln!("Kunde inte starta terminal: {}", e),
|
||||
}
|
||||
id
|
||||
}
|
||||
|
||||
fn close_window(&mut self, id: usize) {
|
||||
@@ -1193,6 +1584,105 @@ fn encode_mouse_event(
|
||||
})
|
||||
}
|
||||
|
||||
/// Skannar rå PTY-data efter terminal-queries och returnerar svar som ska
|
||||
/// skrivas tillbaka till PTY:n.
|
||||
/// Hanterar:
|
||||
/// DA1: \e[c eller \e[0c → svar \e[?62;22c (VT220 med ANSI color)
|
||||
/// CPR: \e[6n → svar \e[{row};{col}R
|
||||
/// XTVERSION: \e[>0q → svar \eP>|TUI-WM 0.1\e\\
|
||||
fn scan_terminal_queries(data: &[u8], parser: &vt100::Parser) -> Vec<Vec<u8>> {
|
||||
let mut responses = Vec::new();
|
||||
let mut i = 0;
|
||||
while i < data.len() {
|
||||
if data[i] != 0x1b {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
// ESC [
|
||||
if i + 1 < data.len() && data[i + 1] == b'[' {
|
||||
let csi_start = i + 2;
|
||||
let mut j = csi_start;
|
||||
// Samla parametrar (siffror + ;)
|
||||
while j < data.len() && (data[j].is_ascii_digit() || data[j] == b';') {
|
||||
j += 1;
|
||||
}
|
||||
if j >= data.len() {
|
||||
break; // ofullständig
|
||||
}
|
||||
let final_byte = data[j];
|
||||
let params = &data[csi_start..j];
|
||||
|
||||
match final_byte {
|
||||
b'c' => {
|
||||
// DA1: ESC[c eller ESC[0c
|
||||
if params.is_empty() || params == b"0" {
|
||||
// Svara som VT220 med ANSI color, mouse, truecolor
|
||||
responses.push(b"\x1b[?62;22c".to_vec());
|
||||
}
|
||||
}
|
||||
b'n' => {
|
||||
// CPR: ESC[6n → cursor position report
|
||||
if params == b"6" {
|
||||
let (row, col) = parser.screen().cursor_position();
|
||||
responses.push(format!("\x1b[{};{}R", row + 1, col + 1).into_bytes());
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
i = j + 1;
|
||||
} else {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
responses
|
||||
}
|
||||
|
||||
/// Skannar rå PTY-data efter OSC-sekvenser.
|
||||
/// Hanterar:
|
||||
/// OSC 52 (clipboard copy) → skickar vidare till värdterminalen via stdout
|
||||
/// OSC 11 (query bg color) → svarar med standardfärg
|
||||
fn scan_osc_sequences(data: &[u8]) {
|
||||
let mut i = 0;
|
||||
while i < data.len() {
|
||||
// OSC startar med ESC ] eller 0x9d
|
||||
if data[i] == 0x1b && i + 1 < data.len() && data[i + 1] == b']' {
|
||||
let osc_start = i + 2;
|
||||
// Hitta terminator: BEL (\x07) eller ST (ESC \)
|
||||
let mut j = osc_start;
|
||||
let mut end = None;
|
||||
while j < data.len() {
|
||||
if data[j] == 0x07 {
|
||||
end = Some(j);
|
||||
break;
|
||||
}
|
||||
if data[j] == 0x1b && j + 1 < data.len() && data[j + 1] == b'\\' {
|
||||
end = Some(j);
|
||||
break;
|
||||
}
|
||||
j += 1;
|
||||
}
|
||||
if let Some(term_pos) = end {
|
||||
if let Ok(payload) = std::str::from_utf8(&data[osc_start..term_pos]) {
|
||||
// OSC 52;c;<base64-data> → clipboard copy
|
||||
if payload.starts_with("52;") {
|
||||
// Vidarebefordra till värdterminalen
|
||||
let osc_end = if data[term_pos] == 0x07 { term_pos + 1 } else { term_pos + 2 };
|
||||
let raw = &data[i..osc_end];
|
||||
// Skriv direkt till stdout (värdterminalen)
|
||||
let _ = std::io::Write::write_all(&mut std::io::stdout(), raw);
|
||||
let _ = std::io::Write::flush(&mut std::io::stdout());
|
||||
}
|
||||
}
|
||||
i = if data[term_pos] == 0x07 { term_pos + 1 } else { term_pos + 2 };
|
||||
} else {
|
||||
break; // ofullständig OSC
|
||||
}
|
||||
} else {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Skannar ett chunk av r\u00e5 PTY-data efter DEC private mode escape-sekvenser som
|
||||
/// styr mus-tracking (\x1b[?<n>h / \x1b[?<n>l) och uppdaterar `mode` och `encoding`.
|
||||
/// En liten "carry"-buffer anv\u00e4nds f\u00f6r att hantera sekvenser som klippts mitt i
|
||||
@@ -1344,7 +1834,7 @@ fn action_display(action: &MenuAction) -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
fn key_to_bytes(key: KeyEvent) -> Option<Vec<u8>> {
|
||||
fn key_to_bytes(key: KeyEvent, app_cursor: bool, _app_keypad: bool) -> Option<Vec<u8>> {
|
||||
use KeyCode::*;
|
||||
let bytes: Vec<u8> = match key.code {
|
||||
Char(c) => {
|
||||
@@ -1364,14 +1854,21 @@ fn key_to_bytes(key: KeyEvent) -> Option<Vec<u8>> {
|
||||
}
|
||||
Enter => vec![b'\r'],
|
||||
Backspace => vec![0x7f],
|
||||
Tab => vec![b'\t'],
|
||||
Tab => {
|
||||
if key.modifiers.contains(KeyModifiers::SHIFT) {
|
||||
b"\x1b[Z".to_vec() // Shift+Tab → CSI Z (reverse tab)
|
||||
} else {
|
||||
vec![b'\t']
|
||||
}
|
||||
}
|
||||
Esc => vec![0x1b],
|
||||
Up => b"\x1b[A".to_vec(),
|
||||
Down => b"\x1b[B".to_vec(),
|
||||
Right => b"\x1b[C".to_vec(),
|
||||
Left => b"\x1b[D".to_vec(),
|
||||
Home => b"\x1b[H".to_vec(),
|
||||
End => b"\x1b[F".to_vec(),
|
||||
Insert => b"\x1b[2~".to_vec(),
|
||||
Up => if app_cursor { b"\x1bOA".to_vec() } else { b"\x1b[A".to_vec() },
|
||||
Down => if app_cursor { b"\x1bOB".to_vec() } else { b"\x1b[B".to_vec() },
|
||||
Right => if app_cursor { b"\x1bOC".to_vec() } else { b"\x1b[C".to_vec() },
|
||||
Left => if app_cursor { b"\x1bOD".to_vec() } else { b"\x1b[D".to_vec() },
|
||||
Home => if app_cursor { b"\x1bOH".to_vec() } else { b"\x1b[H".to_vec() },
|
||||
End => if app_cursor { b"\x1bOF".to_vec() } else { b"\x1b[F".to_vec() },
|
||||
PageUp => b"\x1b[5~".to_vec(),
|
||||
PageDown => b"\x1b[6~".to_vec(),
|
||||
Delete => b"\x1b[3~".to_vec(),
|
||||
@@ -1387,8 +1884,165 @@ fn key_to_bytes(key: KeyEvent) -> Option<Vec<u8>> {
|
||||
F(10) => b"\x1b[21~".to_vec(),
|
||||
F(11) => b"\x1b[23~".to_vec(),
|
||||
F(12) => b"\x1b[24~".to_vec(),
|
||||
BackTab => b"\x1b[Z".to_vec(),
|
||||
_ => return None,
|
||||
};
|
||||
Some(bytes)
|
||||
}
|
||||
|
||||
/// Enkel base64-kodning utan extern dependency.
|
||||
fn base64_encode(data: &[u8]) -> String {
|
||||
const ALPHABET: &[u8; 64] =
|
||||
b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
let mut out = String::with_capacity((data.len() + 2) / 3 * 4);
|
||||
for chunk in data.chunks(3) {
|
||||
let b0 = chunk[0] as u32;
|
||||
let b1 = if chunk.len() > 1 { chunk[1] as u32 } else { 0 };
|
||||
let b2 = if chunk.len() > 2 { chunk[2] as u32 } else { 0 };
|
||||
let triple = (b0 << 16) | (b1 << 8) | b2;
|
||||
out.push(ALPHABET[((triple >> 18) & 0x3F) as usize] as char);
|
||||
out.push(ALPHABET[((triple >> 12) & 0x3F) as usize] as char);
|
||||
if chunk.len() > 1 {
|
||||
out.push(ALPHABET[((triple >> 6) & 0x3F) as usize] as char);
|
||||
} else {
|
||||
out.push('=');
|
||||
}
|
||||
if chunk.len() > 2 {
|
||||
out.push(ALPHABET[(triple & 0x3F) as usize] as char);
|
||||
} else {
|
||||
out.push('=');
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
// ─── Kitty graphics forwarding ───────────────────────────────────────────────
|
||||
|
||||
/// Bearbetar rå PTY-data: extraherar APC Kitty graphics-sekvenser (ESC_G...ESC\),
|
||||
/// skickar resten genom vt100-parsern interfoliat, och returnerar väntande
|
||||
/// grafik-kommandon med markörposition vid varje kommando.
|
||||
fn process_pty_with_kitty_gfx(
|
||||
data: &[u8],
|
||||
parser: &mut vt100::Parser,
|
||||
pty: &mut PtyTerminal,
|
||||
carry: &mut Vec<u8>,
|
||||
) -> Vec<PendingGraphic> {
|
||||
let mut buf = std::mem::take(carry);
|
||||
buf.extend_from_slice(data);
|
||||
|
||||
let mut graphics = Vec::new();
|
||||
let mut pos = 0;
|
||||
|
||||
while pos < buf.len() {
|
||||
// Sök nästa APC start: ESC _ G (0x1b 0x5f 0x47)
|
||||
match find_apc_g_start(&buf[pos..]) {
|
||||
Some(offset) => {
|
||||
// Skicka text före APC genom parsern
|
||||
if offset > 0 {
|
||||
parser.process(&buf[pos..pos + offset]);
|
||||
}
|
||||
let apc_start = pos + offset;
|
||||
|
||||
// Sök APC slut: ESC \ (0x1b 0x5c)
|
||||
match find_apc_end(&buf[apc_start..]) {
|
||||
Some(end_offset) => {
|
||||
let apc_data = &buf[apc_start..apc_start + end_offset];
|
||||
let (row, col) = parser.screen().cursor_position();
|
||||
|
||||
if is_kitty_query(apc_data) {
|
||||
// Svara direkt på query → skicka OK tillbaka till PTY:n
|
||||
if let Some(response) = make_kitty_query_response(apc_data) {
|
||||
let _ = pty.write_input(&response);
|
||||
}
|
||||
} else {
|
||||
graphics.push(PendingGraphic {
|
||||
raw: apc_data.to_vec(),
|
||||
cursor_row: row,
|
||||
cursor_col: col,
|
||||
});
|
||||
}
|
||||
pos = apc_start + end_offset;
|
||||
}
|
||||
None => {
|
||||
// Ofullständig APC — spara resten som carry
|
||||
*carry = buf[apc_start..].to_vec();
|
||||
return graphics;
|
||||
}
|
||||
}
|
||||
}
|
||||
None => {
|
||||
// Inga fler APC-sekvenser — bearbeta resterande data
|
||||
parser.process(&buf[pos..]);
|
||||
pos = buf.len();
|
||||
}
|
||||
}
|
||||
}
|
||||
graphics
|
||||
}
|
||||
|
||||
/// Hitta nästa APC Kitty graphics start (ESC _ G) i data.
|
||||
fn find_apc_g_start(data: &[u8]) -> Option<usize> {
|
||||
if data.len() < 3 {
|
||||
return None;
|
||||
}
|
||||
for i in 0..data.len() - 2 {
|
||||
if data[i] == 0x1b && data[i + 1] == b'_' && data[i + 2] == b'G' {
|
||||
return Some(i);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Hitta APC slut (ESC \) efter start. Returnerar end offset (inkl. ESC \).
|
||||
fn find_apc_end(data: &[u8]) -> Option<usize> {
|
||||
if data.len() < 5 {
|
||||
return None;
|
||||
}
|
||||
for i in 3..data.len() - 1 {
|
||||
if data[i] == 0x1b && data[i + 1] == b'\\' {
|
||||
return Some(i + 2);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Kolla om en APC-sekvens är en Kitty graphics query (a=q).
|
||||
fn is_kitty_query(apc_data: &[u8]) -> bool {
|
||||
if apc_data.len() < 5 {
|
||||
return false;
|
||||
}
|
||||
// Header: allt mellan ESC_G och ';' (eller ESC\)
|
||||
let header_end = apc_data[3..]
|
||||
.iter()
|
||||
.position(|&b| b == b';' || b == 0x1b)
|
||||
.map(|p| p + 3)
|
||||
.unwrap_or(apc_data.len().saturating_sub(2));
|
||||
if let Ok(header) = std::str::from_utf8(&apc_data[3..header_end]) {
|
||||
header.split(',').any(|kv| kv.trim() == "a=q")
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Skapa ett Kitty graphics query-svar (OK) för en given query-sekvens.
|
||||
fn make_kitty_query_response(apc_data: &[u8]) -> Option<Vec<u8>> {
|
||||
if apc_data.len() < 5 {
|
||||
return None;
|
||||
}
|
||||
let header_end = apc_data[3..]
|
||||
.iter()
|
||||
.position(|&b| b == b';' || b == 0x1b)
|
||||
.map(|p| p + 3)
|
||||
.unwrap_or(apc_data.len().saturating_sub(2));
|
||||
let header = std::str::from_utf8(&apc_data[3..header_end]).ok()?;
|
||||
|
||||
// Extrahera image-id (i=N)
|
||||
let mut id = "0";
|
||||
for kv in header.split(',') {
|
||||
if let Some(v) = kv.strip_prefix("i=") {
|
||||
id = v;
|
||||
}
|
||||
}
|
||||
Some(format!("\x1b_Gi={};OK\x1b\\", id).into_bytes())
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user