From b85345be99d2720922293773795221a69e58c086 Mon Sep 17 00:00:00 2001 From: Bjorn Blomberg Date: Wed, 15 Jul 2026 19:40:28 +0200 Subject: [PATCH] Alt+Tab window switcher (MRU), snap/maximize, panel separators MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - window_switcher action: overlay listing windows in most-recently-used order; Tab/arrows cycle, Enter confirms, Esc cancels. Bound to alt+tab with alt+w as fallback for terminals that swallow alt+tab - snap_left/snap_right actions (alt+left/alt+right) tile the focused window to half the content area; dragging a window to the screen edge snaps it too - maximize action (alt+up) toggles maximize/restore, double-click on the title bar does the same; restore returns the pre-snap geometry - Panel: separator between focus dot and buttons, separators between right-docked status widgets, and item widths now use display width (fixes rects for åäö/wide labels) - Integration suite: 23 checks, all passing Co-Authored-By: Claude Fable 5 --- config.toml | 33 +++++++++ src/app.rs | 170 ++++++++++++++++++++++++++++++++++++++++++- src/config.rs | 38 ++++++++++ src/render.rs | 84 +++++++++++++++++++++ tests/integration.py | 32 ++++++++ 5 files changed, 356 insertions(+), 1 deletion(-) diff --git a/config.toml b/config.toml index 164efc1..1a3ae8e 100644 --- a/config.toml +++ b/config.toml @@ -44,6 +44,39 @@ scope = "global" label = "Ny terminal" action = { type = "spawn_terminal" } +# Fönsterväxlare (Alt+Tab-overlay i MRU-ordning). +# OBS: vissa terminaler/skrivbord fångar alt+tab själva — alt+w är reserv. +[[keybind]] +key = "alt+tab" +scope = "global" +label = "Fönsterväxlare" +action = { type = "window_switcher" } + +[[keybind]] +key = "alt+w" +scope = "global" +action = { type = "window_switcher" } + +# Fäst fokuserat fönster som vänster/höger halvskärm +[[keybind]] +key = "alt+left" +scope = "global" +label = "Fäst vänster" +action = { type = "snap_left" } + +[[keybind]] +key = "alt+right" +scope = "global" +label = "Fäst höger" +action = { type = "snap_right" } + +# Maximera/återställ fokuserat fönster (även dubbelklick på titelraden) +[[keybind]] +key = "alt+up" +scope = "global" +label = "Maximera/återställ" +action = { type = "maximize" } + # Avsluta TUI-WM när skrivbordet är fokuserat [[keybind]] key = "ctrl+q" diff --git a/src/app.rs b/src/app.rs index 7861d4b..fab5fe3 100644 --- a/src/app.rs +++ b/src/app.rs @@ -103,6 +103,12 @@ pub struct App { pub config: Config, /// Aktivt tema (laddas från config.theme, hot-reloadas med config) pub theme: Theme, + /// Fokushistorik, senast använda fönstret först (för Alt+Tab) + pub focus_history: Vec, + /// Öppen fönsterväxlare (Alt+Tab-overlay) + pub switcher: Option, + /// Senaste titelradsklick (fönster-id, tidpunkt) för dubbelklick + last_title_click: Option<(usize, std::time::Instant)>, // Layout – uppdateras varje frame pub panel_rects: Vec, @@ -174,6 +180,16 @@ pub struct FloatingWindow { /// Totalt antal rader i scrollback-historiken (uppdateras i tick, /// används av scrollbaren). pub scrollback_total: usize, + /// Geometri före snap/maximering — används för återställning. + pub prev_geom: Option<(i32, i32, u16, u16)>, +} + +/// Tillstånd för fönsterväxlaren (Alt+Tab). +pub struct SwitcherState { + /// Fönster-id i MRU-ordning (senast använda först) + pub order: Vec, + /// Markerat index i `order` + pub selected: usize, } /// En Kitty graphics-sekvens extraherad från PTY-data, redo att vidarebefordras. @@ -464,6 +480,9 @@ impl App { parsed_keybinds, tui_wm_btn_rects: Vec::new(), theme, + focus_history: Vec::new(), + switcher: None, + last_title_click: None, hovered_tui_btn: false, socket_path: None, ipc_out: Vec::new(), @@ -495,7 +514,7 @@ impl App { // Items börjar efter logo (9) + fokus-dot (1) + mellanrum (1) = x+12 let mut x = rect.x + 12; for item in &panel.items { - let w = item.label.len() as u16 + 2; + let w = unicode_width::UnicodeWidthStr::width(item.label.as_str()) as u16 + 2; item_rects.push(Rect::new(x, rect.y, w, 1)); x += w + 1; } @@ -617,6 +636,8 @@ impl App { WindowContent::RunDialog { .. } => true, WindowContent::PopupDialog { .. } => true, }); + let alive_ids: Vec = self.windows.iter().map(|w| w.id).collect(); + self.focus_history.retain(|h| alive_ids.contains(h)); 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); @@ -685,6 +706,11 @@ impl App { } fn handle_key(&mut self, key: KeyEvent) { + // 0a. Fönsterväxlaren fångar alla tangenter medan den är öppen + if self.switcher.is_some() { + self.handle_switcher_key(key); + return; + } // 0. Ctrl+Shift+C → kopiera markerad text if key.modifiers.contains(KeyModifiers::CONTROL | KeyModifiers::SHIFT) && key.code == KeyCode::Char('C') @@ -968,10 +994,22 @@ impl App { } } MouseEventKind::Up(btn) => { + // Släpptes ett fönster-drag vid skärmkanten? → snap till halva + let was_dragging = self.windows.iter().find(|w| w.dragging.is_some()).map(|w| w.id); for w in &mut self.windows { w.dragging = None; w.resizing = None; } + if let Some(id) = was_dragging { + let ca = self.content_area; + if col <= ca.x { + self.focused_id = Some(id); + self.snap_focused(true); + } else if col >= ca.x + ca.width.saturating_sub(1) { + self.focused_id = Some(id); + self.snap_focused(false); + } + } self.forward_mouse_to_focused_terminal(MouseEventKind::Up(btn), col, row); } MouseEventKind::Down(MouseButton::Left) => self.handle_click(col, row), @@ -1136,6 +1174,17 @@ impl App { .map(|w| (w.id, col as i32 - w.x, row as i32 - w.y)); if let Some((id, ox, oy)) = title_hit { self.focus_window(id); + // Dubbelklick på titelraden → maximera/återställ + let now = std::time::Instant::now(); + let is_double = self + .last_title_click + .map(|(lid, t)| lid == id && now.duration_since(t).as_millis() < 400) + .unwrap_or(false); + self.last_title_click = Some((id, now)); + if is_double { + self.toggle_maximize_focused(); + return; + } if let Some(w) = self.windows.iter_mut().find(|w| w.id == id) { w.dragging = Some((ox, oy)); } @@ -1294,6 +1343,10 @@ impl App { } MenuAction::SpawnRunDialog => self.spawn_run_dialog(), MenuAction::Submenu { .. } => {} + MenuAction::WindowSwitcher => self.switcher_open_or_advance(), + MenuAction::SnapLeft => self.snap_focused(true), + MenuAction::SnapRight => self.snap_focused(false), + MenuAction::Maximize => self.toggle_maximize_focused(), MenuAction::NoOp => {} } } @@ -1324,6 +1377,7 @@ impl App { kitty_gfx_carry: Vec::new(), pending_graphics: Vec::new(), scrollback_total: 0, + prev_geom: None, }); self.focus_window(id); } @@ -1365,6 +1419,7 @@ impl App { kitty_gfx_carry: Vec::new(), pending_graphics: Vec::new(), scrollback_total: 0, + prev_geom: None, }); self.focus_window(id); } @@ -1508,6 +1563,7 @@ impl App { kitty_gfx_carry: Vec::new(), pending_graphics: Vec::new(), scrollback_total: 0, + prev_geom: None, }); self.focus_window(id); } @@ -1516,6 +1572,111 @@ impl App { id } + /// Ö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() { + return; + } + match &mut self.switcher { + Some(sw) => sw.selected = (sw.selected + 1) % sw.order.len(), + None => { + // MRU-ordning: historiken först, sedan övriga fönster + let mut order: Vec = self + .focus_history + .iter() + .filter(|id| self.windows.iter().any(|w| w.id == **id)) + .copied() + .collect(); + for w in self.windows.iter().rev() { + if !order.contains(&w.id) { + order.push(w.id); + } + } + let selected = if order.len() > 1 { 1 } else { 0 }; + self.switcher = Some(SwitcherState { order, selected }); + } + } + } + + fn handle_switcher_key(&mut self, key: KeyEvent) { + let Some(sw) = &mut self.switcher else { return }; + let len = sw.order.len().max(1); + match key.code { + KeyCode::Tab | KeyCode::Down | KeyCode::Right => { + sw.selected = (sw.selected + 1) % len; + } + KeyCode::BackTab | KeyCode::Up | KeyCode::Left => { + sw.selected = (sw.selected + len - 1) % len; + } + KeyCode::Enter | KeyCode::Char(' ') => { + let id = sw.order[sw.selected]; + self.switcher = None; + self.focus_window(id); + } + KeyCode::Esc => { + self.switcher = None; + } + // alt+tab / alt+w igen stegar vidare + KeyCode::Char('w') if key.modifiers.contains(KeyModifiers::ALT) => { + sw.selected = (sw.selected + 1) % len; + } + _ => {} + } + } + + /// Fäst fokuserat fönster mot vänster/höger halva av innehållsytan. + fn snap_focused(&mut self, left: bool) { + let ca = self.content_area; + let Some(id) = self.focused_id else { return }; + let half_w = ca.width / 2; + if let Some(w) = self.windows.iter_mut().find(|w| w.id == id) { + if !w.resizable { + return; + } + if w.prev_geom.is_none() { + w.prev_geom = Some((w.x, w.y, w.width, w.height)); + } + w.x = if left { ca.x as i32 } else { (ca.x + half_w) as i32 }; + w.y = ca.y as i32; + w.width = if left { half_w } else { ca.width - half_w }; + w.height = ca.height; + } + } + + /// Maximera fokuserat fönster till hela innehållsytan, eller + /// återställ till geometrin det hade innan snap/maximering. + fn toggle_maximize_focused(&mut self) { + let ca = self.content_area; + let Some(id) = self.focused_id else { return }; + if let Some(w) = self.windows.iter_mut().find(|w| w.id == id) { + if !w.resizable { + return; + } + let is_maximized = w.x == ca.x as i32 + && w.y == ca.y as i32 + && w.width == ca.width + && w.height == ca.height; + if is_maximized { + // Återställ till geometrin före snap/maximering + if let Some((px, py, pw, ph)) = w.prev_geom.take() { + w.x = px; + w.y = py; + w.width = pw; + w.height = ph; + } + } else { + // Behåll ev. redan sparad ursprungsgeometri (t.ex. från snap) + if w.prev_geom.is_none() { + w.prev_geom = Some((w.x, w.y, w.width, w.height)); + } + w.x = ca.x as i32; + w.y = ca.y as i32; + w.width = ca.width; + w.height = ca.height; + } + } + } + fn close_window(&mut self, id: usize) { self.windows.retain(|w| w.id != id); if self.focused_id == Some(id) { @@ -1529,6 +1690,9 @@ impl App { self.windows.push(w); } self.focused_id = Some(id); + // MRU-historik för Alt+Tab + self.focus_history.retain(|&h| h != id); + self.focus_history.insert(0, id); } } @@ -1878,6 +2042,10 @@ fn action_display(action: &MenuAction) -> &'static str { MenuAction::RunProgram { .. } => "Kör program", MenuAction::Submenu { .. } => "Undermeny", MenuAction::SpawnRunDialog => "Kommandodialog", + MenuAction::WindowSwitcher => "Fönsterväxlare", + MenuAction::SnapLeft => "Fäst vänster", + MenuAction::SnapRight => "Fäst höger", + MenuAction::Maximize => "Maximera/återställ", MenuAction::NoOp => "", } } diff --git a/src/config.rs b/src/config.rs index 7c7f3b7..85b7c6d 100644 --- a/src/config.rs +++ b/src/config.rs @@ -115,6 +115,14 @@ pub enum MenuAction { }, /// Öppnar Tui-run kommandodialog SpawnRunDialog, + /// Öppnar fönsterväxlaren (Alt+Tab-overlay, MRU-ordning) + WindowSwitcher, + /// Fäst fokuserat fönster mot vänster halva + SnapLeft, + /// Fäst fokuserat fönster mot höger halva + SnapRight, + /// Maximera/återställ fokuserat fönster + Maximize, /// Gör ingenting – används för display-only rader i menyer NoOp, } @@ -160,6 +168,36 @@ fn default_keybinds() -> Vec { action: MenuAction::SpawnTerminal { shell: None }, label: Some("Ny terminal".to_string()), }, + KeybindConfig { + key: "alt+tab".to_string(), + scope: KeybindScope::Global, + action: MenuAction::WindowSwitcher, + label: Some("Fönsterväxlare".to_string()), + }, + KeybindConfig { + key: "alt+w".to_string(), + scope: KeybindScope::Global, + action: MenuAction::WindowSwitcher, + label: None, + }, + KeybindConfig { + key: "alt+left".to_string(), + scope: KeybindScope::Global, + action: MenuAction::SnapLeft, + label: Some("Fäst vänster".to_string()), + }, + KeybindConfig { + key: "alt+right".to_string(), + scope: KeybindScope::Global, + action: MenuAction::SnapRight, + label: Some("Fäst höger".to_string()), + }, + KeybindConfig { + key: "alt+up".to_string(), + scope: KeybindScope::Global, + action: MenuAction::Maximize, + label: Some("Maximera/återställ".to_string()), + }, KeybindConfig { key: "ctrl+q".to_string(), scope: KeybindScope::Wm, diff --git a/src/render.rs b/src/render.rs index 8353a14..4223c57 100644 --- a/src/render.rs +++ b/src/render.rs @@ -53,6 +53,74 @@ pub fn render(frame: &mut Frame, app: &App) { if let Some(dd) = &app.dropdown { render_dropdown(frame, dd, &app.theme); } + + // Fönsterväxlaren (Alt+Tab) – ovanpå allt + if let Some(sw) = &app.switcher { + render_switcher(frame, app, sw); + } +} + +/// Alt+Tab-overlay: fönsterlista i MRU-ordning, centrerad. +fn render_switcher(frame: &mut Frame, app: &App, sw: &crate::app::SwitcherState) { + let th = &app.theme; + let area = frame.area(); + let titles: Vec = sw + .order + .iter() + .map(|id| { + app.windows + .iter() + .find(|w| w.id == *id) + .map(|w| match &w.content { + WindowContent::Terminal { title, .. } => { + format!("{} ({}x{})", title, w.width, w.height) + } + WindowContent::RunDialog { .. } => "Kommandodialog".to_string(), + WindowContent::PopupDialog { .. } => "Popup".to_string(), + }) + .unwrap_or_else(|| format!("fönster {}", id)) + }) + .collect(); + + let inner_w = titles + .iter() + .map(|t| unicode_width::UnicodeWidthStr::width(t.as_str())) + .max() + .unwrap_or(10) + .clamp(20, area.width.saturating_sub(6) as usize) as u16 + + 4; + let inner_h = titles.len() as u16; + let w = inner_w + 2; + let h = inner_h + 2; + let x = area.x + area.width.saturating_sub(w) / 2; + let y = area.y + area.height.saturating_sub(h) / 2; + let rect = Rect::new(x, y, w, h).intersection(area); + + frame.render_widget(Clear, rect); + frame.render_widget( + Block::default() + .title(Span::styled(" Fönster ", th.logo())) + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .border_style(Style::default().fg(th.accent)) + .style(Style::default().bg(th.menu_bg)), + rect, + ); + for (i, title) in titles.iter().enumerate() { + let row = Rect::new(rect.x + 1, rect.y + 1 + i as u16, rect.width.saturating_sub(2), 1); + if row.y >= rect.y + rect.height.saturating_sub(1) { + break; + } + let style = if i == sw.selected { + th.hover_highlight() + } else { + Style::default().fg(th.text).bg(th.menu_bg) + }; + frame.render_widget( + Paragraph::new(Span::styled(format!(" {} ", title), style)), + row, + ); + } } #[allow(clippy::too_many_arguments)] @@ -93,6 +161,12 @@ fn render_panel( Rect::new(rect.x + 10, rect.y, 1, 1), ); + // Separator mellan fokus-punkten och knapparna + frame.render_widget( + Paragraph::new(Span::styled("│", Style::default().fg(th.hint).bg(th.panel_bg))), + Rect::new(rect.x + 11, rect.y, 1, 1).intersection(frame.area()), + ); + // Vänsterjusterade knappar for (ii, item) in items.iter().enumerate() { let Some(&ir) = item_rects.get(ii) else { continue }; @@ -111,10 +185,20 @@ fn render_panel( // Status-widgets // Höger-dockade renderas från höger kant inåt let mut right_x = rect.x + rect.width; + let mut first_right = true; for (si, sw_cfg) in status_widgets.iter().enumerate().rev() { if sw_cfg.align != StatusAlign::Right { continue; } + if !first_right { + // Separator mellan status-widgets + right_x = right_x.saturating_sub(2); + frame.render_widget( + Paragraph::new(Span::styled(" │", Style::default().fg(th.hint).bg(th.panel_bg))), + Rect::new(right_x, rect.y, 2, 1).intersection(frame.area()), + ); + } + first_right = false; right_x = right_x.saturating_sub(sw_cfg.width); let text = status_states .get(si) diff --git a/tests/integration.py b/tests/integration.py index 9a98237..2775c6d 100644 --- a/tests/integration.py +++ b/tests/integration.py @@ -274,6 +274,38 @@ def main(): if top_win: app.send({"type": "close_window", "window_id": top_win["id"]}) + # ── 4e. Alt+Tab-växlare + snap/maximera ────────────────────────── + # Två fönster finns (mouse-test + top stängdes; spawna ett till) + app.send({"type": "spawn_window", "command": "exec cat", + "request_id": "w-sw"}) + sw_open = app.recv_until("window_opened") + disp.send(key_event(code="Tab", modifiers="ALT")) + text = disp.frame_text(timeout=5.0, contains="Fönster") + check("alt+tab öppnar fönsterväxlaren", "Fönster" in text) + disp.send(key_event(code="Esc")) + time.sleep(0.2) + + # Snap vänster: fokuserat fönster ska bli halva bredden + disp.send(key_event(code="Left", modifiers="ALT")) + time.sleep(0.3) + app.send({"type": "list_windows", "request_id": "lsnap"}) + wsn = app.recv_until("window_list") + snapped = next(w for w in wsn["windows"] if w["id"] == sw_open["id"]) + # innehållsytan är 100 bred (minus panel högst upp) → halvan ~50 + check("alt+left fäster fönstret som halvskärm", + 45 <= snapped["width"] <= 55 and snapped["x"] <= 1, + f"geometri: {snapped}") + + # Maximera/återställ: alt+up två gånger ska ge tillbaka snappad storlek + disp.send(key_event(code="Up", modifiers="ALT")) + time.sleep(0.3) + app.send({"type": "list_windows", "request_id": "lmax"}) + wmx = app.recv_until("window_list") + maxed = next(w for w in wmx["windows"] if w["id"] == sw_open["id"]) + check("alt+up maximerar fönstret", maxed["width"] >= 95, + f"geometri: {maxed}") + app.send({"type": "close_window", "window_id": sw_open["id"]}) + # ── 5. Popup + popup_result via Enter ─────────────────────────── app.send({ "type": "spawn_popup", "message": "Integrationstest?",