use crate::config::{Config, KeybindScope, MenuAction, MenuItem, PanelPosition}; use crate::pty::PtyTerminal; use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEventKind}; use ratatui::layout::Rect; use std::sync::mpsc; use std::time::{Duration, Instant}; // ─── Keybinds ──────────────────────────────────────────────────────────────── /// Parsad version av en KeybindConfig-rad pub struct ParsedKeybind { pub code: KeyCode, pub modifiers: KeyModifiers, pub scope: KeybindScope, pub action: MenuAction, } /// Parsar "ctrl+alt+t" → (KeyModifiers, KeyCode) fn parse_keybind(s: &str) -> Option<(KeyModifiers, KeyCode)> { let mut modifiers = KeyModifiers::NONE; let mut code: Option = None; for part in s.to_lowercase().split('+') { match part.trim() { "ctrl" | "control" => modifiers |= KeyModifiers::CONTROL, "alt" => modifiers |= KeyModifiers::ALT, "shift" => modifiers |= KeyModifiers::SHIFT, other => code = Some(parse_key_code(other)?), } } Some((modifiers, code?)) } fn parse_key_code(s: &str) -> Option { match s { "space" => Some(KeyCode::Char(' ')), "enter" | "return" => Some(KeyCode::Enter), "backspace" => Some(KeyCode::Backspace), "tab" => Some(KeyCode::Tab), "esc" | "escape" => Some(KeyCode::Esc), "up" => Some(KeyCode::Up), "down" => Some(KeyCode::Down), "left" => Some(KeyCode::Left), "right" => Some(KeyCode::Right), "home" => Some(KeyCode::Home), "end" => Some(KeyCode::End), "pageup" => Some(KeyCode::PageUp), "pagedown" => Some(KeyCode::PageDown), "delete" | "del" => Some(KeyCode::Delete), "insert" | "ins" => Some(KeyCode::Insert), s if s.starts_with('f') => s[1..].parse::().ok().map(KeyCode::F), s if s.chars().count() == 1 => s.chars().next().map(KeyCode::Char), _ => None, } } // ─── Resize ────────────────────────────────────────────────────────────────── #[derive(Clone, Copy, PartialEq, Debug)] pub enum ResizeEdge { Left, Right, Bottom, BottomLeft, BottomRight, TopLeft, } #[derive(Clone)] pub struct ResizeState { pub edge: ResizeEdge, pub start_mouse: (i32, i32), pub start_win: (i32, i32, u16, u16), // x, y, w, h } // ─── Status-widget körningsläge ─────────────────────────────────────────────── pub struct StatusWidgetState { pub output: String, pub last_run: Option, // None = aldrig kört, kör direkt } // ─── App IPC output ─────────────────────────────────────────────────────────── pub enum AppIpcOut { PopupResult { client_id: usize, request_id: Option, button: String, button_index: usize, }, WindowOpened { client_id: usize, request_id: Option, window_id: usize, }, } // ─── App ────────────────────────────────────────────────────────────────────── pub struct App { pub should_quit: bool, pub config: Config, // Layout – uppdateras varje frame pub panel_rects: Vec, pub panel_item_rects: Vec>, pub content_area: Rect, // Hover-tillstånd pub hovered_panel_item: Option<(usize, usize)>, pub hovered_window_close: Option, pub hovered_resize: Option<(usize, ResizeEdge)>, // Öppen dropdown pub dropdown: Option, // Flytande fönster (sista = överst) pub windows: Vec, pub next_id: usize, pub focused_id: Option, // Status-widget körtillstånd, indexerat [panel][widget] pub status_states: Vec>, // Parsade keybinds från config pub parsed_keybinds: Vec, // TUI-WM logo-knapp rects (en per panel), för klick och hover pub tui_wm_btn_rects: Vec, pub hovered_tui_btn: bool, // IPC pub socket_path: Option, pub ipc_out: Vec, } pub struct DropdownState { pub panel_idx: usize, pub items: Vec, pub rect: Rect, pub item_rects: Vec, pub hovered: Option, /// Explicit ankar-X vid layout-uppdatering (används för TUI-WM-menyn) pub anchor_x: Option, } pub struct FloatingWindow { pub id: usize, pub x: i32, pub y: i32, pub width: u16, pub height: u16, pub content: WindowContent, pub dragging: Option<(i32, i32)>, pub resizing: Option, /// Om false: inga resize-kanter, kan inte storleksändras pub resizable: bool, /// Mus-tracking-läge (PressRelease / AnyMotion osv.) begärt av appen. /// Lagras oberoende av vt100-parser så att det överlever parser-återskapning vid resize. pub mouse_mode: vt100::MouseProtocolMode, /// Mus-encoding begärd av appen (Default / Sgr / Utf8). 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, /// Aktiv text-markering (selection) i terminalen pub selection: Option, /// Carry-buffer för ofullständiga APC (Kitty graphics) sekvenser pub kitty_gfx_carry: Vec, /// Köade Kitty graphics-kommandon att vidarebefordra till värdterminalen pub pending_graphics: Vec, } /// En Kitty graphics-sekvens extraherad från PTY-data, redo att vidarebefordras. pub struct PendingGraphic { /// Rå APC-sekvens (ESC _ G ... ESC \) pub raw: Vec, /// 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 { Terminal { pty: PtyTerminal, rx: mpsc::Receiver>, parser: vt100::Parser, alive: bool, title: String, }, RunDialog { input: String, cursor_pos: usize, }, PopupDialog { message: String, buttons: Vec, selected: usize, client_id: usize, request_id: Option, }, } enum RunDialogResult { Close, Execute(String), None, } const MIN_WIN_W: u16 = 20; const MIN_WIN_H: u16 = 6; impl FloatingWindow { pub fn rect(&self) -> Rect { Rect::new(self.x.max(0) as u16, self.y.max(0) as u16, self.width, self.height) } pub fn close_btn_rect(&self) -> Rect { let x = (self.x + self.width as i32 - 4).max(0) as u16; Rect::new(x, self.y.max(0) as u16, 3, 1) } pub fn content_rect(&self) -> Rect { Rect::new( (self.x + 1).max(0) as u16, (self.y + 1).max(0) as u16, self.width.saturating_sub(2), self.height.saturating_sub(2), ) } /// Returnerar vilken resize-kant (om någon) som (col, row) träffar pub fn resize_edge_at(&self, col: u16, row: u16) -> Option { if !self.resizable { return None; } let wx = self.x.max(0) as u16; let wy = self.y.max(0) as u16; let wr = wx + self.width; // exklusiv höger let wb = wy + self.height; // exklusiv botten if col >= wr || row >= wb || col < wx || row < wy { return None; } let on_left = col == wx; let on_right = col == wr - 1; let on_top = row == wy; let on_bottom = row == wb - 1; // Övre vänstra hörnet (men INTE övre höger = stäng-knapp-zonen) if on_top && on_left { return Some(ResizeEdge::TopLeft); } // Nedre hörn if on_bottom && on_left { return Some(ResizeEdge::BottomLeft); } if on_bottom && on_right { return Some(ResizeEdge::BottomRight); } // Kanter if on_left && !on_top { return Some(ResizeEdge::Left); } if on_right && !on_top { return Some(ResizeEdge::Right); } if on_bottom { return Some(ResizeEdge::Bottom); } None } fn on_title_bar(&self, col: u16, row: u16) -> bool { let cb = self.close_btn_rect(); let wx = self.x.max(0) as u16; let wy = self.y.max(0) as u16; row == wy && col > wx // vänster kant är TopLeft-resize && col < wx + self.width && !(col >= cb.x && col < cb.x + cb.width) } fn on_close_btn(&self, col: u16, row: u16) -> bool { let cb = self.close_btn_rect(); row == cb.y && col >= cb.x && col < cb.x + cb.width } fn in_content(&self, col: u16, row: u16) -> bool { let cr = self.content_rect(); col >= cr.x && col < cr.x + cr.width && row >= cr.y && row < cr.y + cr.height } /// Applicera resize baserat på nuvarande musposition fn apply_resize(&mut self, col: i32, row: i32) { let rs = match self.resizing.clone() { Some(r) => r, None => return, }; let (sx, sy, sw, sh) = rs.start_win; let dx = col - rs.start_mouse.0; let dy = row - rs.start_mouse.1; match rs.edge { ResizeEdge::Left => { let new_w = (sw as i32 - dx).max(MIN_WIN_W as i32) as u16; self.x = sx + (sw as i32 - new_w as i32); self.width = new_w; } ResizeEdge::Right => { self.width = (sw as i32 + dx).max(MIN_WIN_W as i32) as u16; } ResizeEdge::Bottom => { self.height = (sh as i32 + dy).max(MIN_WIN_H as i32) as u16; } ResizeEdge::BottomLeft => { let new_w = (sw as i32 - dx).max(MIN_WIN_W as i32) as u16; self.x = sx + (sw as i32 - new_w as i32); self.width = new_w; self.height = (sh as i32 + dy).max(MIN_WIN_H as i32) as u16; } ResizeEdge::BottomRight => { self.width = (sw as i32 + dx).max(MIN_WIN_W as i32) as u16; self.height = (sh as i32 + dy).max(MIN_WIN_H as i32) as u16; } ResizeEdge::TopLeft => { let new_w = (sw as i32 - dx).max(MIN_WIN_W as i32) as u16; let new_h = (sh as i32 - dy).max(MIN_WIN_H as i32) as u16; self.x = sx + (sw as i32 - new_w as i32); self.y = sy + (sh as i32 - new_h as i32); self.width = new_w; self.height = new_h; } } } } // ─── App impl ───────────────────────────────────────────────────────────────── impl App { pub fn new(config: Config) -> Self { let status_states: Vec> = config .panels .iter() .map(|p| { p.status_widgets .iter() .map(|_| StatusWidgetState { output: String::new(), last_run: None }) .collect() }) .collect(); let parsed_keybinds: Vec = config .keybinds .iter() .filter_map(|kb| { let (modifiers, code) = parse_keybind(&kb.key)?; Some(ParsedKeybind { code, modifiers, scope: kb.scope.clone(), action: kb.action.clone(), }) }) .collect(); App { should_quit: false, config, panel_rects: Vec::new(), panel_item_rects: Vec::new(), content_area: Rect::default(), hovered_panel_item: None, hovered_window_close: None, hovered_resize: None, dropdown: None, windows: Vec::new(), next_id: 0, focused_id: None, status_states, parsed_keybinds, tui_wm_btn_rects: Vec::new(), hovered_tui_btn: false, socket_path: None, ipc_out: Vec::new(), } } pub fn update_layout(&mut self, area: Rect) { self.panel_rects.clear(); self.panel_item_rects.clear(); self.tui_wm_btn_rects.clear(); let mut top_used = 0u16; let mut bottom_used = 0u16; for panel in &self.config.panels { let rect = match panel.position { PanelPosition::Top => { let r = Rect::new(area.x, area.y + top_used, area.width, 1); top_used += 1; r } PanelPosition::Bottom => { bottom_used += 1; Rect::new(area.x, area.y + area.height.saturating_sub(bottom_used), area.width, 1) } PanelPosition::Left | PanelPosition::Right => Rect::default(), }; let mut item_rects = Vec::new(); // Items börjar efter logo (9) + fokus-dot (1) + mellanrum (1) = x+12 let mut x = rect.x + 12; for item in &panel.items { let w = item.label.len() as u16 + 2; item_rects.push(Rect::new(x, rect.y, w, 1)); x += w + 1; } self.panel_rects.push(rect); self.panel_item_rects.push(item_rects); // Logo-knapp rect: " TUI-WM " (9 tecken bred) vid x+1 self.tui_wm_btn_rects.push(Rect::new(rect.x + 1, rect.y, 9, 1)); } self.content_area = Rect::new( area.x, area.y + top_used, area.width, area.height.saturating_sub(top_used + bottom_used), ); if let Some(dd) = &mut self.dropdown { let pi = dd.panel_idx; if let (Some(pr), Some(ir)) = (self.panel_rects.get(pi), self.panel_item_rects.get(pi)) { let x = dd.anchor_x .or_else(|| ir.first().map(|r| r.x)) .unwrap_or(pr.x); let y = pr.y + 1; let width = dd .items .iter() .map(|i| i.label.len() as u16 + 2) .max() .unwrap_or(12) .max(12); dd.item_rects = (0..dd.items.len()) .map(|i| Rect::new(x, y + 1 + i as u16, width, 1)) .collect(); dd.rect = Rect::new(x, y, width, dd.items.len() as u16); } } } pub fn tick(&mut self) { // ── PTY-data → vt100-parser + mus-tracking-skanning ────────────────────────────────────── for window in &mut self.windows { let WindowContent::Terminal { rx, parser, pty, alive, title, .. } = &mut window.content else { continue }; if !*alive { continue; } loop { match rx.try_recv() { Ok(data) => { // Scanna för mus-escape-sekvenser FÖRE vi ger data till vt100-parsern. scan_mouse_tracking( &data, &mut window.mouse_mode, &mut window.mouse_encoding, &mut window.mouse_seq_carry, ); // 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 (titel, clipboard, etc.) // vt100 0.16 exponerar inte längre titeln på Screen, // så vi fångar OSC 0/2 själva här. if let Some(t) = scan_osc_sequences(&data) { *title = t; } // 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) => { *alive = false; break; } } } } // ── PTY resize om fönsterstorlek ändrats ───────────────────────────── for window in &mut self.windows { let new_rows = window.height.saturating_sub(2).max(1); let new_cols = window.width.saturating_sub(2).max(1); let WindowContent::Terminal { pty, parser, alive, .. } = &mut window.content else { continue }; if !*alive { continue; } let (cur_rows, cur_cols) = parser.screen().size(); if cur_rows != new_rows || cur_cols != new_cols { crate::log::log(&format!("PTY resize: {}x{} -> {}x{} (mouse mode was {:?})", cur_cols, cur_rows, new_cols, new_rows, window.mouse_mode)); let _ = pty.resize(new_rows, new_cols); *parser = vt100::Parser::new(new_rows, new_cols, 0); // OBS: window.mouse_mode / window.mouse_encoding berörs inte — överlever restet. } } // ── Ta bort döda fönster ───────────────────────────────────────────── let prev_focused = self.focused_id; 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) { self.focused_id = self.windows.last().map(|w| w.id); } } // ── Status-widgets: kör kommandon vid behov ────────────────────────── for (pi, panel) in self.config.panels.iter().enumerate() { for (si, sw_cfg) in panel.status_widgets.iter().enumerate() { if let Some(state) = self .status_states .get_mut(pi) .and_then(|v| v.get_mut(si)) { let should_run = match state.last_run { None => true, Some(t) => t.elapsed() >= Duration::from_secs(sw_cfg.interval), }; if should_run { state.output = run_command(&sw_cfg.command); state.last_run = Some(Instant::now()); } } } } } 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, 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); return; } // 2. RunDialog hanterar sina egna tangenter if let Some(id) = self.focused_id { let is_dialog = self.windows.iter() .any(|w| w.id == id && matches!(w.content, WindowContent::RunDialog { .. })); if is_dialog { let result = self.handle_run_dialog_key(id, key); match result { RunDialogResult::Close => self.close_window(id), RunDialogResult::Execute(cmd) => { self.close_window(id); if !cmd.is_empty() { 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 => {} } return; } } // 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(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; } } } // 4. WM-keybinds – bara när ingen terminal är fokuserad let terminal_focused = self.focused_id.map_or(false, |id| { self.windows.iter().any(|w| w.id == id && matches!(w.content, WindowContent::Terminal { .. })) }); if !terminal_focused { if let Some(action) = self.match_keybind(key, KeybindScope::Wm) { self.execute_action(action); return; } } // 5. Inbyggd fallback if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('q') { self.should_quit = true; } } fn match_keybind(&self, key: KeyEvent, scope: KeybindScope) -> Option { self.parsed_keybinds .iter() .find(|kb| kb.scope == scope && kb.code == key.code && kb.modifiers == key.modifiers) .map(|kb| kb.action.clone()) } fn handle_run_dialog_key(&mut self, id: usize, key: KeyEvent) -> RunDialogResult { let window = match self.windows.iter_mut().find(|w| w.id == id) { Some(w) => w, None => return RunDialogResult::None, }; let WindowContent::RunDialog { input, cursor_pos } = &mut window.content else { return RunDialogResult::None; }; match key.code { KeyCode::Esc => RunDialogResult::Close, KeyCode::Enter => RunDialogResult::Execute(input.trim().to_string()), KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => { input.insert(*cursor_pos, c); *cursor_pos += c.len_utf8(); RunDialogResult::None } KeyCode::Backspace => { if *cursor_pos > 0 { let prev = input[..*cursor_pos] .char_indices() .last() .map(|(i, _)| i) .unwrap_or(0); input.drain(prev..*cursor_pos); *cursor_pos = prev; } RunDialogResult::None } KeyCode::Delete => { if *cursor_pos < input.len() { input.remove(*cursor_pos); } RunDialogResult::None } KeyCode::Left => { if *cursor_pos > 0 { *cursor_pos = input[..*cursor_pos] .char_indices() .last() .map(|(i, _)| i) .unwrap_or(0); } RunDialogResult::None } KeyCode::Right => { if *cursor_pos < input.len() { let mut ci = input[*cursor_pos..].char_indices(); ci.next(); *cursor_pos += ci.next().map(|(i, _)| i).unwrap_or(input.len() - *cursor_pos); } RunDialogResult::None } KeyCode::Home => { *cursor_pos = 0; RunDialogResult::None } KeyCode::End => { *cursor_pos = input.len(); RunDialogResult::None } _ => RunDialogResult::None, } } fn handle_popup_key( &mut self, id: usize, key: KeyEvent, ) -> Option<(String, usize, usize, Option)> { 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={} 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); self.forward_mouse_to_focused_terminal(kind, col, row); } MouseEventKind::Drag(MouseButton::Left) => { self.handle_drag(col, row); // Vidarebefordra drag-rörelse till terminal om vi inte håller på med WM-drag/resize let doing_wm = self.windows.iter().any(|w| w.dragging.is_some() || w.resizing.is_some()); if !doing_wm { self.forward_mouse_to_focused_terminal(kind, col, row); } } MouseEventKind::Up(btn) => { for w in &mut self.windows { w.dragging = None; w.resizing = None; } self.forward_mouse_to_focused_terminal(MouseEventKind::Up(btn), col, row); } MouseEventKind::Down(MouseButton::Left) => self.handle_click(col, row), MouseEventKind::Down(btn) => { // Höger/mitten-klick: fokusera fönster och vidarebefordra till terminal let hit = self.windows.iter().rev().find(|w| w.in_content(col, row)).and_then(|w| { if let WindowContent::Terminal { alive, .. } = &w.content { if !*alive { return None; } Some((w.id, w.content_rect(), w.mouse_mode, w.mouse_encoding)) } else { None } }); if let Some((id, content_rect, mode, encoding)) = hit { crate::log::log(&format!(" -> right/middle hit window={} mode={:?} enc={:?} content_rect={:?}", id, mode, encoding, content_rect)); self.focus_window(id); if let Some(bytes) = encode_mouse_event(MouseEventKind::Down(btn), col, row, content_rect, mode, encoding) { crate::log::log(&format!(" -> sending {} bytes: {:?}", bytes.len(), bytes)); if let Some(w) = self.windows.iter_mut().find(|w| w.id == id) { if let WindowContent::Terminal { pty, .. } = &mut w.content { let _ = pty.write_input(&bytes); } } } else { crate::log::log(" -> encode_mouse_event returned None (mode=None or filtered)"); } } else { crate::log::log(" -> no terminal window hit"); } } MouseEventKind::ScrollUp => self.handle_scroll(col, row, true), MouseEventKind::ScrollDown => self.handle_scroll(col, row, false), _ => {} } } fn update_hover(&mut self, col: u16, row: u16) { self.hovered_panel_item = None; self.hovered_window_close = None; self.hovered_resize = None; self.hovered_tui_btn = false; // Dropdown-hover if let Some(dd) = &mut self.dropdown { dd.hovered = None; for (i, r) in dd.item_rects.clone().iter().enumerate() { if in_rect(col, row, *r) { dd.hovered = Some(i); return; } } } // Stäng-knapp (översta fönstret först) for w in self.windows.iter().rev() { if w.on_close_btn(col, row) { self.hovered_window_close = Some(w.id); return; } } // Resize-kant for w in self.windows.iter().rev() { if let Some(edge) = w.resize_edge_at(col, row) { // Kontrollera att det inte är stäng-knappen (redan hanterat ovan) if !w.on_close_btn(col, row) { self.hovered_resize = Some((w.id, edge)); return; } } } // Panel-knappar for (pi, item_rects) in self.panel_item_rects.iter().enumerate() { for (ii, r) in item_rects.iter().enumerate() { if in_rect(col, row, *r) { self.hovered_panel_item = Some((pi, ii)); return; } } } // TUI-WM logo-knapp for &r in &self.tui_wm_btn_rects { if in_rect(col, row, r) { self.hovered_tui_btn = true; return; } } } fn handle_drag(&mut self, col: u16, row: u16) { let col = col as i32; let row = row as i32; // Resize har prioritet över drag for w in self.windows.iter_mut().rev() { if w.resizing.is_some() { w.apply_resize(col, row); return; } } for w in self.windows.iter_mut().rev() { if let Some((ox, oy)) = w.dragging { w.x = col - ox; w.y = row - oy; return; } } } fn handle_click(&mut self, col: u16, row: u16) { // 1. Dropdown-item if let Some(idx) = self.dropdown.as_ref().and_then(|dd| dd.hovered) { let action = self .dropdown .as_ref() .and_then(|dd| dd.items.get(idx)) .map(|i| i.action.clone()); self.dropdown = None; if let Some(action) = action { self.execute_action(action); } return; } if self.dropdown.is_some() { self.dropdown = None; return; } // 2. Stäng-knapp let close_id = self.windows.iter().rev().find(|w| w.on_close_btn(col, row)).map(|w| w.id); if let Some(id) = close_id { self.close_window(id); return; } // 3. Resize-kant let resize_hit = self .windows .iter() .rev() .find_map(|w| w.resize_edge_at(col, row).map(|e| (w.id, w.x, w.y, w.width, w.height, e))); if let Some((id, wx, wy, ww, wh, edge)) = resize_hit { self.focus_window(id); if let Some(w) = self.windows.iter_mut().find(|w| w.id == id) { w.resizing = Some(ResizeState { edge, start_mouse: (col as i32, row as i32), start_win: (wx, wy, ww, wh), }); } return; } // 4. Titel-rad → drag + fokus let title_hit = self .windows .iter() .rev() .find(|w| w.on_title_bar(col, row)) .map(|w| (w.id, col as i32 - w.x, row as i32 - w.y)); if let Some((id, ox, oy)) = title_hit { self.focus_window(id); if let Some(w) = self.windows.iter_mut().find(|w| w.id == id) { w.dragging = Some((ox, oy)); } return; } // 5. Innehållsyta → fokus + vidarebefordra musklick till terminal let hit = self.windows.iter().rev().find(|w| w.in_content(col, row)).and_then(|w| { let (mode, encoding) = match &w.content { WindowContent::Terminal { alive, .. } if *alive => (w.mouse_mode, w.mouse_encoding), _ => (vt100::MouseProtocolMode::None, vt100::MouseProtocolEncoding::Default), }; Some((w.id, w.content_rect(), mode, encoding)) }); if let Some((id, content_rect, mode, encoding)) = hit { crate::log::log(&format!(" click step5: window={} mode={:?} enc={:?} col={} row={} content_rect={:?}", id, mode, encoding, col, row, content_rect)); self.focus_window(id); if let Some(bytes) = encode_mouse_event( MouseEventKind::Down(MouseButton::Left), col, row, content_rect, mode, encoding, ) { crate::log::log(&format!(" -> sending {} bytes: {:?}", bytes.len(), bytes)); if let Some(w) = self.windows.iter_mut().find(|w| w.id == id) { if let WindowContent::Terminal { pty, .. } = &mut w.content { let _ = pty.write_input(&bytes); } } } else { crate::log::log(" -> encode_mouse_event returned None"); } return; } // 6. TUI-WM logo → keybind-meny for pi in 0..self.tui_wm_btn_rects.len() { let r = self.tui_wm_btn_rects[pi]; if in_rect(col, row, r) { self.open_tui_wm_dropdown(pi); return; } } // 7. Panel-knappar for (pi, item_rects) in self.panel_item_rects.clone().iter().enumerate() { for (ii, r) in item_rects.iter().enumerate() { if in_rect(col, row, *r) { let action = self.config.panels[pi].items[ii].action.clone(); if let MenuAction::Submenu { items } = action { self.open_dropdown(pi, ii, items); } else { self.execute_action(action); } return; } } } // 8. Inget träffat → skrivbordet fokuseras self.focused_id = None; } fn handle_scroll(&mut self, col: u16, row: u16, up: bool) { let id = self.windows.iter().rev().find(|w| in_rect(col, row, w.rect())).map(|w| w.id); if let Some(id) = id { // Hämta mus-tracking-info utan mutable lån let info = self.windows.iter().find(|w| w.id == id).and_then(|w| { if let WindowContent::Terminal { alive, .. } = &w.content { if !*alive { return None; } if w.mouse_mode == vt100::MouseProtocolMode::None { return None; } Some((w.content_rect(), w.mouse_mode, w.mouse_encoding)) } else { None } }); if let Some((content_rect, mode, encoding)) = info { let kind = if up { MouseEventKind::ScrollUp } else { MouseEventKind::ScrollDown }; crate::log::log(&format!(" scroll up={} window={} mode={:?} enc={:?}", up, id, mode, encoding)); if let Some(bytes) = encode_mouse_event(kind, col, row, content_rect, mode, encoding) { crate::log::log(&format!(" -> scroll sending {} bytes: {:?}", bytes.len(), bytes)); if let Some(w) = self.windows.iter_mut().find(|w| w.id == id) { if let WindowContent::Terminal { pty, .. } = &mut w.content { let _ = pty.write_input(&bytes); return; } } } else { crate::log::log(" -> scroll encode returned None"); } } // Fallback: piltangenter om ingen mus-tracking är aktiv crate::log::log(&format!(" scroll fallback arrows up={} window={}", up, id)); let bytes: &[u8] = if up { b"\x1b[A\x1b[A\x1b[A" } else { b"\x1b[B\x1b[B\x1b[B" }; if let Some(w) = self.windows.iter_mut().find(|w| w.id == id) { if let WindowContent::Terminal { pty, .. } = &mut w.content { let _ = pty.write_input(bytes); } } } } /// Vidarebefordrar ett mushändelse till det fokuserade terminalfönstret /// om musen befinner sig i fönstrets innehållsyta och mus-tracking är aktivt. fn forward_mouse_to_focused_terminal(&mut self, kind: MouseEventKind, col: u16, row: u16) { let Some(id) = self.focused_id else { return }; let info = self.windows.iter().find(|w| w.id == id).and_then(|w| { if !w.in_content(col, row) { return None; } if let WindowContent::Terminal { alive, .. } = &w.content { if !*alive { return None; } Some((w.content_rect(), w.mouse_mode, w.mouse_encoding)) } else { None } }); if let Some((content_rect, mode, encoding)) = info { if let Some(bytes) = encode_mouse_event(kind, col, row, content_rect, mode, encoding) { crate::log::log(&format!(" forward {:?} -> window={} {} bytes: {:?}", kind, id, bytes.len(), bytes)); if let Some(w) = self.windows.iter_mut().find(|w| w.id == id) { if let WindowContent::Terminal { pty, .. } = &mut w.content { let _ = pty.write_input(&bytes); } } } } } fn execute_action(&mut self, action: MenuAction) { match action { MenuAction::Exit => self.should_quit = true, MenuAction::SpawnTerminal { 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::SpawnRunDialog => self.spawn_run_dialog(), MenuAction::Submenu { .. } => {} MenuAction::NoOp => {} } } pub fn spawn_run_dialog(&mut self) { let ca = self.content_area; let w = 46u16.min(ca.width.saturating_sub(4)); let h = 5u16; 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::RunDialog { input: String::new(), cursor_pos: 0 }, 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 spawn_popup_dialog( &mut self, message: String, buttons: Vec, client_id: usize, request_id: Option, ) { 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) { let pr = match self.panel_rects.get(panel_idx) { Some(r) => *r, None => return, }; let ir = self.panel_item_rects.get(panel_idx) .and_then(|v| v.get(item_idx)) .copied() .unwrap_or(pr); let width = items.iter().map(|i| i.label.len() as u16 + 2).max().unwrap_or(12).max(12); let x = ir.x; let y = pr.y + 1; // item_rects peker på den faktiska synliga raden (y+1 = innanför övre kant) let item_rects: Vec = (0..items.len()).map(|i| Rect::new(x, y + 1 + i as u16, width, 1)).collect(); self.dropdown = Some(DropdownState { panel_idx, rect: Rect::new(x, y, width, items.len() as u16), item_rects, items, hovered: None, anchor_x: None, }); } /// Öppnar en dropdown med alla keybinds, förankrad vid TUI-WM-logotypen. fn open_tui_wm_dropdown(&mut self, panel_idx: usize) { let pr = match self.panel_rects.get(panel_idx) { Some(r) => *r, None => return, }; let btn_r = self.tui_wm_btn_rects.get(panel_idx).copied().unwrap_or(pr); let items = self.build_keybind_menu_items(); if items.is_empty() { return; } let width = items.iter().map(|i| i.label.len() as u16 + 2).max().unwrap_or(20).max(20); let x = btn_r.x; let y = pr.y + 1; // item_rects peker på den faktiska synliga raden (y+1 = innanför övre kant) let item_rects: Vec = (0..items.len()).map(|i| Rect::new(x, y + 1 + i as u16, width, 1)).collect(); self.dropdown = Some(DropdownState { panel_idx, rect: Rect::new(x, y, width, items.len() as u16), item_rects, items, hovered: None, anchor_x: Some(x), }); } /// Bygger meny-items från config.keybinds för visning i TUI-WM-menyn. /// Varje item kör keybindens faktiska action vid klick. fn build_keybind_menu_items(&self) -> Vec { use crate::config::KeybindScope; self.config.keybinds.iter().map(|kb| { let desc = kb.label.as_deref().unwrap_or_else(|| action_display(&kb.action)); let scope = if kb.scope == KeybindScope::Global { " [global]" } else { "" }; MenuItem { label: format!("{:<14} {}{}", kb.key, desc, scope), action: kb.action.clone(), } }).collect() } /// 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)); let h = 26u16.min(ca.height.saturating_sub(4)); let x = ca.x as i32 + 2 + offset; let y = ca.y as i32 + 1 + offset; let rows = h.saturating_sub(2).max(1); let cols = w.saturating_sub(2).max(1); 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); self.next_id += 1; let title = shell.split('/').last().unwrap_or(shell).to_string(); self.windows.push(FloatingWindow { id, x, y, width: w, height: h, content: WindowContent::Terminal { pty, rx, parser, alive: true, title }, dragging: None, resizing: None, resizable: true, 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) { self.windows.retain(|w| w.id != id); if self.focused_id == Some(id) { self.focused_id = self.windows.last().map(|w| w.id); } } fn focus_window(&mut self, id: usize) { if let Some(pos) = self.windows.iter().position(|w| w.id == id) { let w = self.windows.remove(pos); self.windows.push(w); } self.focused_id = Some(id); } } // ─── Hjälpfunktioner ───────────────────────────────────────────────────────── /// Kodar ett crossterm-mushändelse till ANSI-byte-sekvens enligt terminalen /// begärda mus-tracking-protokoll. Returnerar None om protokollet är None eller /// om händelsetypen inte täcks av det aktiva protokollet. fn encode_mouse_event( kind: MouseEventKind, col: u16, row: u16, content_rect: Rect, mode: vt100::MouseProtocolMode, encoding: vt100::MouseProtocolEncoding, ) -> Option> { if mode == vt100::MouseProtocolMode::None { return None; } // 1-baserade terminalkoordinater relativt innehållsytan let term_col = col.saturating_sub(content_rect.x) + 1; let term_row = row.saturating_sub(content_rect.y) + 1; let (button, is_release): (u8, bool) = match kind { MouseEventKind::Down(MouseButton::Left) => (0, false), MouseEventKind::Down(MouseButton::Middle) => (1, false), MouseEventKind::Down(MouseButton::Right) => (2, false), MouseEventKind::Up(_) => (3, true), MouseEventKind::Drag(MouseButton::Left) => (32, false), MouseEventKind::Drag(MouseButton::Middle) => (33, false), MouseEventKind::Drag(MouseButton::Right) => (34, false), MouseEventKind::Moved => (35, false), MouseEventKind::ScrollUp => (64, false), MouseEventKind::ScrollDown => (65, false), _ => return None, }; // Filtrera händelsetyp mot aktivt protokoll match mode { vt100::MouseProtocolMode::None => return None, vt100::MouseProtocolMode::Press => { if !matches!(kind, MouseEventKind::Down(_) | MouseEventKind::ScrollUp | MouseEventKind::ScrollDown ) { return None; } } vt100::MouseProtocolMode::PressRelease => { if !matches!(kind, MouseEventKind::Down(_) | MouseEventKind::Up(_) | MouseEventKind::ScrollUp | MouseEventKind::ScrollDown ) { return None; } } vt100::MouseProtocolMode::ButtonMotion => { if !matches!(kind, MouseEventKind::Down(_) | MouseEventKind::Up(_) | MouseEventKind::Drag(_) | MouseEventKind::ScrollUp | MouseEventKind::ScrollDown ) { return None; } } vt100::MouseProtocolMode::AnyMotion => {} // alla händelsetyper passerar } Some(match encoding { vt100::MouseProtocolEncoding::Default => { if term_col > 223 || term_row > 223 { return None; } vec![0x1b, b'[', b'M', button + 32, term_col as u8 + 32, term_row as u8 + 32] } vt100::MouseProtocolEncoding::Sgr => { let suffix = if is_release { 'm' } else { 'M' }; format!("\x1b[<{};{};{}{}", button, term_col, term_row, suffix).into_bytes() } vt100::MouseProtocolEncoding::Utf8 => { let mut bytes = vec![0x1b, b'[', b'M', button + 32]; for coord in [term_col, term_row] { let code_point = coord as u32 + 32; if let Some(c) = char::from_u32(code_point) { let mut buf = [0u8; 4]; bytes.extend_from_slice(c.encode_utf8(&mut buf).as_bytes()); } else { return None; } } bytes } }) } /// 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> { 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 0/2 (fönstertitel) → returneras så fönsterrubriken kan uppdateras /// OSC 52 (clipboard copy) → skickar vidare till värdterminalen via stdout /// OSC 11 (query bg color) → svarar med standardfärg /// Returnerar senast satta fönstertitel i chunken, om någon. fn scan_osc_sequences(data: &[u8]) -> Option { let mut new_title = None; 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 0; / OSC 2; → fönstertitel if let Some(t) = payload.strip_prefix("0;").or_else(|| payload.strip_prefix("2;")) { if !t.is_empty() { new_title = Some(t.to_string()); } } // OSC 52;c; → 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; } } new_title } /// Skannar ett chunk av r\u00e5 PTY-data efter DEC private mode escape-sekvenser som /// styr mus-tracking (\x1b[?h / \x1b[?l) och uppdaterar `mode` och `encoding`. /// En liten "carry"-buffer anv\u00e4nds f\u00f6r att hantera sekvenser som klippts mitt i /// en chunk-gr\u00e4ns.\n/// /// Hanterar b\u00e5de enkla sekvenser (\x1b[?1003h) och kombinerade sekvenser (\x1b[?1003;1006h). fn scan_mouse_tracking( data: &[u8], mode: &mut vt100::MouseProtocolMode, encoding: &mut vt100::MouseProtocolEncoding, carry: &mut Vec, ) { // Kombinera carry med ny data let mut buf = std::mem::take(carry); buf.extend_from_slice(data); let mut i = 0; while i < buf.len() { if buf[i] != 0x1b { i += 1; continue; } // Kolla om vi har ESC [ ? if i + 2 < buf.len() && buf[i + 1] == b'[' && buf[i + 2] == b'?' { let num_start = i + 3; let mut j = num_start; // Scanna siffror och semikolon tills h eller l (eller slut p\u00e5 buffer) while j < buf.len() && (buf[j].is_ascii_digit() || buf[j] == b';') { j += 1; } if j >= buf.len() { // Ofullst\u00e4ndig sekvens \u2014 spara resten som carry *carry = buf[i..].to_vec(); return; } if buf[j] == b'h' || buf[j] == b'l' { let enable = buf[j] == b'h'; if let Ok(s) = std::str::from_utf8(&buf[num_start..j]) { let old_mode = *mode; let old_enc = *encoding; for part in s.split(';') { if let Ok(n) = part.trim().parse::() { apply_mouse_dec_mode(n, enable, mode, encoding); } } if *mode != old_mode || *encoding != old_enc { crate::log::log(&format!( "PTY mus-lage uppdaterat: mode={:?} enc={:?} via ESC[?{}{}", mode, encoding, s, if enable { 'h' } else { 'l' } )); } } i = j + 1; } else { // Inte h/l \u2014 bara en ESC vi inte k\u00e4nner i += 1; } } else if i + 1 >= buf.len() { // M\u00f6jlig ofullst\u00e4ndig ESC sekvens vid bufferkanten *carry = buf[i..].to_vec(); return; } else { i += 1; } } } /// Till\u00e4mpar en DEC private mode-\u00e4ndring p\u00e5 det laggrade mus-l\u00e4get. fn apply_mouse_dec_mode( n: u16, enable: bool, mode: &mut vt100::MouseProtocolMode, encoding: &mut vt100::MouseProtocolEncoding, ) { if enable { match n { 9 => *mode = vt100::MouseProtocolMode::Press, // X10 1000 => *mode = vt100::MouseProtocolMode::PressRelease, // VT200 1002 => *mode = vt100::MouseProtocolMode::ButtonMotion, 1003 => *mode = vt100::MouseProtocolMode::AnyMotion, 1005 => *encoding = vt100::MouseProtocolEncoding::Utf8, 1006 => *encoding = vt100::MouseProtocolEncoding::Sgr, 1015 => {} // URXVT \u2014 behandla som Default (ignorera) _ => {} } } else { match n { 9 | 1000 | 1002 | 1003 => *mode = vt100::MouseProtocolMode::None, 1005 | 1006 => *encoding = vt100::MouseProtocolEncoding::Default, _ => {} } } } fn in_rect(col: u16, row: u16, rect: Rect) -> bool { col >= rect.x && col < rect.x + rect.width && row >= rect.y && row < rect.y + rect.height } fn default_shell() -> String { if cfg!(windows) { // Försök hitta PowerShell i prioritetsordning: pwsh (Core) → powershell (inbyggd) which_windows("pwsh").or_else(|| which_windows("powershell")).unwrap_or_else(|| "powershell.exe".to_string()) } else { std::env::var("SHELL").unwrap_or_else(|_| "/bin/bash".to_string()) } } #[cfg(windows)] fn which_windows(name: &str) -> Option { let exe = format!("{}.exe", name); std::env::var("PATH").ok()?.split(';').find_map(|dir| { let path = std::path::Path::new(dir).join(&exe); if path.exists() { Some(path.to_string_lossy().into_owned()) } else { None } }) } #[cfg(not(windows))] fn which_windows(_name: &str) -> Option { None } /// Kör ett shell-kommando och returnerar dess stdout som trimmat str fn run_command(command: &str) -> String { #[cfg(unix)] let result = std::process::Command::new("sh").arg("-c").arg(command).output(); #[cfg(windows)] let result = std::process::Command::new("powershell.exe") .args(["-NoProfile", "-NonInteractive", "-Command", command]) .output(); match result { Ok(out) => String::from_utf8_lossy(&out.stdout).trim().to_string(), Err(_) => String::new(), } } /// Returnerar ett kortfattat namn för en MenuAction (används som fallback i keybind-menyn). fn action_display(action: &MenuAction) -> &'static str { match action { MenuAction::Exit => "Avsluta", MenuAction::SpawnTerminal { .. } => "Ny terminal", MenuAction::RunScript { .. } => "Kör skript", MenuAction::RunProgram { .. } => "Kör program", MenuAction::Submenu { .. } => "Undermeny", MenuAction::SpawnRunDialog => "Kommandodialog", MenuAction::NoOp => "", } } fn key_to_bytes(key: KeyEvent, app_cursor: bool, _app_keypad: bool) -> Option> { use KeyCode::*; let bytes: Vec = match key.code { Char(c) => { if key.modifiers.contains(KeyModifiers::CONTROL) { match c { 'a'..='z' => vec![c as u8 - b'a' + 1], 'A'..='Z' => vec![c as u8 - b'A' + 1], '[' => vec![0x1b], '\\' => vec![0x1c], ']' => vec![0x1d], _ => return None, } } else { let mut buf = [0u8; 4]; c.encode_utf8(&mut buf).as_bytes().to_vec() } } Enter => vec![b'\r'], Backspace => vec![0x7f], 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], 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(), F(1) => b"\x1bOP".to_vec(), F(2) => b"\x1bOQ".to_vec(), F(3) => b"\x1bOR".to_vec(), F(4) => b"\x1bOS".to_vec(), F(5) => b"\x1b[15~".to_vec(), F(6) => b"\x1b[17~".to_vec(), F(7) => b"\x1b[18~".to_vec(), F(8) => b"\x1b[19~".to_vec(), F(9) => b"\x1b[20~".to_vec(), 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, ) -> Vec { 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 { 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 { 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> { 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()) }