Alt+Tab window switcher (MRU), snap/maximize, panel separators

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-07-15 19:40:28 +02:00
parent 1c7999de09
commit b85345be99
5 changed files with 356 additions and 1 deletions

View File

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

View File

@@ -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<usize>,
/// Öppen fönsterväxlare (Alt+Tab-overlay)
pub switcher: Option<SwitcherState>,
/// 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<Rect>,
@@ -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<usize>,
/// 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<usize> = 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<usize> = 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 => "",
}
}

View File

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

View File

@@ -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<String> = 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)

View File

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