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"
|
label = "Maximera/återställ"
|
||||||
action = { type = "maximize" }
|
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
|
# Avsluta TUI-WM när skrivbordet är fokuserat
|
||||||
[[keybind]]
|
[[keybind]]
|
||||||
key = "ctrl+q"
|
key = "ctrl+q"
|
||||||
|
|||||||
199
src/app.rs
199
src/app.rs
@@ -109,6 +109,12 @@ pub struct App {
|
|||||||
pub switcher: Option<SwitcherState>,
|
pub switcher: Option<SwitcherState>,
|
||||||
/// Senaste titelradsklick (fönster-id, tidpunkt) för dubbelklick
|
/// Senaste titelradsklick (fönster-id, tidpunkt) för dubbelklick
|
||||||
last_title_click: Option<(usize, std::time::Instant)>,
|
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
|
// Layout – uppdateras varje frame
|
||||||
pub panel_rects: Vec<Rect>,
|
pub panel_rects: Vec<Rect>,
|
||||||
@@ -184,6 +190,16 @@ pub struct FloatingWindow {
|
|||||||
pub prev_geom: Option<(i32, i32, u16, u16)>,
|
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).
|
/// Tillstånd för fönsterväxlaren (Alt+Tab).
|
||||||
pub struct SwitcherState {
|
pub struct SwitcherState {
|
||||||
/// Fönster-id i MRU-ordning (senast använda först)
|
/// Fönster-id i MRU-ordning (senast använda först)
|
||||||
@@ -432,8 +448,8 @@ impl FloatingWindow {
|
|||||||
// ─── App impl ─────────────────────────────────────────────────────────────────
|
// ─── App impl ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
impl App {
|
impl App {
|
||||||
pub fn new(config: Config) -> Self {
|
fn build_status_states(config: &Config) -> Vec<Vec<StatusWidgetState>> {
|
||||||
let status_states: Vec<Vec<StatusWidgetState>> = config
|
config
|
||||||
.panels
|
.panels
|
||||||
.iter()
|
.iter()
|
||||||
.map(|p| {
|
.map(|p| {
|
||||||
@@ -442,9 +458,11 @@ impl App {
|
|||||||
.map(|_| StatusWidgetState { output: String::new(), last_run: None })
|
.map(|_| StatusWidgetState { output: String::new(), last_run: None })
|
||||||
.collect()
|
.collect()
|
||||||
})
|
})
|
||||||
.collect();
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
let parsed_keybinds: Vec<ParsedKeybind> = config
|
fn build_keybinds(config: &Config) -> Vec<ParsedKeybind> {
|
||||||
|
config
|
||||||
.keybinds
|
.keybinds
|
||||||
.iter()
|
.iter()
|
||||||
.filter_map(|kb| {
|
.filter_map(|kb| {
|
||||||
@@ -456,9 +474,48 @@ impl App {
|
|||||||
action: kb.action.clone(),
|
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 theme = Theme::load_or_default(config.theme.as_deref());
|
||||||
|
let watch_sig = Self::config_signature(&Self::watch_paths(&config));
|
||||||
|
|
||||||
App {
|
App {
|
||||||
should_quit: false,
|
should_quit: false,
|
||||||
@@ -483,6 +540,9 @@ impl App {
|
|||||||
focus_history: Vec::new(),
|
focus_history: Vec::new(),
|
||||||
switcher: None,
|
switcher: None,
|
||||||
last_title_click: None,
|
last_title_click: None,
|
||||||
|
copy_mode: None,
|
||||||
|
watch_sig,
|
||||||
|
last_watch_check: None,
|
||||||
hovered_tui_btn: false,
|
hovered_tui_btn: false,
|
||||||
socket_path: None,
|
socket_path: None,
|
||||||
ipc_out: Vec::new(),
|
ipc_out: Vec::new(),
|
||||||
@@ -557,6 +617,20 @@ impl App {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn tick(&mut self) {
|
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 ──────────────────────────────────────
|
// ── PTY-data → vt100-parser + mus-tracking-skanning ──────────────────────────────────────
|
||||||
for window in &mut self.windows {
|
for window in &mut self.windows {
|
||||||
let WindowContent::Terminal { rx, parser, pty, alive, title, .. } = &mut window.content else { continue };
|
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();
|
let alive_ids: Vec<usize> = self.windows.iter().map(|w| w.id).collect();
|
||||||
self.focus_history.retain(|h| alive_ids.contains(h));
|
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 let Some(fid) = prev_focused {
|
||||||
if !self.windows.iter().any(|w| w.id == fid) {
|
if !self.windows.iter().any(|w| w.id == fid) {
|
||||||
self.focused_id = self.windows.last().map(|w| w.id);
|
self.focused_id = self.windows.last().map(|w| w.id);
|
||||||
@@ -711,6 +790,11 @@ impl App {
|
|||||||
self.handle_switcher_key(key);
|
self.handle_switcher_key(key);
|
||||||
return;
|
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
|
// 0. Ctrl+Shift+C → kopiera markerad text
|
||||||
if key.modifiers.contains(KeyModifiers::CONTROL | KeyModifiers::SHIFT)
|
if key.modifiers.contains(KeyModifiers::CONTROL | KeyModifiers::SHIFT)
|
||||||
&& key.code == KeyCode::Char('C')
|
&& key.code == KeyCode::Char('C')
|
||||||
@@ -1344,6 +1428,7 @@ impl App {
|
|||||||
MenuAction::SpawnRunDialog => self.spawn_run_dialog(),
|
MenuAction::SpawnRunDialog => self.spawn_run_dialog(),
|
||||||
MenuAction::Submenu { .. } => {}
|
MenuAction::Submenu { .. } => {}
|
||||||
MenuAction::WindowSwitcher => self.switcher_open_or_advance(),
|
MenuAction::WindowSwitcher => self.switcher_open_or_advance(),
|
||||||
|
MenuAction::CopyMode => self.enter_copy_mode(),
|
||||||
MenuAction::SnapLeft => self.snap_focused(true),
|
MenuAction::SnapLeft => self.snap_focused(true),
|
||||||
MenuAction::SnapRight => self.snap_focused(false),
|
MenuAction::SnapRight => self.snap_focused(false),
|
||||||
MenuAction::Maximize => self.toggle_maximize_focused(),
|
MenuAction::Maximize => self.toggle_maximize_focused(),
|
||||||
@@ -1572,6 +1657,109 @@ impl App {
|
|||||||
id
|
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.
|
/// Öppna fönsterväxlaren, eller stega framåt om den redan är öppen.
|
||||||
fn switcher_open_or_advance(&mut self) {
|
fn switcher_open_or_advance(&mut self) {
|
||||||
if self.windows.is_empty() {
|
if self.windows.is_empty() {
|
||||||
@@ -2043,6 +2231,7 @@ fn action_display(action: &MenuAction) -> &'static str {
|
|||||||
MenuAction::Submenu { .. } => "Undermeny",
|
MenuAction::Submenu { .. } => "Undermeny",
|
||||||
MenuAction::SpawnRunDialog => "Kommandodialog",
|
MenuAction::SpawnRunDialog => "Kommandodialog",
|
||||||
MenuAction::WindowSwitcher => "Fönsterväxlare",
|
MenuAction::WindowSwitcher => "Fönsterväxlare",
|
||||||
|
MenuAction::CopyMode => "Copy-mode",
|
||||||
MenuAction::SnapLeft => "Fäst vänster",
|
MenuAction::SnapLeft => "Fäst vänster",
|
||||||
MenuAction::SnapRight => "Fäst höger",
|
MenuAction::SnapRight => "Fäst höger",
|
||||||
MenuAction::Maximize => "Maximera/återställ",
|
MenuAction::Maximize => "Maximera/återställ",
|
||||||
|
|||||||
@@ -123,6 +123,8 @@ pub enum MenuAction {
|
|||||||
SnapRight,
|
SnapRight,
|
||||||
/// Maximera/återställ fokuserat fönster
|
/// Maximera/återställ fokuserat fönster
|
||||||
Maximize,
|
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
|
/// Gör ingenting – används för display-only rader i menyer
|
||||||
NoOp,
|
NoOp,
|
||||||
}
|
}
|
||||||
@@ -198,6 +200,12 @@ fn default_keybinds() -> Vec<KeybindConfig> {
|
|||||||
action: MenuAction::Maximize,
|
action: MenuAction::Maximize,
|
||||||
label: Some("Maximera/återställ".to_string()),
|
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 {
|
KeybindConfig {
|
||||||
key: "ctrl+q".to_string(),
|
key: "ctrl+q".to_string(),
|
||||||
scope: KeybindScope::Wm,
|
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
|
// Scrollbar på högerkanten när terminalen har scrollback-historik
|
||||||
if show_scrollbar && window.scrollback_total > 0 {
|
if show_scrollbar && window.scrollback_total > 0 {
|
||||||
if let WindowContent::Terminal { parser, alive: true, .. } = &window.content {
|
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(
|
fn render_popup_dialog(
|
||||||
frame: &mut Frame,
|
frame: &mut Frame,
|
||||||
window: &FloatingWindow,
|
window: &FloatingWindow,
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ Miljö: TUI_WM_BIN (default target/debug/tui-wm)
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import shutil
|
||||||
import re
|
import re
|
||||||
import socket
|
import socket
|
||||||
import struct
|
import struct
|
||||||
@@ -116,8 +117,20 @@ def main():
|
|||||||
env = dict(os.environ, XDG_RUNTIME_DIR=tmp)
|
env = dict(os.environ, XDG_RUNTIME_DIR=tmp)
|
||||||
sock_path = os.path.join(tmp, "tui-wm.sock")
|
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(
|
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,
|
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
@@ -306,6 +319,35 @@ def main():
|
|||||||
f"geometri: {maxed}")
|
f"geometri: {maxed}")
|
||||||
app.send({"type": "close_window", "window_id": sw_open["id"]})
|
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 ───────────────────────────
|
# ── 5. Popup + popup_result via Enter ───────────────────────────
|
||||||
app.send({
|
app.send({
|
||||||
"type": "spawn_popup", "message": "Integrationstest?",
|
"type": "spawn_popup", "message": "Integrationstest?",
|
||||||
|
|||||||
Reference in New Issue
Block a user