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:
@@ -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"
|
||||
|
||||
199
src/app.rs
199
src/app.rs
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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?",
|
||||
|
||||
Reference in New Issue
Block a user