diff --git a/README.md b/README.md index 462fa2d..54356ae 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,17 @@ TUI-WM/ ## Konfiguration +Toppnivå-nycklar i `config.toml`: + +| Nyckel | Default | Beskrivning | +|---|---|---| +| `default_shell` | `$SHELL` | Skal för nya terminaler | +| `default_window_size` | `[82, 26]` | `[bredd, höjd]` för nya terminalfönster | +| `scrollback_lines` | `1000` | Historikrader per virtuell terminal | +| `show_scrollbar` | `true` | Scrollbar på terminalfönstrens högerkant | +| `background_image` | – | Bakgrundsbild via Kitty graphics | +| `terminal_bg_color` | genomskinlig | Terminalfönstrens bakgrundsfärg | + Konfigurationen delas upp i två nivåer: 1. **`config.toml`** — huvud-konfigfil, definierar paneler och keybinds @@ -206,7 +217,7 @@ Exempelnycklar: `a`–`z`, `0`–`9`, `space`, `enter`, `esc`, `tab`, `backspace | Vänsterklick på `[x]` | Stänger fönstret | | Vänsterklick på kant/hörn | Börjar storleksändra fönstret | | Klick i innehållsyta | Fokuserar fönstret och skickar musklick till appen i terminalen | -| Scroll inuti terminal | Skickar scroll-event till appen (med ANSI mus-protokoll) eller pilar som fallback | +| Scroll inuti terminal | Vidarebefordras till appen om den fångar musen; annars scrollas terminalens egen historik (`scrollback_lines`, scrollbar på högerkanten). På alternativskärm utan mus-stöd skickas pilar | | Höger/mitten-klick i terminal | Vidarebefordras direkt till appen | Möss-tracking fungerar automatiskt — om appen i terminalen aktiverar ANSI mus-protokoll (t.ex. vim, htop, ncurses-appar) vidarebefordras alla mus-events korrekt med rätt encoding (SGR, UTF-8 eller default X10). @@ -215,6 +226,8 @@ Möss-tracking fungerar automatiskt — om appen i terminalen aktiverar ANSI mus ## Startlägen +Kör `tui-wm --help` för en komplett flagg- och tangentöversikt. + TUI-WM kan startas i tre lägen: ### Standalone (standard) @@ -233,6 +246,8 @@ tui-wm -d Kör headless — ingen lokal terminal-rendering. State hanteras enbart via socketen. Frames renderas internt och skickas till anslutna display-klienter. Passar för att köra TUI-WM som bakgrundstjänst. +Daemonen stängs INTE av att en klient kopplar ner eller trycker `ctrl+q` — det kopplar bara ner den klienten. Stoppa daemonen med `pkill tui-wm` eller via systemd. + ### Klient (`-c`) ```bash diff --git a/config.toml b/config.toml index 00b6f88..164efc1 100644 --- a/config.toml +++ b/config.toml @@ -11,6 +11,17 @@ background_image = "/home/brasse/Bilder/backrunder/pexels-veeterzy-38136.jpg" # Standard-shell för nya terminaler. Om inte satt → $SHELL eller /bin/bash. # default_shell = "/bin/fish" +# Antal raders scrollback-historik per virtuell terminal (default: 1000). +# Scrolla med mushjulet i fönster vars app inte själv fångar musen; +# tangenttryck hoppar tillbaka till botten. +# scrollback_lines = 1000 + +# Visa scrollbar på terminalfönstrens högerkant när historik finns (default: true) +# show_scrollbar = true + +# Standardstorlek [bredd, höjd] för nya terminalfönster (default: [82, 26]) +# default_window_size = [82, 26] + [[panel]] position = "top" file = "panels/topbar.toml" diff --git a/src/app.rs b/src/app.rs index f15623e..303bb8d 100644 --- a/src/app.rs +++ b/src/app.rs @@ -168,6 +168,9 @@ pub struct FloatingWindow { pub kitty_gfx_carry: Vec, /// Köade Kitty graphics-kommandon att vidarebefordra till värdterminalen pub pending_graphics: Vec, + /// Totalt antal rader i scrollback-historiken (uppdateras i tick, + /// används av scrollbaren). + pub scrollback_total: usize, } /// En Kitty graphics-sekvens extraherad från PTY-data, redo att vidarebefordras. @@ -572,6 +575,13 @@ impl App { } } } + // Uppdatera scrollback-totalen för scrollbaren. + // set_scrollback klampar till historikens faktiska längd, så + // "hoppa till toppen och läs av" ger antalet historikrader. + let cur = parser.screen().scrollback(); + parser.screen_mut().set_scrollback(usize::MAX); + window.scrollback_total = parser.screen().scrollback(); + parser.screen_mut().set_scrollback(cur); } // ── PTY resize om fönsterstorlek ändrats ───────────────────────────── @@ -588,7 +598,7 @@ impl App { 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); + *parser = vt100::Parser::new(new_rows, new_cols, self.config.scrollback_lines); // OBS: window.mouse_mode / window.mouse_encoding berörs inte — överlever restet. } } @@ -693,16 +703,10 @@ impl App { 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()); - } - } + // Kör kommandot direkt i fönstret (argument hanteras + // via sh -c i pty::build_command). Fönstret stängs + // när programmet avslutas. + self.spawn_terminal(&cmd); } } RunDialogResult::None => {} @@ -740,6 +744,8 @@ impl App { 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) { + // Tangenttryck → hoppa tillbaka till botten av historiken + parser.screen_mut().set_scrollback(0); let _ = pty.write_input(&bytes); } return; @@ -1213,12 +1219,22 @@ impl App { } } - // 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" }; + // Ingen mus-tracking i appen: + // - alternativskärm (htop/less m.fl. utan mus): piltangenter + // - primärskärm (vanligt skal): scrolla i vt100-historiken 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); + if let WindowContent::Terminal { pty, parser, .. } = &mut w.content { + if parser.screen().alternate_screen() { + crate::log::log(&format!(" scroll -> pilar (altskärm) up={} window={}", up, id)); + let bytes: &[u8] = if up { b"\x1b[A\x1b[A\x1b[A" } else { b"\x1b[B\x1b[B\x1b[B" }; + let _ = pty.write_input(bytes); + } else { + let cur = parser.screen().scrollback(); + let new_off = if up { cur.saturating_add(3) } else { cur.saturating_sub(3) }; + crate::log::log(&format!(" scroll -> historik {} -> {} window={}", cur, new_off, id)); + // set_scrollback klampar till historikens längd + parser.screen_mut().set_scrollback(new_off); + } } } } @@ -1300,6 +1316,7 @@ impl App { selection: None, kitty_gfx_carry: Vec::new(), pending_graphics: Vec::new(), + scrollback_total: 0, }); self.focus_window(id); } @@ -1340,6 +1357,7 @@ impl App { selection: None, kitty_gfx_carry: Vec::new(), pending_graphics: Vec::new(), + scrollback_total: 0, }); self.focus_window(id); } @@ -1445,8 +1463,9 @@ impl App { 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 [def_w, def_h] = self.config.default_window_size; + let w = def_w.max(8).min(ca.width.saturating_sub(4)); + let h = def_h.max(4).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); @@ -1462,7 +1481,7 @@ impl App { match PtyTerminal::spawn(shell, rows, cols, &env_vars) { Ok((pty, rx)) => { - let parser = vt100::Parser::new(rows, cols, 0); + let parser = vt100::Parser::new(rows, cols, self.config.scrollback_lines); self.next_id += 1; let title = shell.split('/').next_back().unwrap_or(shell).to_string(); self.windows.push(FloatingWindow { @@ -1481,6 +1500,7 @@ impl App { selection: None, kitty_gfx_carry: Vec::new(), pending_graphics: Vec::new(), + scrollback_total: 0, }); self.focus_window(id); } diff --git a/src/config.rs b/src/config.rs index 8f940e7..e80602f 100644 --- a/src/config.rs +++ b/src/config.rs @@ -13,12 +13,33 @@ pub struct Config { /// Standard-shell för nya terminaler. Om None → $SHELL eller /bin/bash. #[serde(default)] pub default_shell: Option, + /// Antal raders scrollback-historik per virtuell terminal. + #[serde(default = "default_scrollback_lines")] + pub scrollback_lines: usize, + /// Visa scrollbar på terminalfönstrens högerkant när historik finns. + #[serde(default = "default_true")] + pub show_scrollbar: bool, + /// Standardstorlek [bredd, höjd] för nya terminalfönster. + #[serde(default = "default_window_size")] + pub default_window_size: [u16; 2], #[serde(default, rename = "panel")] pub panels: Vec, #[serde(default, rename = "keybind")] pub keybinds: Vec, } +fn default_scrollback_lines() -> usize { + 1000 +} + +fn default_true() -> bool { + true +} + +fn default_window_size() -> [u16; 2] { + [82, 26] +} + /// En tangentbords-genväg definierad i config #[derive(Deserialize, Debug, Clone)] pub struct KeybindConfig { @@ -186,6 +207,9 @@ impl Config { background_image: None, terminal_bg_color: None, default_shell: None, + scrollback_lines: default_scrollback_lines(), + show_scrollbar: default_true(), + default_window_size: default_window_size(), panels: default_panels(), keybinds: default_keybinds(), } diff --git a/src/main.rs b/src/main.rs index 39701c3..f4fb1a2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -28,6 +28,7 @@ enum Mode { Standalone, Daemon, Client(String), + Help, } fn parse_args() -> Mode { @@ -35,6 +36,7 @@ fn parse_args() -> Mode { let mut i = 1; while i < args.len() { match args[i].as_str() { + "-h" | "--help" => return Mode::Help, "-d" | "--daemon" => return Mode::Daemon, "-c" | "--connect" => { let path = args @@ -56,9 +58,58 @@ fn main() -> io::Result<()> { Mode::Standalone => run_standalone(), Mode::Daemon => run_daemon_mode(), Mode::Client(path) => client::run(&path), + Mode::Help => { + print_help(); + Ok(()) + } } } +fn print_help() { + println!( + "\ +TUI-WM — fönsterhanterare i terminalen (tmux-liknande, med mus) + +ANVÄNDNING: + tui-wm [FLAGGA] + +FLAGGOR: + (ingen) Standalone: kör fönsterhanteraren i den här + terminalen och starta samtidigt socket-servern. + -d, --daemon Daemon: kör headless utan lokal rendering. + Anslut med `tui-wm -c`. Stoppas med `pkill tui-wm` + eller via systemd — INTE av att klienter kopplar ner. + -c, --connect [SOCKET] + Klient: anslut till en körande server (även över + SSH). Tangentbord, mus (klick/scroll/drag) och + resize vidarebefordras. Koppla ner med Ctrl+Shift+Q + — servern och dess program fortsätter köra. + -h, --help Visa den här hjälpen. + +KONFIGURATION (./config.toml + panels/*.toml): + default_shell = \"/bin/bash\" # skal för nya terminaler + default_window_size = [82, 26] # [bredd, höjd] för nya fönster + scrollback_lines = 1000 # historikrader per terminal + show_scrollbar = true # scrollbar på fönstrens högerkant + background_image = \"bild.png\" # bakgrund (Kitty graphics) + terminal_bg_color = \"#1e1e2e\" # terminalbakgrund + +TANGENTER (standard, ändras via [[keybind]] i config.toml): + ctrl+space Kommandodialog — skriv t.ex. `htop` och tryck Enter + alt+enter Ny terminal + ctrl+q Avsluta (från en ansluten klient: kopplar bara ner den) + +MUS: + Dra titelraden = flytta fönster Dra kant/hörn = ändra storlek + Scroll = historik (skal) eller vidarebefordras (appar med mus-stöd) + Klick/hover vidarebefordras till appar som vim, htop, mc ... + +SOCKET-API: + Program i fönstren får $TUI_WM_SOCKET och $TUI_WM_WINDOW_ID och kan + öppna fönster/popups via socketen — se SOCKET_API.md." + ); +} + fn run_standalone() -> io::Result<()> { log::init(); let socket_path = ipc::default_socket_path(); @@ -400,9 +451,11 @@ fn handle_ipc_event( } ClientMessage::KeyEvent { event } => { app.handle_event(crossterm::event::Event::Key(event)); + disconnect_instead_of_quit(app, all_clients, client_id); } ClientMessage::MouseEvent { event } => { app.handle_event(crossterm::event::Event::Mouse(event)); + disconnect_instead_of_quit(app, all_clients, client_id); } ClientMessage::Resize { width, height } => { if let Some(client) = all_clients.get_mut(&client_id) { @@ -476,6 +529,22 @@ fn handle_ipc_event( } } +/// Exit utlöst av en ansluten klient (t.ex. ctrl+q eller panelens +/// Avsluta-knapp) ska bara koppla ner DEN klienten — servern/daemonen +/// och alla program fortsätter köra. Lokal Exit i standalone-läget +/// går inte via IPC och stänger som vanligt hela WM:et. +fn disconnect_instead_of_quit( + app: &mut App, + all_clients: &mut HashMap)>, + client_id: usize, +) { + if app.should_quit { + app.should_quit = false; + // Sändaren droppas → klientens ström stängs → klienten avslutar. + all_clients.remove(&client_id); + } +} + fn send_app_ipc_out( out_ev: AppIpcOut, all_clients: &HashMap)>, diff --git a/src/render.rs b/src/render.rs index 6e26f8a..81d7756 100644 --- a/src/render.rs +++ b/src/render.rs @@ -32,7 +32,10 @@ pub fn render(frame: &mut Frame, app: &App) { .hovered_resize .filter(|(id, _)| *id == window.id) .map(|(_, e)| e); - render_window(frame, window, app.focused_id, app.hovered_window_close, resize_hover, terminal_bg); + render_window( + frame, window, app.focused_id, app.hovered_window_close, resize_hover, + terminal_bg, bg_color, app.config.show_scrollbar, + ); } // Paneler – alltid ovanpå fönster @@ -186,6 +189,7 @@ fn render_dropdown(frame: &mut Frame, dd: &DropdownState) { } } +#[allow(clippy::too_many_arguments)] fn render_window( frame: &mut Frame, window: &FloatingWindow, @@ -193,6 +197,8 @@ fn render_window( hovered_close: Option, resize_hover: Option, terminal_bg: Color, + outer_bg: Color, + show_scrollbar: bool, ) { let full_rect = window.rect().intersection(frame.area()); if full_rect.width < 4 || full_rect.height < 2 { @@ -211,16 +217,28 @@ fn render_window( }; frame.render_widget(Clear, full_rect); + // Ramzonen målas i skrivbordets färg — annars fylls även hörncellerna + // utanför de rundade hörnen med fönstrets bakgrund och rutan ser + // fyrkantig ut med en "inre" rundad ram. + frame.render_widget( + Block::default().style(Style::default().bg(outer_bg)), + full_rect, + ); + let content_bg = match &window.content { + WindowContent::Terminal { .. } => terminal_bg, + _ => Color::Black, + }; + let inner_rect = full_rect.inner(ratatui::layout::Margin { vertical: 1, horizontal: 1 }); + frame.render_widget( + Block::default().style(Style::default().bg(content_bg)), + inner_rect, + ); frame.render_widget( Block::default() .title(Span::styled(&title_text, theme::window_title())) .borders(Borders::ALL) .border_type(BorderType::Rounded) - .border_style(border_style) - .style(Style::default().bg(match &window.content { - WindowContent::Terminal { .. } => terminal_bg, - _ => Color::Black, - })), + .border_style(border_style), full_rect, ); @@ -267,6 +285,28 @@ fn render_window( render_popup_dialog(frame, window, focused_id); } } + + // Scrollbar på högerkanten när terminalen har scrollback-historik + if show_scrollbar && window.scrollback_total > 0 { + if let WindowContent::Terminal { parser, alive: true, .. } = &window.content { + use ratatui::widgets::{Scrollbar, ScrollbarOrientation, ScrollbarState}; + let total = window.scrollback_total; + let offset = parser.screen().scrollback().min(total); // 0 = botten + let mut state = ScrollbarState::new(total).position(total - offset); + let sb = Scrollbar::new(ScrollbarOrientation::VerticalRight) + .begin_symbol(None) + .end_symbol(None) + .track_symbol(Some("│")) + .thumb_symbol("█") + .track_style(border_style) + .thumb_style(Style::default().fg(theme::ACCENT)); + frame.render_stateful_widget( + sb, + full_rect.inner(ratatui::layout::Margin { vertical: 1, horizontal: 0 }), + &mut state, + ); + } + } } fn render_popup_dialog( @@ -276,9 +316,9 @@ fn render_popup_dialog( ) { let is_focused = focused_id == Some(window.id); let border_style = if is_focused { - Style::default().fg(Color::Cyan) + Style::default().fg(theme::ACCENT) } else { - Style::default().fg(Color::Indexed(240)) + Style::default().fg(theme::HINT) }; let WindowContent::PopupDialog { message, buttons, selected, .. } = &window.content else { @@ -290,9 +330,10 @@ fn render_popup_dialog( frame.render_widget( Block::default() .borders(Borders::ALL) + .border_type(BorderType::Rounded) .border_style(border_style) - .title(Span::styled(" TUI-WM ", Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD))) - .style(Style::default().bg(Color::Indexed(235))), + .title(Span::styled(" TUI-WM ", theme::logo())) + .style(Style::default().bg(theme::DESKTOP_BG)), rect, ); diff --git a/tests/integration.py b/tests/integration.py index c3e3b8c..483297e 100644 --- a/tests/integration.py +++ b/tests/integration.py @@ -59,10 +59,16 @@ class Conn: def recv(self): while len(self.buf) < 4: - self.buf += self.sock.recv(65536) + data = self.sock.recv(65536) + if not data: + raise EOFError("servern stängde anslutningen") + self.buf += data (length,) = struct.unpack(">I", self.buf[:4]) while len(self.buf) < 4 + length: - self.buf += self.sock.recv(65536) + data = self.sock.recv(65536) + if not data: + raise EOFError("servern stängde anslutningen") + self.buf += data payload = self.buf[4 : 4 + length] self.buf = self.buf[4 + length :] return json.loads(payload) @@ -135,6 +141,23 @@ def main(): frame = disp.recv_until("frame") check("frames strömmas till display", frame.get("width", 0) > 0) + # ── 1b. Exit från en klient kopplar bara ner klienten ─────────── + disp2 = Conn(sock_path) + disp2.send({"type": "hello", "role": "display", "width": 80, "height": 24}) + disp2.recv_until("hello_ok") + disp2.recv_until("frame") + disp2.send(key_event(ch="q", modifiers="CONTROL")) + try: + deadline = time.time() + 3 + while time.time() < deadline: + disp2.recv() + disconnected = False + except (EOFError, TimeoutError, OSError): + disconnected = True + check("ctrl+q från klient kopplar ner klienten", disconnected) + frame = disp.recv_until("frame") + check("daemonen överlever klientens exit", frame.get("width", 0) > 0) + # ── 2. spawn_window med mus-tracking-testapp ──────────────────── # printf slår på AnyMotion + SGR-mus-tracking, cat -v ekar allt # PTY:n tar emot — inklusive forwardade mus-sekvenser. @@ -188,6 +211,46 @@ def main(): f"frame-text: {text[-200:]!r}") app.send({"type": "close_window", "window_id": emoji_open["id"]}) + # ── 4c. Scrollback: mushjul utan mus-tracking scrollar historiken ─ + app.send({"type": "spawn_window", + "command": "seq 1 200; exec cat", + "request_id": "w-scroll"}) + scroll_open = app.recv_until("window_opened") + text = disp.frame_text(timeout=5.0, contains="200") + app.send({"type": "list_windows", "request_id": "ls"}) + wls = app.recv_until("window_list") + sw = next(w for w in wls["windows"] if w["id"] == scroll_open["id"]) + scx, scy = sw["x"] + 10, sw["y"] + 5 + visible_before = "163" in text + for _ in range(8): + disp.send(mouse_event("ScrollUp", scx, scy)) + text = disp.frame_text(timeout=5.0, contains="163") + check("mushjul scrollar terminalens historik", + "163" in text and not visible_before, + f"före: {visible_before}, frame-tail: {text[-150:]!r}") + # Tangenttryck hoppar tillbaka till botten av historiken + disp.send(key_event(ch="x")) + text = disp.frame_text(timeout=5.0, contains="200") + check("tangenttryck hoppar till historikens botten", "200" in text) + app.send({"type": "close_window", "window_id": scroll_open["id"]}) + + # ── 4d. Kommandodialogen kör kommandot direkt ──────────────────── + disp.send(key_event(ch=" ", modifiers="CONTROL")) # ctrl+space + time.sleep(0.3) + for ch in "top": + disp.send(key_event(ch=ch)) + disp.send(key_event(code="Enter")) + text = disp.frame_text(timeout=8.0, contains="PID") + check("kommandodialogen kör programmet (top)", "PID" in text, + f"frame-tail: {text[-200:]!r}") + app.send({"type": "list_windows", "request_id": "lt"}) + wlt = app.recv_until("window_list") + top_win = next((w for w in wlt["windows"] if w["title"].strip() == "top"), None) + check("fönstret har programmets namn som titel", top_win is not None, + f"fönster: {[w['title'] for w in wlt['windows']]}") + if top_win: + app.send({"type": "close_window", "window_id": top_win["id"]}) + # ── 5. Popup + popup_result via Enter ─────────────────────────── app.send({ "type": "spawn_popup", "message": "Integrationstest?",