Add build output dependency file for mouse-test application

Add standard menu fore keybinds
This commit is contained in:
2026-03-28 03:18:41 +01:00
parent 4440ad79bd
commit 3a9e292088
203 changed files with 3797 additions and 29 deletions

View File

@@ -108,6 +108,10 @@ pub struct App {
// 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,
}
pub struct DropdownState {
@@ -116,6 +120,8 @@ pub struct DropdownState {
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 {
@@ -129,6 +135,13 @@ pub struct FloatingWindow {
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>,
}
pub enum WindowContent {
@@ -324,12 +337,15 @@ impl App {
focused_id: None,
status_states,
parsed_keybinds,
tui_wm_btn_rects: Vec::new(),
hovered_tui_btn: false,
}
}
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;
@@ -349,7 +365,8 @@ impl App {
};
let mut item_rects = Vec::new();
let mut x = rect.x + 10;
// 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));
@@ -358,6 +375,8 @@ impl App {
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(
@@ -372,7 +391,9 @@ impl App {
if let (Some(pr), Some(ir)) =
(self.panel_rects.get(pi), self.panel_item_rects.get(pi))
{
let x = ir.first().map(|r| r.x).unwrap_or(pr.x);
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
@@ -382,7 +403,7 @@ impl App {
.unwrap_or(12)
.max(12);
dd.item_rects = (0..dd.items.len())
.map(|i| Rect::new(x, y + i as u16, width, 1))
.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);
}
@@ -390,7 +411,7 @@ impl App {
}
pub fn tick(&mut self) {
// ── PTY-data → vt100-parser ──────────────────────────────────────────
// ── PTY-data → vt100-parser + mus-tracking-skanning ──────────────────────────────────────
for window in &mut self.windows {
let WindowContent::Terminal { rx, parser, alive, .. } = &mut window.content else { continue };
if !*alive {
@@ -398,7 +419,17 @@ impl App {
}
loop {
match rx.try_recv() {
Ok(data) => parser.process(&data),
Ok(data) => {
// Scanna för mus-escape-sekvenser FÖRE vi ger data till vt100-parsern.
// På så sätt överlever mus-läget parser-återskapning vid resize.
scan_mouse_tracking(
&data,
&mut window.mouse_mode,
&mut window.mouse_encoding,
&mut window.mouse_seq_carry,
);
parser.process(&data);
}
Err(mpsc::TryRecvError::Empty) => break,
Err(mpsc::TryRecvError::Disconnected) => {
*alive = false;
@@ -418,8 +449,10 @@ impl App {
}
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.
}
}
@@ -594,16 +627,58 @@ impl App {
}
fn handle_mouse(&mut self, col: u16, row: u16, kind: MouseEventKind) {
// Logga alla mushändelser utom Moved (för mycker brus)
if !matches!(kind, MouseEventKind::Moved) {
crate::log::log(&format!("MOUSE {:?} col={} row={}", kind, col, row));
}
match kind {
MouseEventKind::Moved => self.update_hover(col, row),
MouseEventKind::Drag(MouseButton::Left) => self.handle_drag(col, row),
MouseEventKind::Up(MouseButton::Left) => {
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),
_ => {}
@@ -614,6 +689,7 @@ impl App {
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 {
@@ -654,6 +730,14 @@ impl App {
}
}
}
// 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) {
@@ -735,14 +819,42 @@ impl App {
return;
}
// 5. Innehållsyta → fokus
let content_id = self.windows.iter().rev().find(|w| w.in_content(col, row)).map(|w| w.id);
if let Some(id) = content_id {
// 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. Panel-knappar
// 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) {
@@ -756,11 +868,43 @@ impl App {
}
}
}
// 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 {
@@ -770,6 +914,33 @@ impl App {
}
}
/// 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,
@@ -781,6 +952,7 @@ impl App {
MenuAction::RunProgram { command, .. } => self.spawn_terminal(&command),
MenuAction::SpawnRunDialog => self.spawn_run_dialog(),
MenuAction::Submenu { .. } => {}
MenuAction::NoOp => {}
}
}
@@ -803,6 +975,9 @@ impl App {
dragging: None,
resizing: None,
resizable: false,
mouse_mode: vt100::MouseProtocolMode::None,
mouse_encoding: vt100::MouseProtocolEncoding::Default,
mouse_seq_carry: Vec::new(),
});
self.focus_window(id);
}
@@ -821,8 +996,9 @@ impl App {
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 + i as u16, width, 1)).collect();
(0..items.len()).map(|i| Rect::new(x, y + 1 + i as u16, width, 1)).collect();
self.dropdown = Some(DropdownState {
panel_idx,
@@ -830,9 +1006,51 @@ impl App {
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()
}
pub fn spawn_terminal(&mut self, shell: &str) {
let ca = self.content_area;
let offset = (self.windows.len() as i32) * 2;
@@ -859,6 +1077,9 @@ impl App {
dragging: None,
resizing: None,
resizable: true,
mouse_mode: vt100::MouseProtocolMode::None,
mouse_encoding: vt100::MouseProtocolEncoding::Default,
mouse_seq_carry: Vec::new(),
});
self.focus_window(id);
}
@@ -884,6 +1105,187 @@ impl App {
// ─── 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 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
@@ -929,6 +1331,19 @@ fn run_command(command: &str) -> String {
}
}
/// 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) -> Option<Vec<u8>> {
use KeyCode::*;
let bytes: Vec<u8> = match key.code {