Add --help, terminal scrollback + scrollbar, run-dialog fix, client-safe exit, true rounded corners

- -h/--help prints a full usage overview (modes, config keys, keys, mouse)
- Run dialog now executes the typed command directly in the new window
  (previously it spawned the default shell and typed the command into
  it, which showed up as just an empty terminal)
- Virtual terminals get vt100 scrollback (config scrollback_lines,
  default 1000): mouse wheel scrolls history when the app doesn't
  capture the mouse, arrow-key fallback on the alternate screen, any
  keypress jumps back to the bottom; optional scrollbar on the right
  border (show_scrollbar) plus configurable default_window_size
- Exit triggered from a connected display client (ctrl+q / panel
  button) now only disconnects that client — the daemon/standalone
  session and its programs keep running
- Windows now paint the border zone in the desktop background color so
  rounded corners actually look rounded instead of a filled square
  with an inner rounded frame; popup dialogs rounded too
- Integration suite extended to 19 checks (scrollback, run dialog,
  client-safe exit) — all passing

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-15 18:58:45 +02:00
parent 9cebeae716
commit db18c42481
7 changed files with 275 additions and 32 deletions

View File

@@ -168,6 +168,9 @@ pub struct FloatingWindow {
pub kitty_gfx_carry: Vec<u8>,
/// Köade Kitty graphics-kommandon att vidarebefordra till värdterminalen
pub pending_graphics: Vec<PendingGraphic>,
/// 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);
}

View File

@@ -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<String>,
/// 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<PanelConfig>,
#[serde(default, rename = "keybind")]
pub keybinds: Vec<KeybindConfig>,
}
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(),
}

View File

@@ -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<usize, (ClientRole, u16, u16, mpsc::SyncSender<ServerMessage>)>,
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<usize, (ClientRole, u16, u16, mpsc::SyncSender<ServerMessage>)>,

View File

@@ -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<usize>,
resize_hover: Option<ResizeEdge>,
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,
);