feat: add configuration management and panel rendering

- Introduced a new `Config` struct for loading application configuration from a TOML file.
- Added support for keybindings and panel configurations, including status widgets.
- Implemented `PtyTerminal` for handling pseudo-terminal interactions.
- Created a rendering module to manage the display of application windows, panels, and dropdowns.
- Refactored the main application loop to utilize the new configuration and rendering logic.
- Removed legacy code related to event handling and rendering from `main.rs`.
This commit is contained in:
Björn Blomberg
2026-03-27 17:01:06 +01:00
parent d7c70baf35
commit 4440ad79bd
15 changed files with 2154 additions and 177 deletions

979
src/app.rs Normal file
View File

@@ -0,0 +1,979 @@
use crate::config::{Config, KeybindScope, MenuAction, MenuItem, PanelPosition};
use crate::pty::PtyTerminal;
use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEventKind};
use ratatui::layout::Rect;
use std::sync::mpsc;
use std::time::{Duration, Instant};
// ─── Keybinds ────────────────────────────────────────────────────────────────
/// Parsad version av en KeybindConfig-rad
pub struct ParsedKeybind {
pub code: KeyCode,
pub modifiers: KeyModifiers,
pub scope: KeybindScope,
pub action: MenuAction,
}
/// Parsar "ctrl+alt+t" → (KeyModifiers, KeyCode)
fn parse_keybind(s: &str) -> Option<(KeyModifiers, KeyCode)> {
let mut modifiers = KeyModifiers::NONE;
let mut code: Option<KeyCode> = None;
for part in s.to_lowercase().split('+') {
match part.trim() {
"ctrl" | "control" => modifiers |= KeyModifiers::CONTROL,
"alt" => modifiers |= KeyModifiers::ALT,
"shift" => modifiers |= KeyModifiers::SHIFT,
other => code = Some(parse_key_code(other)?),
}
}
Some((modifiers, code?))
}
fn parse_key_code(s: &str) -> Option<KeyCode> {
match s {
"space" => Some(KeyCode::Char(' ')),
"enter" | "return" => Some(KeyCode::Enter),
"backspace" => Some(KeyCode::Backspace),
"tab" => Some(KeyCode::Tab),
"esc" | "escape" => Some(KeyCode::Esc),
"up" => Some(KeyCode::Up),
"down" => Some(KeyCode::Down),
"left" => Some(KeyCode::Left),
"right" => Some(KeyCode::Right),
"home" => Some(KeyCode::Home),
"end" => Some(KeyCode::End),
"pageup" => Some(KeyCode::PageUp),
"pagedown" => Some(KeyCode::PageDown),
"delete" | "del" => Some(KeyCode::Delete),
"insert" | "ins" => Some(KeyCode::Insert),
s if s.starts_with('f') => s[1..].parse::<u8>().ok().map(KeyCode::F),
s if s.chars().count() == 1 => s.chars().next().map(KeyCode::Char),
_ => None,
}
}
// ─── Resize ──────────────────────────────────────────────────────────────────
#[derive(Clone, Copy, PartialEq, Debug)]
pub enum ResizeEdge {
Left,
Right,
Bottom,
BottomLeft,
BottomRight,
TopLeft,
}
#[derive(Clone)]
pub struct ResizeState {
pub edge: ResizeEdge,
pub start_mouse: (i32, i32),
pub start_win: (i32, i32, u16, u16), // x, y, w, h
}
// ─── Status-widget körningsläge ───────────────────────────────────────────────
pub struct StatusWidgetState {
pub output: String,
pub last_run: Option<Instant>, // None = aldrig kört, kör direkt
}
// ─── App ──────────────────────────────────────────────────────────────────────
pub struct App {
pub should_quit: bool,
pub config: Config,
// Layout uppdateras varje frame
pub panel_rects: Vec<Rect>,
pub panel_item_rects: Vec<Vec<Rect>>,
pub content_area: Rect,
// Hover-tillstånd
pub hovered_panel_item: Option<(usize, usize)>,
pub hovered_window_close: Option<usize>,
pub hovered_resize: Option<(usize, ResizeEdge)>,
// Öppen dropdown
pub dropdown: Option<DropdownState>,
// Flytande fönster (sista = överst)
pub windows: Vec<FloatingWindow>,
pub next_id: usize,
pub focused_id: Option<usize>,
// Status-widget körtillstånd, indexerat [panel][widget]
pub status_states: Vec<Vec<StatusWidgetState>>,
// Parsade keybinds från config
pub parsed_keybinds: Vec<ParsedKeybind>,
}
pub struct DropdownState {
pub panel_idx: usize,
pub items: Vec<MenuItem>,
pub rect: Rect,
pub item_rects: Vec<Rect>,
pub hovered: Option<usize>,
}
pub struct FloatingWindow {
pub id: usize,
pub x: i32,
pub y: i32,
pub width: u16,
pub height: u16,
pub content: WindowContent,
pub dragging: Option<(i32, i32)>,
pub resizing: Option<ResizeState>,
/// Om false: inga resize-kanter, kan inte storleksändras
pub resizable: bool,
}
pub enum WindowContent {
Terminal {
pty: PtyTerminal,
rx: mpsc::Receiver<Vec<u8>>,
parser: vt100::Parser,
alive: bool,
title: String,
},
RunDialog {
input: String,
cursor_pos: usize,
},
}
enum RunDialogResult {
Close,
Execute(String),
None,
}
const MIN_WIN_W: u16 = 20;
const MIN_WIN_H: u16 = 6;
impl FloatingWindow {
pub fn rect(&self) -> Rect {
Rect::new(self.x.max(0) as u16, self.y.max(0) as u16, self.width, self.height)
}
pub fn close_btn_rect(&self) -> Rect {
let x = (self.x + self.width as i32 - 4).max(0) as u16;
Rect::new(x, self.y.max(0) as u16, 3, 1)
}
pub fn content_rect(&self) -> Rect {
Rect::new(
(self.x + 1).max(0) as u16,
(self.y + 1).max(0) as u16,
self.width.saturating_sub(2),
self.height.saturating_sub(2),
)
}
/// Returnerar vilken resize-kant (om någon) som (col, row) träffar
pub fn resize_edge_at(&self, col: u16, row: u16) -> Option<ResizeEdge> {
if !self.resizable {
return None;
}
let wx = self.x.max(0) as u16;
let wy = self.y.max(0) as u16;
let wr = wx + self.width; // exklusiv höger
let wb = wy + self.height; // exklusiv botten
if col >= wr || row >= wb || col < wx || row < wy {
return None;
}
let on_left = col == wx;
let on_right = col == wr - 1;
let on_top = row == wy;
let on_bottom = row == wb - 1;
// Övre vänstra hörnet (men INTE övre höger = stäng-knapp-zonen)
if on_top && on_left {
return Some(ResizeEdge::TopLeft);
}
// Nedre hörn
if on_bottom && on_left {
return Some(ResizeEdge::BottomLeft);
}
if on_bottom && on_right {
return Some(ResizeEdge::BottomRight);
}
// Kanter
if on_left && !on_top {
return Some(ResizeEdge::Left);
}
if on_right && !on_top {
return Some(ResizeEdge::Right);
}
if on_bottom {
return Some(ResizeEdge::Bottom);
}
None
}
fn on_title_bar(&self, col: u16, row: u16) -> bool {
let cb = self.close_btn_rect();
let wx = self.x.max(0) as u16;
let wy = self.y.max(0) as u16;
row == wy
&& col > wx // vänster kant är TopLeft-resize
&& col < wx + self.width
&& !(col >= cb.x && col < cb.x + cb.width)
}
fn on_close_btn(&self, col: u16, row: u16) -> bool {
let cb = self.close_btn_rect();
row == cb.y && col >= cb.x && col < cb.x + cb.width
}
fn in_content(&self, col: u16, row: u16) -> bool {
let cr = self.content_rect();
col >= cr.x && col < cr.x + cr.width && row >= cr.y && row < cr.y + cr.height
}
/// Applicera resize baserat på nuvarande musposition
fn apply_resize(&mut self, col: i32, row: i32) {
let rs = match self.resizing.clone() {
Some(r) => r,
None => return,
};
let (sx, sy, sw, sh) = rs.start_win;
let dx = col - rs.start_mouse.0;
let dy = row - rs.start_mouse.1;
match rs.edge {
ResizeEdge::Left => {
let new_w = (sw as i32 - dx).max(MIN_WIN_W as i32) as u16;
self.x = sx + (sw as i32 - new_w as i32);
self.width = new_w;
}
ResizeEdge::Right => {
self.width = (sw as i32 + dx).max(MIN_WIN_W as i32) as u16;
}
ResizeEdge::Bottom => {
self.height = (sh as i32 + dy).max(MIN_WIN_H as i32) as u16;
}
ResizeEdge::BottomLeft => {
let new_w = (sw as i32 - dx).max(MIN_WIN_W as i32) as u16;
self.x = sx + (sw as i32 - new_w as i32);
self.width = new_w;
self.height = (sh as i32 + dy).max(MIN_WIN_H as i32) as u16;
}
ResizeEdge::BottomRight => {
self.width = (sw as i32 + dx).max(MIN_WIN_W as i32) as u16;
self.height = (sh as i32 + dy).max(MIN_WIN_H as i32) as u16;
}
ResizeEdge::TopLeft => {
let new_w = (sw as i32 - dx).max(MIN_WIN_W as i32) as u16;
let new_h = (sh as i32 - dy).max(MIN_WIN_H as i32) as u16;
self.x = sx + (sw as i32 - new_w as i32);
self.y = sy + (sh as i32 - new_h as i32);
self.width = new_w;
self.height = new_h;
}
}
}
}
// ─── App impl ─────────────────────────────────────────────────────────────────
impl App {
pub fn new(config: Config) -> Self {
let status_states: Vec<Vec<StatusWidgetState>> = config
.panels
.iter()
.map(|p| {
p.status_widgets
.iter()
.map(|_| StatusWidgetState { output: String::new(), last_run: None })
.collect()
})
.collect();
let parsed_keybinds: Vec<ParsedKeybind> = config
.keybinds
.iter()
.filter_map(|kb| {
let (modifiers, code) = parse_keybind(&kb.key)?;
Some(ParsedKeybind {
code,
modifiers,
scope: kb.scope.clone(),
action: kb.action.clone(),
})
})
.collect();
App {
should_quit: false,
config,
panel_rects: Vec::new(),
panel_item_rects: Vec::new(),
content_area: Rect::default(),
hovered_panel_item: None,
hovered_window_close: None,
hovered_resize: None,
dropdown: None,
windows: Vec::new(),
next_id: 0,
focused_id: None,
status_states,
parsed_keybinds,
}
}
pub fn update_layout(&mut self, area: Rect) {
self.panel_rects.clear();
self.panel_item_rects.clear();
let mut top_used = 0u16;
let mut bottom_used = 0u16;
for panel in &self.config.panels {
let rect = match panel.position {
PanelPosition::Top => {
let r = Rect::new(area.x, area.y + top_used, area.width, 1);
top_used += 1;
r
}
PanelPosition::Bottom => {
bottom_used += 1;
Rect::new(area.x, area.y + area.height.saturating_sub(bottom_used), area.width, 1)
}
PanelPosition::Left | PanelPosition::Right => Rect::default(),
};
let mut item_rects = Vec::new();
let mut x = rect.x + 10;
for item in &panel.items {
let w = item.label.len() as u16 + 2;
item_rects.push(Rect::new(x, rect.y, w, 1));
x += w + 1;
}
self.panel_rects.push(rect);
self.panel_item_rects.push(item_rects);
}
self.content_area = Rect::new(
area.x,
area.y + top_used,
area.width,
area.height.saturating_sub(top_used + bottom_used),
);
if let Some(dd) = &mut self.dropdown {
let pi = dd.panel_idx;
if let (Some(pr), Some(ir)) =
(self.panel_rects.get(pi), self.panel_item_rects.get(pi))
{
let x = ir.first().map(|r| r.x).unwrap_or(pr.x);
let y = pr.y + 1;
let width = dd
.items
.iter()
.map(|i| i.label.len() as u16 + 2)
.max()
.unwrap_or(12)
.max(12);
dd.item_rects = (0..dd.items.len())
.map(|i| Rect::new(x, y + i as u16, width, 1))
.collect();
dd.rect = Rect::new(x, y, width, dd.items.len() as u16);
}
}
}
pub fn tick(&mut self) {
// ── PTY-data → vt100-parser ──────────────────────────────────────────
for window in &mut self.windows {
let WindowContent::Terminal { rx, parser, alive, .. } = &mut window.content else { continue };
if !*alive {
continue;
}
loop {
match rx.try_recv() {
Ok(data) => parser.process(&data),
Err(mpsc::TryRecvError::Empty) => break,
Err(mpsc::TryRecvError::Disconnected) => {
*alive = false;
break;
}
}
}
}
// ── PTY resize om fönsterstorlek ändrats ─────────────────────────────
for window in &mut self.windows {
let new_rows = window.height.saturating_sub(2).max(1);
let new_cols = window.width.saturating_sub(2).max(1);
let WindowContent::Terminal { pty, parser, alive, .. } = &mut window.content else { continue };
if !*alive {
continue;
}
let (cur_rows, cur_cols) = parser.screen().size();
if cur_rows != new_rows || cur_cols != new_cols {
let _ = pty.resize(new_rows, new_cols);
*parser = vt100::Parser::new(new_rows, new_cols, 0);
}
}
// ── Ta bort döda fönster ─────────────────────────────────────────────
let prev_focused = self.focused_id;
self.windows.retain(|w| match &w.content {
WindowContent::Terminal { alive, .. } => *alive,
WindowContent::RunDialog { .. } => true,
});
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);
}
}
// ── Status-widgets: kör kommandon vid behov ──────────────────────────
for (pi, panel) in self.config.panels.iter().enumerate() {
for (si, sw_cfg) in panel.status_widgets.iter().enumerate() {
if let Some(state) = self
.status_states
.get_mut(pi)
.and_then(|v| v.get_mut(si))
{
let should_run = match state.last_run {
None => true,
Some(t) => t.elapsed() >= Duration::from_secs(sw_cfg.interval),
};
if should_run {
state.output = run_command(&sw_cfg.command);
state.last_run = Some(Instant::now());
}
}
}
}
}
pub fn handle_event(&mut self, event: Event) {
match event {
Event::Key(key) => self.handle_key(key),
Event::Mouse(m) => self.handle_mouse(m.column, m.row, m.kind),
Event::Resize(_, _) => {}
_ => {}
}
}
fn handle_key(&mut self, key: KeyEvent) {
// 1. Globala keybinds alltid, oavsett fokus
if let Some(action) = self.match_keybind(key, KeybindScope::Global) {
self.execute_action(action);
return;
}
// 2. RunDialog hanterar sina egna tangenter
if let Some(id) = self.focused_id {
let is_dialog = self.windows.iter()
.any(|w| w.id == id && matches!(w.content, WindowContent::RunDialog { .. }));
if is_dialog {
let result = self.handle_run_dialog_key(id, key);
match result {
RunDialogResult::Close => self.close_window(id),
RunDialogResult::Execute(cmd) => {
self.close_window(id);
if !cmd.is_empty() {
self.spawn_terminal(&cmd);
}
}
RunDialogResult::None => {}
}
return;
}
}
// 3. Vidarebefordra till fokuserat terminalfönster
if let Some(id) = self.focused_id {
if let Some(bytes) = key_to_bytes(key) {
if let Some(w) = self.windows.iter_mut().find(|w| w.id == id) {
if let WindowContent::Terminal { pty, .. } = &mut w.content {
let _ = pty.write_input(&bytes);
}
return;
}
}
}
// 4. WM-keybinds bara när ingen terminal är fokuserad
let terminal_focused = self.focused_id.map_or(false, |id| {
self.windows.iter().any(|w| w.id == id && matches!(w.content, WindowContent::Terminal { .. }))
});
if !terminal_focused {
if let Some(action) = self.match_keybind(key, KeybindScope::Wm) {
self.execute_action(action);
return;
}
}
// 5. Inbyggd fallback
if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('q') {
self.should_quit = true;
}
}
fn match_keybind(&self, key: KeyEvent, scope: KeybindScope) -> Option<MenuAction> {
self.parsed_keybinds
.iter()
.find(|kb| kb.scope == scope && kb.code == key.code && kb.modifiers == key.modifiers)
.map(|kb| kb.action.clone())
}
fn handle_run_dialog_key(&mut self, id: usize, key: KeyEvent) -> RunDialogResult {
let window = match self.windows.iter_mut().find(|w| w.id == id) {
Some(w) => w,
None => return RunDialogResult::None,
};
let WindowContent::RunDialog { input, cursor_pos } = &mut window.content else {
return RunDialogResult::None;
};
match key.code {
KeyCode::Esc => RunDialogResult::Close,
KeyCode::Enter => RunDialogResult::Execute(input.trim().to_string()),
KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => {
input.insert(*cursor_pos, c);
*cursor_pos += c.len_utf8();
RunDialogResult::None
}
KeyCode::Backspace => {
if *cursor_pos > 0 {
let prev = input[..*cursor_pos]
.char_indices()
.last()
.map(|(i, _)| i)
.unwrap_or(0);
input.drain(prev..*cursor_pos);
*cursor_pos = prev;
}
RunDialogResult::None
}
KeyCode::Delete => {
if *cursor_pos < input.len() {
input.remove(*cursor_pos);
}
RunDialogResult::None
}
KeyCode::Left => {
if *cursor_pos > 0 {
*cursor_pos = input[..*cursor_pos]
.char_indices()
.last()
.map(|(i, _)| i)
.unwrap_or(0);
}
RunDialogResult::None
}
KeyCode::Right => {
if *cursor_pos < input.len() {
let mut ci = input[*cursor_pos..].char_indices();
ci.next();
*cursor_pos += ci.next().map(|(i, _)| i).unwrap_or(input.len() - *cursor_pos);
}
RunDialogResult::None
}
KeyCode::Home => {
*cursor_pos = 0;
RunDialogResult::None
}
KeyCode::End => {
*cursor_pos = input.len();
RunDialogResult::None
}
_ => RunDialogResult::None,
}
}
fn handle_mouse(&mut self, col: u16, row: u16, kind: MouseEventKind) {
match kind {
MouseEventKind::Moved => self.update_hover(col, row),
MouseEventKind::Drag(MouseButton::Left) => self.handle_drag(col, row),
MouseEventKind::Up(MouseButton::Left) => {
for w in &mut self.windows {
w.dragging = None;
w.resizing = None;
}
}
MouseEventKind::Down(MouseButton::Left) => self.handle_click(col, row),
MouseEventKind::ScrollUp => self.handle_scroll(col, row, true),
MouseEventKind::ScrollDown => self.handle_scroll(col, row, false),
_ => {}
}
}
fn update_hover(&mut self, col: u16, row: u16) {
self.hovered_panel_item = None;
self.hovered_window_close = None;
self.hovered_resize = None;
// Dropdown-hover
if let Some(dd) = &mut self.dropdown {
dd.hovered = None;
for (i, r) in dd.item_rects.clone().iter().enumerate() {
if in_rect(col, row, *r) {
dd.hovered = Some(i);
return;
}
}
}
// Stäng-knapp (översta fönstret först)
for w in self.windows.iter().rev() {
if w.on_close_btn(col, row) {
self.hovered_window_close = Some(w.id);
return;
}
}
// Resize-kant
for w in self.windows.iter().rev() {
if let Some(edge) = w.resize_edge_at(col, row) {
// Kontrollera att det inte är stäng-knappen (redan hanterat ovan)
if !w.on_close_btn(col, row) {
self.hovered_resize = Some((w.id, edge));
return;
}
}
}
// Panel-knappar
for (pi, item_rects) in self.panel_item_rects.iter().enumerate() {
for (ii, r) in item_rects.iter().enumerate() {
if in_rect(col, row, *r) {
self.hovered_panel_item = Some((pi, ii));
return;
}
}
}
}
fn handle_drag(&mut self, col: u16, row: u16) {
let col = col as i32;
let row = row as i32;
// Resize har prioritet över drag
for w in self.windows.iter_mut().rev() {
if w.resizing.is_some() {
w.apply_resize(col, row);
return;
}
}
for w in self.windows.iter_mut().rev() {
if let Some((ox, oy)) = w.dragging {
w.x = col - ox;
w.y = row - oy;
return;
}
}
}
fn handle_click(&mut self, col: u16, row: u16) {
// 1. Dropdown-item
if let Some(idx) = self.dropdown.as_ref().and_then(|dd| dd.hovered) {
let action = self
.dropdown
.as_ref()
.and_then(|dd| dd.items.get(idx))
.map(|i| i.action.clone());
self.dropdown = None;
if let Some(action) = action {
self.execute_action(action);
}
return;
}
if self.dropdown.is_some() {
self.dropdown = None;
return;
}
// 2. Stäng-knapp
let close_id = self.windows.iter().rev().find(|w| w.on_close_btn(col, row)).map(|w| w.id);
if let Some(id) = close_id {
self.close_window(id);
return;
}
// 3. Resize-kant
let resize_hit = self
.windows
.iter()
.rev()
.find_map(|w| w.resize_edge_at(col, row).map(|e| (w.id, w.x, w.y, w.width, w.height, e)));
if let Some((id, wx, wy, ww, wh, edge)) = resize_hit {
self.focus_window(id);
if let Some(w) = self.windows.iter_mut().find(|w| w.id == id) {
w.resizing = Some(ResizeState {
edge,
start_mouse: (col as i32, row as i32),
start_win: (wx, wy, ww, wh),
});
}
return;
}
// 4. Titel-rad → drag + fokus
let title_hit = self
.windows
.iter()
.rev()
.find(|w| w.on_title_bar(col, row))
.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);
if let Some(w) = self.windows.iter_mut().find(|w| w.id == id) {
w.dragging = Some((ox, oy));
}
return;
}
// 5. Innehållsyta → fokus
let content_id = self.windows.iter().rev().find(|w| w.in_content(col, row)).map(|w| w.id);
if let Some(id) = content_id {
self.focus_window(id);
return;
}
// 6. Panel-knappar
for (pi, item_rects) in self.panel_item_rects.clone().iter().enumerate() {
for (ii, r) in item_rects.iter().enumerate() {
if in_rect(col, row, *r) {
let action = self.config.panels[pi].items[ii].action.clone();
if let MenuAction::Submenu { items } = action {
self.open_dropdown(pi, ii, items);
} else {
self.execute_action(action);
}
return;
}
}
}
}
fn handle_scroll(&mut self, col: u16, row: u16, up: bool) {
let id = self.windows.iter().rev().find(|w| in_rect(col, row, w.rect())).map(|w| w.id);
if let Some(id) = id {
let bytes: &[u8] = if up { b"\x1b[A\x1b[A\x1b[A" } else { b"\x1b[B\x1b[B\x1b[B" };
if let Some(w) = self.windows.iter_mut().find(|w| w.id == id) {
if let WindowContent::Terminal { pty, .. } = &mut w.content {
let _ = pty.write_input(bytes);
}
}
}
}
fn execute_action(&mut self, action: MenuAction) {
match action {
MenuAction::Exit => self.should_quit = true,
MenuAction::SpawnTerminal { shell } => {
let shell = shell.unwrap_or_else(default_shell);
self.spawn_terminal(&shell);
}
MenuAction::RunScript { path } => self.spawn_terminal(&path),
MenuAction::RunProgram { command, .. } => self.spawn_terminal(&command),
MenuAction::SpawnRunDialog => self.spawn_run_dialog(),
MenuAction::Submenu { .. } => {}
}
}
pub fn spawn_run_dialog(&mut self) {
let ca = self.content_area;
let w = 46u16.min(ca.width.saturating_sub(4));
let h = 5u16;
let x = ca.x as i32 + (ca.width as i32 - w as i32) / 2;
let y = ca.y as i32 + (ca.height as i32 - h as i32) / 2;
let id = self.next_id;
self.next_id += 1;
self.windows.push(FloatingWindow {
id,
x: x.max(0),
y: y.max(0),
width: w,
height: h,
content: WindowContent::RunDialog { input: String::new(), cursor_pos: 0 },
dragging: None,
resizing: None,
resizable: false,
});
self.focus_window(id);
}
fn open_dropdown(&mut self, panel_idx: usize, item_idx: usize, items: Vec<MenuItem>) {
let pr = match self.panel_rects.get(panel_idx) {
Some(r) => *r,
None => return,
};
let ir = self.panel_item_rects.get(panel_idx)
.and_then(|v| v.get(item_idx))
.copied()
.unwrap_or(pr);
let width = items.iter().map(|i| i.label.len() as u16 + 2).max().unwrap_or(12).max(12);
let x = ir.x;
let y = pr.y + 1;
let item_rects: Vec<Rect> =
(0..items.len()).map(|i| Rect::new(x, y + i as u16, width, 1)).collect();
self.dropdown = Some(DropdownState {
panel_idx,
rect: Rect::new(x, y, width, items.len() as u16),
item_rects,
items,
hovered: None,
});
}
pub fn spawn_terminal(&mut self, shell: &str) {
let ca = self.content_area;
let offset = (self.windows.len() as i32) * 2;
let w = 82u16.min(ca.width.saturating_sub(4));
let h = 26u16.min(ca.height.saturating_sub(4));
let x = ca.x as i32 + 2 + offset;
let y = ca.y as i32 + 1 + offset;
let rows = h.saturating_sub(2).max(1);
let cols = w.saturating_sub(2).max(1);
match PtyTerminal::spawn(shell, rows, cols) {
Ok((pty, rx)) => {
let parser = vt100::Parser::new(rows, cols, 0);
let id = self.next_id;
self.next_id += 1;
let title = shell.split('/').last().unwrap_or(shell).to_string();
self.windows.push(FloatingWindow {
id,
x,
y,
width: w,
height: h,
content: WindowContent::Terminal { pty, rx, parser, alive: true, title },
dragging: None,
resizing: None,
resizable: true,
});
self.focus_window(id);
}
Err(e) => eprintln!("Kunde inte starta terminal: {}", e),
}
}
fn close_window(&mut self, id: usize) {
self.windows.retain(|w| w.id != id);
if self.focused_id == Some(id) {
self.focused_id = self.windows.last().map(|w| w.id);
}
}
fn focus_window(&mut self, id: usize) {
if let Some(pos) = self.windows.iter().position(|w| w.id == id) {
let w = self.windows.remove(pos);
self.windows.push(w);
}
self.focused_id = Some(id);
}
}
// ─── Hjälpfunktioner ─────────────────────────────────────────────────────────
fn in_rect(col: u16, row: u16, rect: Rect) -> bool {
col >= rect.x
&& col < rect.x + rect.width
&& row >= rect.y
&& row < rect.y + rect.height
}
fn default_shell() -> String {
if cfg!(windows) {
// Försök hitta PowerShell i prioritetsordning: pwsh (Core) → powershell (inbyggd)
which_windows("pwsh").or_else(|| which_windows("powershell")).unwrap_or_else(|| "powershell.exe".to_string())
} else {
std::env::var("SHELL").unwrap_or_else(|_| "/bin/bash".to_string())
}
}
#[cfg(windows)]
fn which_windows(name: &str) -> Option<String> {
let exe = format!("{}.exe", name);
std::env::var("PATH").ok()?.split(';').find_map(|dir| {
let path = std::path::Path::new(dir).join(&exe);
if path.exists() { Some(path.to_string_lossy().into_owned()) } else { None }
})
}
#[cfg(not(windows))]
fn which_windows(_name: &str) -> Option<String> {
None
}
/// Kör ett shell-kommando och returnerar dess stdout som trimmat str
fn run_command(command: &str) -> String {
#[cfg(unix)]
let result = std::process::Command::new("sh").arg("-c").arg(command).output();
#[cfg(windows)]
let result = std::process::Command::new("powershell.exe")
.args(["-NoProfile", "-NonInteractive", "-Command", command])
.output();
match result {
Ok(out) => String::from_utf8_lossy(&out.stdout).trim().to_string(),
Err(_) => String::new(),
}
}
fn key_to_bytes(key: KeyEvent) -> Option<Vec<u8>> {
use KeyCode::*;
let bytes: Vec<u8> = match key.code {
Char(c) => {
if key.modifiers.contains(KeyModifiers::CONTROL) {
match c {
'a'..='z' => vec![c as u8 - b'a' + 1],
'A'..='Z' => vec![c as u8 - b'A' + 1],
'[' => vec![0x1b],
'\\' => vec![0x1c],
']' => vec![0x1d],
_ => return None,
}
} else {
let mut buf = [0u8; 4];
c.encode_utf8(&mut buf).as_bytes().to_vec()
}
}
Enter => vec![b'\r'],
Backspace => vec![0x7f],
Tab => vec![b'\t'],
Esc => vec![0x1b],
Up => b"\x1b[A".to_vec(),
Down => b"\x1b[B".to_vec(),
Right => b"\x1b[C".to_vec(),
Left => b"\x1b[D".to_vec(),
Home => b"\x1b[H".to_vec(),
End => b"\x1b[F".to_vec(),
PageUp => b"\x1b[5~".to_vec(),
PageDown => b"\x1b[6~".to_vec(),
Delete => b"\x1b[3~".to_vec(),
F(1) => b"\x1bOP".to_vec(),
F(2) => b"\x1bOQ".to_vec(),
F(3) => b"\x1bOR".to_vec(),
F(4) => b"\x1bOS".to_vec(),
F(5) => b"\x1b[15~".to_vec(),
F(6) => b"\x1b[17~".to_vec(),
F(7) => b"\x1b[18~".to_vec(),
F(8) => b"\x1b[19~".to_vec(),
F(9) => b"\x1b[20~".to_vec(),
F(10) => b"\x1b[21~".to_vec(),
F(11) => b"\x1b[23~".to_vec(),
F(12) => b"\x1b[24~".to_vec(),
_ => return None,
};
Some(bytes)
}