Files
TUI-WM/src/app.rs
Bjorn Blomberg b8d6a18a2e Upgrade TUI stack: ratatui 0.30, crossterm 0.29, portable-pty 0.9, vt100 0.16
- 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>
2026-07-14 19:31:41 +02:00

2058 lines
77 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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())
}