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 {

View File

@@ -18,6 +18,9 @@ pub struct KeybindConfig {
#[serde(default)]
pub scope: KeybindScope,
pub action: MenuAction,
/// Visningsnamn som visas i TUI-WM-menyn (valfritt)
#[serde(default)]
pub label: Option<String>,
}
#[derive(Deserialize, Debug, Clone, Default, PartialEq)]
@@ -77,6 +80,8 @@ pub enum MenuAction {
},
/// Öppnar Tui-run kommandodialog
SpawnRunDialog,
/// Gör ingenting används för display-only rader i menyer
NoOp,
}
/// En status-widget i panelen: kör ett kommando periodiskt och renderar output
@@ -106,6 +111,29 @@ fn default_interval() -> u64 {
10
}
fn default_keybinds() -> Vec<KeybindConfig> {
vec![
KeybindConfig {
key: "ctrl+space".to_string(),
scope: KeybindScope::Global,
action: MenuAction::SpawnRunDialog,
label: Some("Öppna kommandodialog".to_string()),
},
KeybindConfig {
key: "alt+enter".to_string(),
scope: KeybindScope::Global,
action: MenuAction::SpawnTerminal { shell: None },
label: Some("Ny terminal".to_string()),
},
KeybindConfig {
key: "ctrl+q".to_string(),
scope: KeybindScope::Wm,
action: MenuAction::Exit,
label: Some("Avsluta TUI-WM".to_string()),
},
]
}
impl Config {
pub fn load(path: &str) -> anyhow::Result<Config> {
let content = fs::read_to_string(path)?;
@@ -128,7 +156,10 @@ impl Config {
Ok(c) => c,
Err(e) => {
eprintln!("Varning: kunde inte ladda {}: {}", path, e);
Config::default()
Config {
keybinds: default_keybinds(),
..Config::default()
}
}
}
}

98
src/log.rs Normal file
View File

@@ -0,0 +1,98 @@
/// Enkel fil-logger. Loggar till <exe_dir>/logs/<datum>.log
/// Trådsäker via en global Mutex<File>.
use std::fs::{self, OpenOptions};
use std::io::Write;
use std::path::PathBuf;
use std::sync::Mutex;
use std::time::SystemTime;
static LOG: Mutex<Option<std::fs::File>> = Mutex::new(None);
/// Initialisera loggern. Anropas en gång från main().
pub fn init() {
let dir = log_dir();
if let Err(e) = fs::create_dir_all(&dir) {
eprintln!("log::init: kunde inte skapa logg-mapp {:?}: {}", dir, e);
return;
}
let name = today_filename();
let path = dir.join(name);
match OpenOptions::new().create(true).append(true).open(&path) {
Ok(f) => {
*LOG.lock().unwrap() = Some(f);
log(&format!("=== TUI-WM startad ==="));
}
Err(e) => eprintln!("log::init: kunde inte öppna {:?}: {}", path, e),
}
}
/// Skriv en logg-rad med tidsstämpel.
pub fn log(msg: &str) {
let ts = timestamp();
let line = format!("[{}] {}\n", ts, msg);
if let Ok(mut guard) = LOG.lock() {
if let Some(f) = guard.as_mut() {
let _ = f.write_all(line.as_bytes());
let _ = f.flush();
}
}
}
/// Makro för bekväm formatering — används som log!("foo {}", bar)
#[macro_export]
macro_rules! log {
($($arg:tt)*) => {
$crate::log::log(&format!($($arg)*))
};
}
// ─── Interna hjälpfunktioner ──────────────────────────────────────────────────
fn log_dir() -> PathBuf {
// Lägg logs/ bredvid den körande binären
let exe = std::env::current_exe().unwrap_or_else(|_| PathBuf::from("."));
exe.parent().unwrap_or(std::path::Path::new(".")).join("logs")
}
fn today_filename() -> String {
// Bygg YYYY-MM-DD från SystemTime utan externa beroenden
let secs = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let (y, mo, d) = unix_to_ymd(secs);
format!("{:04}-{:02}-{:02}.log", y, mo, d)
}
fn timestamp() -> String {
let secs = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let subsec_ms = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.map(|d| d.subsec_millis())
.unwrap_or(0);
let (y, mo, d) = unix_to_ymd(secs);
let h = (secs % 86400) / 3600;
let m = (secs % 3600) / 60;
let s = secs % 60;
format!("{:04}-{:02}-{:02} {:02}:{:02}:{:02}.{:03}", y, mo, d, h, m, s, subsec_ms)
}
/// Konverterar Unix-sekunder till (år, månad, dag). Ingen extern crate nödvändig.
fn unix_to_ymd(secs: u64) -> (u32, u32, u32) {
// Algoritm: https://howardhinnant.github.io/date_algorithms.html
let z = (secs / 86400) as i64 + 719468;
let era = if z >= 0 { z } else { z - 146096 } / 146097;
let doe = (z - era * 146097) as u32;
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
let y = yoe as i64 + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = if m <= 2 { y + 1 } else { y };
(y as u32, m, d)
}

View File

@@ -1,5 +1,6 @@
mod app;
mod config;
mod log;
mod pty;
mod render;
@@ -14,6 +15,7 @@ use ratatui::{backend::CrosstermBackend, Terminal};
use std::{io, time::Duration};
fn main() -> io::Result<()> {
log::init();
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;

View File

@@ -41,6 +41,8 @@ pub fn render(frame: &mut Frame, app: &App) {
pi,
&panel_cfg.status_widgets,
status_states,
app.focused_id.is_none(),
app.hovered_tui_btn,
);
}
}
@@ -61,6 +63,8 @@ fn render_panel(
panel_idx: usize,
status_widgets: &[crate::config::StatusWidget],
status_states: &[crate::app::StatusWidgetState],
desktop_focused: bool,
hovered_tui: bool,
) {
// Panelens bakgrund
frame.render_widget(
@@ -68,15 +72,24 @@ fn render_panel(
rect,
);
// Logotyp
// Logotyp klickbar meny-knapp med hover-highlight
let logo_style = if hovered_tui {
Style::default().fg(Color::Black).bg(Color::Cyan).add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)
};
frame.render_widget(
Paragraph::new(Span::styled(
" TUI-WM ",
Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD),
)),
Paragraph::new(Span::styled(" TUI-WM ", logo_style)),
Rect::new(rect.x + 1, rect.y, 9, 1),
);
// Fokus-indikator: grön ● = skrivbord fokuserat, grå ● = fönster fokuserat
let dot_color = if desktop_focused { Color::Green } else { Color::Indexed(240) };
frame.render_widget(
Paragraph::new(Span::styled("", Style::default().fg(dot_color).bg(Color::Indexed(238)))),
Rect::new(rect.x + 10, rect.y, 1, 1),
);
// Vänsterjusterade knappar
for (ii, item) in items.iter().enumerate() {
let Some(&ir) = item_rects.get(ii) else { continue };
@@ -117,7 +130,7 @@ fn render_panel(
}
// Vänster-dockade status-widgets renderas efter knapparna
let left_start = item_rects.last().map(|r| r.x + r.width + 1).unwrap_or(rect.x + 10);
let left_start = item_rects.last().map(|r| r.x + r.width + 1).unwrap_or(rect.x + 12);
let mut left_x = left_start;
for (si, sw_cfg) in status_widgets.iter().enumerate() {
if sw_cfg.align != StatusAlign::Left {
@@ -154,7 +167,8 @@ fn render_dropdown(frame: &mut Frame, dd: &DropdownState) {
} else {
Style::default().fg(Color::White).bg(Color::Indexed(236))
};
let draw_rect = Rect::new(ir.x + 1, ir.y + 1, ir.width.saturating_sub(2), 1);
// ir pekar redan på den faktiska raden innanför kanten
let draw_rect = Rect::new(ir.x + 1, ir.y, ir.width.saturating_sub(2), 1);
frame.render_widget(
Paragraph::new(Span::styled(format!(" {} ", item.label), style)),
draw_rect,