Copy-mode with scrollback search + config hot-reload

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-07-15 19:43:57 +02:00
parent b85345be99
commit b1424da4fb
5 changed files with 323 additions and 6 deletions

View File

@@ -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"

View File

@@ -109,6 +109,12 @@ pub struct App {
pub switcher: Option<SwitcherState>,
/// 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<CopyMode>,
/// mtime-signatur för bevakade config-filer (hot-reload)
watch_sig: Vec<Option<std::time::SystemTime>>,
/// Senaste hot-reload-kollen
last_watch_check: Option<std::time::Instant>,
// Layout uppdateras varje frame
pub panel_rects: Vec<Rect>,
@@ -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<Vec<StatusWidgetState>> = config
fn build_status_states(config: &Config) -> Vec<Vec<StatusWidgetState>> {
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<ParsedKeybind> = config
fn build_keybinds(config: &Config) -> Vec<ParsedKeybind> {
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<String> {
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<Option<std::time::SystemTime>> {
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<usize> = 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<FloatingWindow>, 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<usize> = 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",

View File

@@ -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<KeybindConfig> {
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,

View File

@@ -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<u16> = 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<char> = text.chars().collect();
let qchars: Vec<char> = 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,

View File

@@ -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?",