From b1424da4fb4f6c1b129f85b6d9a63d95067c06b5 Mon Sep 17 00:00:00 2001 From: Bjorn Blomberg Date: Wed, 15 Jul 2026 19:43:57 +0200 Subject: [PATCH] Copy-mode with scrollback search + config hot-reload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - alt+c enters copy-mode on the focused terminal: arrows/PgUp/PgDn/ Home/End scroll the history, / searches (matches highlighted in the view), n/N jump between hits, q/Esc exits back to the bottom. Status line shows position and key help - config.toml, panel files and the theme file are watched (1s mtime poll): edits reload theme/panels/keybinds live without restarting — windows and focus are untouched - Integration suite runs the daemon in an isolated config dir and covers copy-mode search and panel hot-reload — 27/27 passing Co-Authored-By: Claude Fable 5 --- config.toml | 8 ++ src/app.rs | 199 +++++++++++++++++++++++++++++++++++++++++-- src/config.rs | 8 ++ src/render.rs | 70 +++++++++++++++ tests/integration.py | 44 +++++++++- 5 files changed, 323 insertions(+), 6 deletions(-) diff --git a/config.toml b/config.toml index 1a3ae8e..f79229b 100644 --- a/config.toml +++ b/config.toml @@ -77,6 +77,14 @@ scope = "global" label = "Maximera/återställ" action = { type = "maximize" } +# Copy-mode: bläddra/söka i fokuserat fönsters historik med tangentbordet +# (↑↓/PgUp/PgDn bläddrar, / söker, n/N hoppar mellan träffar, q stänger) +[[keybind]] +key = "alt+c" +scope = "global" +label = "Copy-mode (sök i historik)" +action = { type = "copy_mode" } + # Avsluta TUI-WM när skrivbordet är fokuserat [[keybind]] key = "ctrl+q" diff --git a/src/app.rs b/src/app.rs index fab5fe3..9ec5029 100644 --- a/src/app.rs +++ b/src/app.rs @@ -109,6 +109,12 @@ pub struct App { pub switcher: Option, /// Senaste titelradsklick (fönster-id, tidpunkt) för dubbelklick last_title_click: Option<(usize, std::time::Instant)>, + /// Copy-mode: bläddra/söka i fokuserat fönsters historik med tangentbordet + pub copy_mode: Option, + /// mtime-signatur för bevakade config-filer (hot-reload) + watch_sig: Vec>, + /// Senaste hot-reload-kollen + last_watch_check: Option, // Layout – uppdateras varje frame pub panel_rects: Vec, @@ -184,6 +190,16 @@ pub struct FloatingWindow { pub prev_geom: Option<(i32, i32, u16, u16)>, } +/// Copy-mode: tangentbordsstyrd bläddring och sökning i scrollback. +pub struct CopyMode { + /// Fönstret som copy-mode gäller + pub window_id: usize, + /// Skriver i sökfältet just nu + pub editing: bool, + /// Aktuell sökfråga + pub query: String, +} + /// Tillstånd för fönsterväxlaren (Alt+Tab). pub struct SwitcherState { /// Fönster-id i MRU-ordning (senast använda först) @@ -432,8 +448,8 @@ impl FloatingWindow { // ─── App impl ───────────────────────────────────────────────────────────────── impl App { - pub fn new(config: Config) -> Self { - let status_states: Vec> = config + fn build_status_states(config: &Config) -> Vec> { + config .panels .iter() .map(|p| { @@ -442,9 +458,11 @@ impl App { .map(|_| StatusWidgetState { output: String::new(), last_run: None }) .collect() }) - .collect(); + .collect() + } - let parsed_keybinds: Vec = config + fn build_keybinds(config: &Config) -> Vec { + config .keybinds .iter() .filter_map(|kb| { @@ -456,9 +474,48 @@ impl App { action: kb.action.clone(), }) }) - .collect(); + .collect() + } + + /// Filerna som hot-reload bevakar: config.toml + panel- och temafiler. + fn watch_paths(config: &Config) -> Vec { + let mut paths = vec!["config.toml".to_string()]; + for p in &config.panels { + if let Some(f) = &p.file { + paths.push(f.clone()); + } + } + if let Some(t) = &config.theme { + paths.push(t.clone()); + } + paths + } + + fn config_signature(paths: &[String]) -> Vec> { + paths + .iter() + .map(|p| std::fs::metadata(p).and_then(|m| m.modified()).ok()) + .collect() + } + + /// Ladda om config.toml, tema, paneler och keybinds utan omstart. + /// Fönster och fokus behålls. + pub fn reload_config(&mut self) { + let config = Config::load_or_default("config.toml"); + self.theme = Theme::load_or_default(config.theme.as_deref()); + self.parsed_keybinds = Self::build_keybinds(&config); + self.status_states = Self::build_status_states(&config); + self.watch_sig = Self::config_signature(&Self::watch_paths(&config)); + self.config = config; + crate::log::log("config hot-reload: laddade om config/tema/paneler"); + } + + pub fn new(config: Config) -> Self { + let status_states = Self::build_status_states(&config); + let parsed_keybinds = Self::build_keybinds(&config); let theme = Theme::load_or_default(config.theme.as_deref()); + let watch_sig = Self::config_signature(&Self::watch_paths(&config)); App { should_quit: false, @@ -483,6 +540,9 @@ impl App { focus_history: Vec::new(), switcher: None, last_title_click: None, + copy_mode: None, + watch_sig, + last_watch_check: None, hovered_tui_btn: false, socket_path: None, ipc_out: Vec::new(), @@ -557,6 +617,20 @@ impl App { } pub fn tick(&mut self) { + // ── Hot-reload: ladda om config/tema/paneler om filerna ändrats ── + let now = std::time::Instant::now(); + let due = self + .last_watch_check + .map(|t| now.duration_since(t).as_millis() >= 1000) + .unwrap_or(true); + if due { + self.last_watch_check = Some(now); + let sig = Self::config_signature(&Self::watch_paths(&self.config)); + if sig != self.watch_sig { + self.reload_config(); + } + } + // ── 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 }; @@ -638,6 +712,11 @@ impl App { }); let alive_ids: Vec = self.windows.iter().map(|w| w.id).collect(); self.focus_history.retain(|h| alive_ids.contains(h)); + if let Some(cm) = &self.copy_mode { + if !alive_ids.contains(&cm.window_id) { + self.copy_mode = None; + } + } 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); @@ -711,6 +790,11 @@ impl App { self.handle_switcher_key(key); return; } + // 0b. Copy-mode fångar alla tangenter medan det är aktivt + if self.copy_mode.is_some() { + self.handle_copy_mode_key(key); + return; + } // 0. Ctrl+Shift+C → kopiera markerad text if key.modifiers.contains(KeyModifiers::CONTROL | KeyModifiers::SHIFT) && key.code == KeyCode::Char('C') @@ -1344,6 +1428,7 @@ impl App { MenuAction::SpawnRunDialog => self.spawn_run_dialog(), MenuAction::Submenu { .. } => {} MenuAction::WindowSwitcher => self.switcher_open_or_advance(), + MenuAction::CopyMode => self.enter_copy_mode(), MenuAction::SnapLeft => self.snap_focused(true), MenuAction::SnapRight => self.snap_focused(false), MenuAction::Maximize => self.toggle_maximize_focused(), @@ -1572,6 +1657,109 @@ impl App { id } + /// Gå in i copy-mode för det fokuserade terminalfönstret. + fn enter_copy_mode(&mut self) { + let Some(id) = self.focused_id else { return }; + let is_term = self + .windows + .iter() + .any(|w| w.id == id && matches!(w.content, WindowContent::Terminal { .. })); + if is_term { + self.copy_mode = Some(CopyMode { window_id: id, editing: false, query: String::new() }); + } + } + + fn handle_copy_mode_key(&mut self, key: KeyEvent) { + let Some(cm) = &mut self.copy_mode else { return }; + let id = cm.window_id; + + // Sökfältet är aktivt: redigera frågan + if cm.editing { + match key.code { + KeyCode::Enter => { + cm.editing = false; + self.copy_mode_search(true); + } + KeyCode::Esc => { + cm.editing = false; + cm.query.clear(); + } + KeyCode::Backspace => { + cm.query.pop(); + } + KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => { + cm.query.push(c); + } + _ => {} + } + return; + } + + let scroll_by = |windows: &mut Vec, delta: i64| { + if let Some(w) = windows.iter_mut().find(|w| w.id == id) { + if let WindowContent::Terminal { parser, .. } = &mut w.content { + let cur = parser.screen().scrollback() as i64; + let new = (cur + delta).max(0) as usize; + parser.screen_mut().set_scrollback(new); + } + } + }; + + match key.code { + KeyCode::Up | KeyCode::Char('k') => scroll_by(&mut self.windows, 1), + KeyCode::Down | KeyCode::Char('j') => scroll_by(&mut self.windows, -1), + KeyCode::PageUp => scroll_by(&mut self.windows, 20), + KeyCode::PageDown => scroll_by(&mut self.windows, -20), + KeyCode::Home | KeyCode::Char('g') => scroll_by(&mut self.windows, i64::MAX / 2), + KeyCode::End | KeyCode::Char('G') => scroll_by(&mut self.windows, i64::MIN / 2), + KeyCode::Char('/') => { + cm.query.clear(); + cm.editing = true; + } + KeyCode::Char('n') => self.copy_mode_search(true), + KeyCode::Char('N') => self.copy_mode_search(false), + KeyCode::Esc | KeyCode::Char('q') => { + // Lämna copy-mode och hoppa till botten + if let Some(w) = self.windows.iter_mut().find(|w| w.id == id) { + if let WindowContent::Terminal { parser, .. } = &mut w.content { + parser.screen_mut().set_scrollback(0); + } + } + self.copy_mode = None; + } + _ => {} + } + } + + /// Sök i historiken: `up=true` söker uppåt (äldre), annars nedåt. + /// Skärmen ställs på första offset där frågan syns i vyn. + fn copy_mode_search(&mut self, up: bool) { + let Some(cm) = &self.copy_mode else { return }; + if cm.query.is_empty() { + return; + } + let query = cm.query.clone(); + let id = cm.window_id; + let Some(w) = self.windows.iter_mut().find(|w| w.id == id) else { return }; + let WindowContent::Terminal { parser, .. } = &mut w.content else { return }; + + let cur = parser.screen().scrollback(); + let max = w.scrollback_total; + let offsets: Vec = if up { + ((cur + 1)..=max).collect() + } else { + (0..cur).rev().collect() + }; + for off in offsets { + parser.screen_mut().set_scrollback(off); + if parser.screen().contents().contains(&query) { + return; // stanna på träffen + } + } + // Ingen träff: återställ positionen + parser.screen_mut().set_scrollback(cur); + } + /// Öppna fönsterväxlaren, eller stega framåt om den redan är öppen. fn switcher_open_or_advance(&mut self) { if self.windows.is_empty() { @@ -2043,6 +2231,7 @@ fn action_display(action: &MenuAction) -> &'static str { MenuAction::Submenu { .. } => "Undermeny", MenuAction::SpawnRunDialog => "Kommandodialog", MenuAction::WindowSwitcher => "Fönsterväxlare", + MenuAction::CopyMode => "Copy-mode", MenuAction::SnapLeft => "Fäst vänster", MenuAction::SnapRight => "Fäst höger", MenuAction::Maximize => "Maximera/återställ", diff --git a/src/config.rs b/src/config.rs index 85b7c6d..d3da525 100644 --- a/src/config.rs +++ b/src/config.rs @@ -123,6 +123,8 @@ pub enum MenuAction { SnapRight, /// Maximera/återställ fokuserat fönster Maximize, + /// Copy-mode: bläddra/söka i fokuserat fönsters historik + CopyMode, /// Gör ingenting – används för display-only rader i menyer NoOp, } @@ -198,6 +200,12 @@ fn default_keybinds() -> Vec { action: MenuAction::Maximize, label: Some("Maximera/återställ".to_string()), }, + KeybindConfig { + key: "alt+c".to_string(), + scope: KeybindScope::Global, + action: MenuAction::CopyMode, + label: Some("Copy-mode (sök i historik)".to_string()), + }, KeybindConfig { key: "ctrl+q".to_string(), scope: KeybindScope::Wm, diff --git a/src/render.rs b/src/render.rs index 4223c57..85091c6 100644 --- a/src/render.rs +++ b/src/render.rs @@ -388,6 +388,32 @@ fn render_window(frame: &mut Frame, window: &FloatingWindow, app: &App) { } } + // Copy-mode: statusrad + markering av sökträffar + if let Some(cm) = &app.copy_mode { + if cm.window_id == window.id && cr.height > 0 { + if !cm.query.is_empty() { + highlight_matches(frame, cr, &cm.query, th.hover_highlight()); + } + let offset = match &window.content { + WindowContent::Terminal { parser, .. } => parser.screen().scrollback(), + _ => 0, + }; + let status = if cm.editing { + format!(" KOPIERA /{}▏ Enter=sök Esc=avbryt ", cm.query) + } else { + format!( + " KOPIERA rad {}/{} ↑↓/PgUp/PgDn bläddra /=sök n/N träff q=stäng ", + offset, window.scrollback_total + ) + }; + let row = Rect::new(cr.x, cr.y + cr.height - 1, cr.width, 1); + frame.render_widget( + Paragraph::new(Span::styled(status, th.hover_highlight())), + row, + ); + } + } + // 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 { @@ -436,6 +462,50 @@ fn render_shadow(frame: &mut Frame, rect: Rect, shadow_bg: Color) { } } +/// Markera alla förekomster av `query` i rektangeln genom att restyla +/// cellerna direkt i buffern (fungerar oavsett hur raden är spansad). +fn highlight_matches(frame: &mut Frame, rect: Rect, query: &str, style: Style) { + if query.is_empty() { + return; + } + let area = frame.area(); + let buf = frame.buffer_mut(); + for y in rect.y..(rect.y + rect.height).min(area.height) { + // Bygg radens text + kolumnpositioner + let mut text = String::new(); + let mut cols: Vec = Vec::new(); + for x in rect.x..(rect.x + rect.width).min(area.width) { + if let Some(cell) = buf.cell((x, y)) { + let sym = cell.symbol(); + if sym.is_empty() { + continue; + } + for _ in 0..sym.chars().count() { + cols.push(x); + } + text.push_str(sym); + } + } + // Hitta träffar och restyla cellerna + let chars: Vec = text.chars().collect(); + let qchars: Vec = query.chars().collect(); + if qchars.is_empty() || chars.len() < qchars.len() { + continue; + } + for start in 0..=(chars.len() - qchars.len()) { + if chars[start..start + qchars.len()] == qchars[..] { + for i in start..start + qchars.len() { + if let Some(&cx) = cols.get(i) { + if let Some(cell) = buf.cell_mut((cx, y)) { + cell.set_style(style); + } + } + } + } + } + } +} + fn render_popup_dialog( frame: &mut Frame, window: &FloatingWindow, diff --git a/tests/integration.py b/tests/integration.py index 2775c6d..8640d80 100644 --- a/tests/integration.py +++ b/tests/integration.py @@ -21,6 +21,7 @@ Miljö: TUI_WM_BIN (default target/debug/tui-wm) import json import os +import shutil import re import socket import struct @@ -116,8 +117,20 @@ def main(): env = dict(os.environ, XDG_RUNTIME_DIR=tmp) sock_path = os.path.join(tmp, "tui-wm.sock") + # Egen konfig-katalog så testerna (t.ex. hot-reload) kan skriva i + # konfigfiler utan att röra repot. + cfgdir = os.path.join(tmp, "cfg") + os.makedirs(cfgdir) + shutil.copy(os.path.join(REPO, "config.toml"), cfgdir) + shutil.copytree(os.path.join(REPO, "panels"), os.path.join(cfgdir, "panels")) + shutil.copytree(os.path.join(REPO, "themes"), os.path.join(cfgdir, "themes")) + # bakgrundsbilden finns inte i test-miljön — ta bort raden + cfg = open(os.path.join(cfgdir, "config.toml")).read() + cfg = "\n".join(l for l in cfg.splitlines() if not l.startswith("background_image")) + open(os.path.join(cfgdir, "config.toml"), "w").write(cfg) + daemon = subprocess.Popen( - [TUI_WM_BIN, "-d"], cwd=REPO, env=env, + [TUI_WM_BIN, "-d"], cwd=cfgdir, env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, ) try: @@ -306,6 +319,35 @@ def main(): f"geometri: {maxed}") app.send({"type": "close_window", "window_id": sw_open["id"]}) + # ── 4f. Copy-mode: alt+c, sök, avsluta ─────────────────────────── + app.send({"type": "spawn_window", "command": "seq 1 200; exec cat", + "request_id": "w-copy"}) + cm_open = app.recv_until("window_opened") + disp.frame_text(timeout=5.0, contains="200") + disp.send(key_event(ch="c", modifiers="ALT")) + text = disp.frame_text(timeout=5.0, contains="KOPIERA") + check("alt+c öppnar copy-mode", "KOPIERA" in text) + # sök efter rad 42 + disp.send(key_event(ch="/")) + for ch in "42\n": + if ch == "\n": + disp.send(key_event(code="Enter")) + else: + disp.send(key_event(ch=ch)) + text = disp.frame_text(timeout=5.0, contains="42") + check("copy-mode-sökning hittar träffen", "42" in text) + disp.send(key_event(ch="q")) + text = disp.frame_text(timeout=5.0, contains="200") + check("q lämnar copy-mode och hoppar till botten", "200" in text) + app.send({"type": "close_window", "window_id": cm_open["id"]}) + + # ── 4g. Hot-reload: ändra panelfilen → panelen uppdateras ──────── + with open(os.path.join(cfgdir, "panels", "topbar.toml"), "a") as f: + f.write('\n[[item]]\nlabel = "HOTRELOAD"\naction = { type = "no_op" }\n') + text = disp.frame_text(timeout=6.0, contains="HOTRELOAD") + check("hot-reload plockar upp panel-ändring", "HOTRELOAD" in text, + f"frame-tail: {text[:150]!r}") + # ── 5. Popup + popup_result via Enter ─────────────────────────── app.send({ "type": "spawn_popup", "message": "Integrationstest?",