- Capture OSC 0/2 window title in scan_osc_sequences since vt100 0.16 no longer exposes Screen::title() - Remove committed build artifacts (testapps/mouse-test/target) and extend .gitignore Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2058 lines
77 KiB
Rust
2058 lines
77 KiB
Rust
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<KeyCode> = 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<KeyCode> {
|
||
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::<u8>().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<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 {
|
||
pub should_quit: bool,
|
||
pub config: Config,
|
||
|
||
// Layout – uppdateras varje frame
|
||
pub panel_rects: Vec<Rect>,
|
||
pub panel_item_rects: Vec<Vec<Rect>>,
|
||
pub content_area: Rect,
|
||
|
||
// Hover-tillstånd
|
||
pub hovered_panel_item: Option<(usize, usize)>,
|
||
pub hovered_window_close: Option<usize>,
|
||
pub hovered_resize: Option<(usize, ResizeEdge)>,
|
||
|
||
// Öppen dropdown
|
||
pub dropdown: Option<DropdownState>,
|
||
|
||
// Flytande fönster (sista = överst)
|
||
pub windows: Vec<FloatingWindow>,
|
||
pub next_id: usize,
|
||
pub focused_id: Option<usize>,
|
||
|
||
// Status-widget körtillstånd, indexerat [panel][widget]
|
||
pub status_states: Vec<Vec<StatusWidgetState>>,
|
||
|
||
// Parsade keybinds från config
|
||
pub parsed_keybinds: Vec<ParsedKeybind>,
|
||
|
||
// 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 {
|
||
pub panel_idx: usize,
|
||
pub items: Vec<MenuItem>,
|
||
pub rect: Rect,
|
||
pub item_rects: Vec<Rect>,
|
||
pub hovered: Option<usize>,
|
||
/// Explicit ankar-X vid layout-uppdatering (används för TUI-WM-menyn)
|
||
pub anchor_x: Option<u16>,
|
||
}
|
||
|
||
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<ResizeState>,
|
||
/// 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<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 {
|
||
Terminal {
|
||
pty: PtyTerminal,
|
||
rx: mpsc::Receiver<Vec<u8>>,
|
||
parser: vt100::Parser,
|
||
alive: bool,
|
||
title: String,
|
||
},
|
||
RunDialog {
|
||
input: String,
|
||
cursor_pos: usize,
|
||
},
|
||
PopupDialog {
|
||
message: String,
|
||
buttons: Vec<String>,
|
||
selected: usize,
|
||
client_id: usize,
|
||
request_id: Option<String>,
|
||
},
|
||
}
|
||
|
||
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<ResizeEdge> {
|
||
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<Vec<StatusWidgetState>> = config
|
||
.panels
|
||
.iter()
|
||
.map(|p| {
|
||
p.status_widgets
|
||
.iter()
|
||
.map(|_| StatusWidgetState { output: String::new(), last_run: None })
|
||
.collect()
|
||
})
|
||
.collect();
|
||
|
||
let parsed_keybinds: Vec<ParsedKeybind> = 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<MenuAction> {
|
||
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<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={} 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<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,
|
||
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<Rect> =
|
||
(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<Rect> =
|
||
(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<MenuItem> {
|
||
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<Vec<u8>> {
|
||
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<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 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<String> {
|
||
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;<titel> / OSC 2;<titel> → 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;<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;
|
||
}
|
||
}
|
||
new_title
|
||
}
|
||
|
||
/// 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
|
||
/// 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<u8>,
|
||
) {
|
||
// 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::<u16>() {
|
||
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<String> {
|
||
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<String> {
|
||
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<Vec<u8>> {
|
||
use KeyCode::*;
|
||
let bytes: Vec<u8> = 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<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())
|
||
}
|
||
|