diff --git a/src/app.rs b/src/app.rs index 303bb8d..7861d4b 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,4 +1,5 @@ use crate::config::{Config, KeybindScope, MenuAction, MenuItem, PanelPosition}; +use crate::theme::Theme; use crate::pty::PtyTerminal; use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEventKind}; use ratatui::layout::Rect; @@ -100,6 +101,8 @@ pub enum AppIpcOut { pub struct App { pub should_quit: bool, pub config: Config, + /// Aktivt tema (laddas från config.theme, hot-reloadas med config) + pub theme: Theme, // Layout – uppdateras varje frame pub panel_rects: Vec, @@ -439,6 +442,8 @@ impl App { }) .collect(); + let theme = Theme::load_or_default(config.theme.as_deref()); + App { should_quit: false, config, @@ -458,6 +463,7 @@ impl App { status_states, parsed_keybinds, tui_wm_btn_rects: Vec::new(), + theme, hovered_tui_btn: false, socket_path: None, ipc_out: Vec::new(), @@ -598,8 +604,9 @@ impl App { if cur_rows != new_rows || cur_cols != new_cols { crate::log::log(&format!("PTY resize: {}x{} -> {}x{} (mouse mode was {:?})", cur_cols, cur_rows, new_cols, new_rows, window.mouse_mode)); let _ = pty.resize(new_rows, new_cols); - *parser = vt100::Parser::new(new_rows, new_cols, self.config.scrollback_lines); - // OBS: window.mouse_mode / window.mouse_encoding berörs inte — överlever restet. + // set_size behåller skärminnehåll och scrollback-historik — + // att återskapa parsern här raderade allt vid varje resize. + parser.screen_mut().set_size(new_rows, new_cols); } } diff --git a/src/config.rs b/src/config.rs index e80602f..7c7f3b7 100644 --- a/src/config.rs +++ b/src/config.rs @@ -13,6 +13,12 @@ pub struct Config { /// Standard-shell för nya terminaler. Om None → $SHELL eller /bin/bash. #[serde(default)] pub default_shell: Option, + /// Sökväg till temafil (t.ex. "themes/dark.toml"). Om None → inbyggt tema. + #[serde(default)] + pub theme: Option, + /// Rita skugga under/till höger om fönster. + #[serde(default = "default_true")] + pub shadow: bool, /// Antal raders scrollback-historik per virtuell terminal. #[serde(default = "default_scrollback_lines")] pub scrollback_lines: usize, @@ -207,6 +213,8 @@ impl Config { background_image: None, terminal_bg_color: None, default_shell: None, + theme: None, + shadow: true, scrollback_lines: default_scrollback_lines(), show_scrollbar: default_true(), default_window_size: default_window_size(), diff --git a/src/render.rs b/src/render.rs index 2f5b92b..8353a14 100644 --- a/src/render.rs +++ b/src/render.rs @@ -1,5 +1,4 @@ use crate::app::{App, DropdownState, FloatingWindow, ResizeEdge, WindowContent}; -use crate::theme; use crate::config::StatusAlign; use ratatui::{ layout::Rect, @@ -16,7 +15,7 @@ pub fn render(frame: &mut Frame, app: &App) { let bg_color = if app.config.background_image.is_some() { Color::Reset } else { - theme::DESKTOP_BG + app.theme.desktop_bg }; frame.render_widget( Block::default().style(Style::default().bg(bg_color)), @@ -24,18 +23,8 @@ pub fn render(frame: &mut Frame, app: &App) { ); // Flytande fönster – sista i listan renderas överst - let terminal_bg = app.config.terminal_bg_color.as_deref() - .and_then(parse_color) - .unwrap_or(Color::Reset); for window in &app.windows { - let resize_hover = app - .hovered_resize - .filter(|(id, _)| *id == window.id) - .map(|(_, e)| e); - render_window( - frame, window, app.focused_id, app.hovered_window_close, resize_hover, - terminal_bg, bg_color, app.config.show_scrollbar, - ); + render_window(frame, window, app); } // Paneler – alltid ovanpå fönster @@ -55,13 +44,14 @@ pub fn render(frame: &mut Frame, app: &App) { status_states, app.focused_id.is_none(), app.hovered_tui_btn, + &app.theme, ); } } // Dropdown – allra överst if let Some(dd) = &app.dropdown { - render_dropdown(frame, dd); + render_dropdown(frame, dd, &app.theme); } } @@ -77,18 +67,19 @@ fn render_panel( status_states: &[crate::app::StatusWidgetState], desktop_focused: bool, hovered_tui: bool, + th: &crate::theme::Theme, ) { // Panelens bakgrund frame.render_widget( - Block::default().style(Style::default().bg(theme::PANEL_BG)), + Block::default().style(Style::default().bg(th.panel_bg)), rect, ); // Logotyp – klickbar meny-knapp med hover-highlight let logo_style = if hovered_tui { - theme::hover_highlight() + th.hover_highlight() } else { - theme::logo() + th.logo() }; frame.render_widget( Paragraph::new(Span::styled(" TUI-WM ", logo_style)), @@ -96,9 +87,9 @@ fn render_panel( ); // Fokus-indikator: grön ● = skrivbord fokuserat, grå ● = fönster fokuserat - let dot_color = if desktop_focused { theme::OK } else { theme::HINT }; + let dot_color = if desktop_focused { th.ok } else { th.hint }; frame.render_widget( - Paragraph::new(Span::styled("●", Style::default().fg(dot_color).bg(theme::PANEL_BG))), + Paragraph::new(Span::styled("●", Style::default().fg(dot_color).bg(th.panel_bg))), Rect::new(rect.x + 10, rect.y, 1, 1), ); @@ -107,9 +98,9 @@ fn render_panel( let Some(&ir) = item_rects.get(ii) else { continue }; let is_hovered = hovered == Some((panel_idx, ii)); let style = if is_hovered { - theme::hover_highlight() + th.hover_highlight() } else { - Style::default().fg(theme::TEXT).bg(theme::PANEL_BG) + Style::default().fg(th.text).bg(th.panel_bg) }; frame.render_widget( Paragraph::new(Span::styled(format!(" {} ", item.label), style)), @@ -135,7 +126,7 @@ fn render_panel( frame.render_widget( Paragraph::new(Span::styled( padded, - theme::panel_item(), + th.panel_item(), )), draw_rect, ); @@ -154,7 +145,7 @@ fn render_panel( frame.render_widget( Paragraph::new(Span::styled( padded, - theme::panel_item(), + th.panel_item(), )), draw_rect, ); @@ -162,23 +153,23 @@ fn render_panel( } } -fn render_dropdown(frame: &mut Frame, dd: &DropdownState) { +fn render_dropdown(frame: &mut Frame, dd: &DropdownState, th: &crate::theme::Theme) { let border_rect = Rect::new(dd.rect.x, dd.rect.y, dd.rect.width, dd.rect.height + 2); frame.render_widget(Clear, border_rect); frame.render_widget( Block::default() .borders(Borders::ALL) .border_type(BorderType::Rounded) - .border_style(Style::default().fg(theme::ACCENT)) - .style(Style::default().bg(theme::MENU_BG)), + .border_style(Style::default().fg(th.accent)) + .style(Style::default().bg(th.menu_bg)), border_rect, ); for (i, (item, &ir)) in dd.items.iter().zip(dd.item_rects.iter()).enumerate() { let is_hovered = dd.hovered == Some(i); let style = if is_hovered { - theme::hover_highlight() + th.hover_highlight() } else { - Style::default().fg(theme::TEXT).bg(theme::MENU_BG) + Style::default().fg(th.text).bg(th.menu_bg) }; // ir pekar redan på den faktiska raden innanför kanten let draw_rect = Rect::new(ir.x + 1, ir.y, ir.width.saturating_sub(2), 1); @@ -189,17 +180,26 @@ fn render_dropdown(frame: &mut Frame, dd: &DropdownState) { } } -#[allow(clippy::too_many_arguments)] -fn render_window( - frame: &mut Frame, - window: &FloatingWindow, - focused_id: Option, - hovered_close: Option, - resize_hover: Option, - terminal_bg: Color, - outer_bg: Color, - show_scrollbar: bool, -) { +fn render_window(frame: &mut Frame, window: &FloatingWindow, app: &App) { + let th = &app.theme; + let focused_id = app.focused_id; + let hovered_close = app.hovered_window_close; + let resize_hover = app + .hovered_resize + .filter(|(id, _)| *id == window.id) + .map(|(_, e)| e); + let terminal_bg = app + .config + .terminal_bg_color + .as_deref() + .and_then(crate::theme::parse_color) + .unwrap_or(Color::Reset); + let outer_bg = if app.config.background_image.is_some() { + Color::Reset + } else { + th.desktop_bg + }; + let show_scrollbar = app.config.show_scrollbar; let full_rect = window.rect().intersection(frame.area()); if full_rect.width < 4 || full_rect.height < 2 { return; @@ -208,7 +208,7 @@ fn render_window( let is_focused = focused_id == Some(window.id); let is_resizing = window.resizing.is_some(); - let border_style = theme::window_border(is_focused, is_resizing, resize_hover.is_some()); + let border_style = th.window_border(is_focused, is_resizing, resize_hover.is_some()); let title_text = match &window.content { WindowContent::Terminal { title, .. } => format!(" {} ", title), @@ -218,8 +218,8 @@ fn render_window( // Diskret skugga nedanför/till höger om fönstret (inte när en // bakgrundsbild används — då ska skrivbordet synas igenom). - if outer_bg != Color::Reset { - render_shadow(frame, full_rect); + if app.config.shadow && outer_bg != Color::Reset { + render_shadow(frame, full_rect, th.shadow_color); } frame.render_widget(Clear, full_rect); @@ -242,7 +242,7 @@ fn render_window( // Titeln renderas som ett "chip" infällt i ramen: ┤ titel ├ let title_line = Line::from(vec![ Span::styled("┤", border_style), - Span::styled(title_text.clone(), theme::window_title(is_focused)), + Span::styled(title_text.clone(), th.window_title(is_focused)), Span::styled("├", border_style), ]); frame.render_widget( @@ -259,9 +259,9 @@ fn render_window( if cb.x + 3 <= full_rect.x + full_rect.width { let close_hov = hovered_close == Some(window.id); let close_style = if close_hov { - Style::default().fg(theme::TEXT).bg(theme::DANGER).add_modifier(Modifier::BOLD) + Style::default().fg(th.text).bg(th.danger).add_modifier(Modifier::BOLD) } else { - Style::default().fg(theme::DANGER) + Style::default().fg(th.danger) }; let close_line = Line::from(vec![ Span::styled("┤", border_style), @@ -284,22 +284,23 @@ fn render_window( match &window.content { WindowContent::Terminal { parser, alive, .. } => { if *alive { - render_terminal(frame, parser.screen(), cr, window.selection.as_ref(), cr, terminal_bg); + let dim = th.dim_unfocused && !is_focused; + render_terminal(frame, parser.screen(), cr, window.selection.as_ref(), cr, terminal_bg, dim); } else { frame.render_widget( Paragraph::new(Span::styled( " [Avslutad] ", - Style::default().fg(Color::DarkGray), + Style::default().fg(th.hint), )), cr, ); } } WindowContent::RunDialog { input, cursor_pos } => { - render_run_dialog_content(frame, input, *cursor_pos, cr); + render_run_dialog_content(frame, input, *cursor_pos, cr, th); } WindowContent::PopupDialog { .. } => { - render_popup_dialog(frame, window, focused_id); + render_popup_dialog(frame, window, focused_id, th); } } @@ -316,7 +317,7 @@ fn render_window( .track_symbol(Some("│")) .thumb_symbol("█") .track_style(border_style) - .thumb_style(Style::default().fg(theme::ACCENT)); + .thumb_style(Style::default().fg(th.accent)); frame.render_stateful_widget( sb, full_rect.inner(ratatui::layout::Margin { vertical: 1, horizontal: 0 }), @@ -330,13 +331,13 @@ fn render_window( /// mörkare bakgrund + dämpad text, så fönstren ser ut att sväva över /// skrivbordet. Muterar cellerna direkt så att underliggande innehåll /// (andra fönster/skrivbordet) syns kvar — bara mörkare. -fn render_shadow(frame: &mut Frame, rect: Rect) { +fn render_shadow(frame: &mut Frame, rect: Rect, shadow_bg: Color) { let area = frame.area(); let buf = frame.buffer_mut(); let mut shade = |x: u16, y: u16| { if x < area.width && y < area.height { if let Some(cell) = buf.cell_mut((x, y)) { - cell.set_bg(theme::SHADOW_BG); + cell.set_bg(shadow_bg); cell.modifier.insert(Modifier::DIM); } } @@ -355,12 +356,13 @@ fn render_popup_dialog( frame: &mut Frame, window: &FloatingWindow, focused_id: Option, + th: &crate::theme::Theme, ) { let is_focused = focused_id == Some(window.id); let border_style = if is_focused { - Style::default().fg(theme::ACCENT) + Style::default().fg(th.accent) } else { - Style::default().fg(theme::HINT) + Style::default().fg(th.hint) }; let WindowContent::PopupDialog { message, buttons, selected, .. } = &window.content else { @@ -374,8 +376,8 @@ fn render_popup_dialog( .borders(Borders::ALL) .border_type(BorderType::Rounded) .border_style(border_style) - .title(Span::styled(" TUI-WM ", theme::logo())) - .style(Style::default().bg(theme::DESKTOP_BG)), + .title(Span::styled(" TUI-WM ", th.logo())) + .style(Style::default().bg(th.desktop_bg)), rect, ); @@ -386,7 +388,7 @@ fn render_popup_dialog( Paragraph::new(message.as_str()) .wrap(Wrap { trim: true }) .alignment(ratatui::layout::Alignment::Center) - .style(Style::default().fg(Color::White).bg(Color::Indexed(235))), + .style(Style::default().fg(th.text).bg(th.desktop_bg)), Rect::new(content.x, content.y, content.width, msg_h), ); } @@ -401,9 +403,9 @@ fn render_popup_dialog( for (i, label) in labels.iter().enumerate() { let w = label.len() as u16; let style = if i == *selected { - theme::hover_highlight() + th.hover_highlight() } else { - Style::default().fg(theme::TEXT).bg(theme::PANEL_BG) + Style::default().fg(th.text).bg(th.panel_bg) }; if btn_x + w <= rect.x + rect.width { frame.render_widget( @@ -448,7 +450,7 @@ fn render_resize_hints( } } -fn render_run_dialog_content(frame: &mut Frame, input: &str, cursor_pos: usize, area: Rect) { +fn render_run_dialog_content(frame: &mut Frame, input: &str, cursor_pos: usize, area: Rect, th: &crate::theme::Theme) { if area.height == 0 { return; } @@ -465,10 +467,10 @@ fn render_run_dialog_content(frame: &mut Frame, input: &str, cursor_pos: usize, }; let input_line = Line::from(vec![ - Span::styled(prefix, Style::default().fg(Color::Cyan)), - Span::styled(before_cursor, Style::default().fg(Color::White)), + Span::styled(prefix, Style::default().fg(th.accent)), + Span::styled(before_cursor, Style::default().fg(th.text)), Span::styled(&at_cursor, Style::default().fg(Color::Black).bg(Color::White).add_modifier(Modifier::BOLD)), - Span::styled(after_cursor, Style::default().fg(Color::White)), + Span::styled(after_cursor, Style::default().fg(th.text)), ]); frame.render_widget( Paragraph::new(input_line), @@ -481,13 +483,14 @@ fn render_run_dialog_content(frame: &mut Frame, input: &str, cursor_pos: usize, frame.render_widget( Paragraph::new(Span::styled( " Enter=Kör Esc=Avbryt", - Style::default().fg(Color::Indexed(242)), + Style::default().fg(th.border_dim), )), Rect::new(area.x, area.y + 2, area.width, 1), ); } } +#[allow(clippy::too_many_arguments)] fn render_terminal( frame: &mut Frame, screen: &vt100::Screen, @@ -495,6 +498,7 @@ fn render_terminal( selection: Option<&crate::app::Selection>, _content_rect: Rect, terminal_bg: Color, + dim: bool, ) { let (screen_rows, screen_cols) = screen.size(); let rows = (area.height as usize).min(screen_rows as usize); @@ -541,6 +545,9 @@ fn render_terminal( if cell.underline() { st = st.add_modifier(Modifier::UNDERLINED); } + if dim { + st = st.add_modifier(Modifier::DIM); + } if is_selected { st = Style::default() .fg(Color::Black) @@ -590,29 +597,4 @@ fn vt_color(c: vt100::Color) -> Color { } } -fn parse_color(s: &str) -> Option { - let s = s.trim(); - if let Some(hex) = s.strip_prefix('#') { - if hex.len() == 6 { - let r = u8::from_str_radix(&hex[0..2], 16).ok()?; - let g = u8::from_str_radix(&hex[2..4], 16).ok()?; - let b = u8::from_str_radix(&hex[4..6], 16).ok()?; - return Some(Color::Rgb(r, g, b)); - } - } - if let Ok(n) = s.parse::() { - return Some(Color::Indexed(n)); - } - match s.to_lowercase().as_str() { - "black" => Some(Color::Black), - "red" => Some(Color::Red), - "green" => Some(Color::Green), - "yellow" => Some(Color::Yellow), - "blue" => Some(Color::Blue), - "magenta" => Some(Color::Magenta), - "cyan" => Some(Color::Cyan), - "white" => Some(Color::White), - "reset" | "transparent" => Some(Color::Reset), - _ => None, - } -} + diff --git a/src/theme.rs b/src/theme.rs index b7657cb..9fd01f5 100644 --- a/src/theme.rs +++ b/src/theme.rs @@ -1,77 +1,240 @@ -//! Centralt tema för TUI-WM. +//! Tema för TUI-WM. //! -//! ALLA färger och återkommande stilar definieras här — ändra utseendet -//! på hela fönsterhanteraren från en enda fil. render.rs ska inte -//! hårdkoda `Color::`-literaler för sådant som temat täcker. +//! Ett tema definieras i en egen TOML-fil (t.ex. `themes/dark.toml`) och +//! väljs i `config.toml` med `theme = "themes/dark.toml"` — precis som +//! paneler pekar på sina egna filer. Alla fält är valfria; utelämnade +//! fält faller tillbaka på standardtemat. +//! +//! Färger anges som strängar: färgnamn ("cyan"), index ("235") eller +//! hex ("#1e1e2e"). use ratatui::style::{Color, Modifier, Style}; +use serde::Deserialize; -// ─── Palett ────────────────────────────────────────────────────────────── -/// Fokusmarkering (fönsterram, logga, markerade menyrader) -pub const ACCENT: Color = Color::Cyan; -/// Pågående storleksändring -pub const RESIZE: Color = Color::Yellow; -/// Hover över fönsterkant (inför resize) -pub const RESIZE_HOVER: Color = Color::Indexed(220); -/// Ofokuserad fönsterram -pub const BORDER_DIM: Color = Color::Indexed(242); -/// Skrivbordsbakgrund (när ingen bakgrundsbild används) -pub const DESKTOP_BG: Color = Color::Indexed(235); -/// Panelens bakgrund -pub const PANEL_BG: Color = Color::Indexed(238); -/// Dropdown-menyns bakgrund -pub const MENU_BG: Color = Color::Indexed(236); -/// Normal text -pub const TEXT: Color = Color::White; -/// Panel-text (något nedtonad) -pub const PANEL_TEXT: Color = Color::Indexed(252); -/// Hints och inaktiva indikatorer -pub const HINT: Color = Color::Indexed(240); -/// Positiv indikator (skrivbordsfokus-punkten) -pub const OK: Color = Color::Green; -/// Destruktivt (stäng-knappen) -pub const DANGER: Color = Color::Red; +#[derive(Debug, Clone, PartialEq)] +pub struct Theme { + /// Fokusmarkering (fönsterram, logga, markerade menyrader) + pub accent: Color, + /// Pågående storleksändring + pub resize: Color, + /// Hover över fönsterkant (inför resize) + pub resize_hover: Color, + /// Ofokuserad fönsterram + pub border_dim: Color, + /// Skrivbordsbakgrund (när ingen bakgrundsbild används) + pub desktop_bg: Color, + /// Panelens bakgrund + pub panel_bg: Color, + /// Dropdown-menyns bakgrund + pub menu_bg: Color, + /// Normal text + pub text: Color, + /// Panel-text (något nedtonad) + pub panel_text: Color, + /// Hints och inaktiva indikatorer + pub hint: Color, + /// Positiv indikator (skrivbordsfokus-punkten) + pub ok: Color, + /// Destruktivt (stäng-knappen) + pub danger: Color, + /// Fönsterskuggans bakgrundsfärg + pub shadow_color: Color, + /// Dämpa innehållet i ofokuserade fönster + pub dim_unfocused: bool, +} -// ─── Stilar ────────────────────────────────────────────────────────────── -/// Ramstil för ett fönster utifrån dess tillstånd. -pub fn window_border(focused: bool, resizing: bool, resize_hover: bool) -> Style { - if resizing { - Style::default().fg(RESIZE) - } else if resize_hover { - Style::default().fg(RESIZE_HOVER) - } else if focused { - Style::default().fg(ACCENT) - } else { - Style::default().fg(BORDER_DIM) +impl Default for Theme { + fn default() -> Self { + Theme { + accent: Color::Cyan, + resize: Color::Yellow, + resize_hover: Color::Indexed(220), + border_dim: Color::Indexed(242), + desktop_bg: Color::Indexed(235), + panel_bg: Color::Indexed(238), + menu_bg: Color::Indexed(236), + text: Color::White, + panel_text: Color::Indexed(252), + hint: Color::Indexed(240), + ok: Color::Green, + danger: Color::Red, + shadow_color: Color::Indexed(233), + dim_unfocused: true, + } } } -/// Markerad rad/knapp (hover): inverterad accent. -pub fn hover_highlight() -> Style { - Style::default() - .fg(Color::Black) - .bg(ACCENT) - .add_modifier(Modifier::BOLD) +/// Rå temafil — alla fält valfria strängar som parsas till färger. +#[derive(Deserialize, Default)] +pub struct ThemeFile { + pub accent: Option, + pub resize: Option, + pub resize_hover: Option, + pub border_dim: Option, + pub desktop_bg: Option, + pub panel_bg: Option, + pub menu_bg: Option, + pub text: Option, + pub panel_text: Option, + pub hint: Option, + pub ok: Option, + pub danger: Option, + pub shadow_color: Option, + pub dim_unfocused: Option, } -/// TUI-WM-loggan i panelen. -pub fn logo() -> Style { - Style::default().fg(ACCENT).add_modifier(Modifier::BOLD) -} +impl Theme { + /// Ladda ett tema från fil. Fält som saknas (eller inte kan parsas) + /// behåller standardtemats värde. + pub fn load(path: &str) -> anyhow::Result { + let content = std::fs::read_to_string(path)?; + let file: ThemeFile = toml::from_str(&content)?; + Ok(Theme::from_file(file)) + } -/// Panel-objekt (menyknappar) i normalläge. -pub fn panel_item() -> Style { - Style::default().fg(PANEL_TEXT).bg(PANEL_BG) -} + pub fn from_file(f: ThemeFile) -> Theme { + let d = Theme::default(); + let c = |s: Option, fallback: Color| { + s.as_deref().and_then(parse_color).unwrap_or(fallback) + }; + Theme { + accent: c(f.accent, d.accent), + resize: c(f.resize, d.resize), + resize_hover: c(f.resize_hover, d.resize_hover), + border_dim: c(f.border_dim, d.border_dim), + desktop_bg: c(f.desktop_bg, d.desktop_bg), + panel_bg: c(f.panel_bg, d.panel_bg), + menu_bg: c(f.menu_bg, d.menu_bg), + text: c(f.text, d.text), + panel_text: c(f.panel_text, d.panel_text), + hint: c(f.hint, d.hint), + ok: c(f.ok, d.ok), + danger: c(f.danger, d.danger), + shadow_color: c(f.shadow_color, d.shadow_color), + dim_unfocused: f.dim_unfocused.unwrap_or(d.dim_unfocused), + } + } -/// Fönstertitel — accentfärgad när fönstret har fokus. -pub fn window_title(focused: bool) -> Style { - if focused { - Style::default().fg(ACCENT).add_modifier(Modifier::BOLD) - } else { - Style::default().fg(HINT) + /// Ladda temat som konfigurationen pekar på, annars standardtemat. + pub fn load_or_default(path: Option<&str>) -> Theme { + match path { + None => Theme::default(), + Some(p) => match Theme::load(p) { + Ok(t) => t, + Err(e) => { + eprintln!("Varning: kunde inte ladda tema {}: {}", p, e); + Theme::default() + } + }, + } + } + + // ── Stilar ─────────────────────────────────────────────────────────── + /// Ramstil för ett fönster utifrån dess tillstånd. + pub fn window_border(&self, focused: bool, resizing: bool, resize_hover: bool) -> Style { + if resizing { + Style::default().fg(self.resize) + } else if resize_hover { + Style::default().fg(self.resize_hover) + } else if focused { + Style::default().fg(self.accent) + } else { + Style::default().fg(self.border_dim) + } + } + + /// Markerad rad/knapp (hover): inverterad accent. + pub fn hover_highlight(&self) -> Style { + Style::default() + .fg(Color::Black) + .bg(self.accent) + .add_modifier(Modifier::BOLD) + } + + /// TUI-WM-loggan i panelen. + pub fn logo(&self) -> Style { + Style::default().fg(self.accent).add_modifier(Modifier::BOLD) + } + + /// Panel-objekt (menyknappar, status-widgets) i normalläge. + pub fn panel_item(&self) -> Style { + Style::default().fg(self.panel_text).bg(self.panel_bg) + } + + /// Fönstertitel — accentfärgad när fönstret har fokus. + pub fn window_title(&self, focused: bool) -> Style { + if focused { + Style::default().fg(self.accent).add_modifier(Modifier::BOLD) + } else { + Style::default().fg(self.hint) + } } } -/// Bakgrundsfärg för fönsterskuggan (mörkare än skrivbordet). -pub const SHADOW_BG: Color = Color::Indexed(233); +/// Tolka en färgsträng: färgnamn, "0"–"255" (indexed) eller "#RRGGBB". +pub fn parse_color(s: &str) -> Option { + let s = s.trim(); + if let Some(hex) = s.strip_prefix('#') { + if hex.len() == 6 { + let r = u8::from_str_radix(&hex[0..2], 16).ok()?; + let g = u8::from_str_radix(&hex[2..4], 16).ok()?; + let b = u8::from_str_radix(&hex[4..6], 16).ok()?; + return Some(Color::Rgb(r, g, b)); + } + return None; + } + if let Ok(idx) = s.parse::() { + return Some(Color::Indexed(idx)); + } + match s.to_lowercase().as_str() { + "black" => Some(Color::Black), + "red" => Some(Color::Red), + "green" => Some(Color::Green), + "yellow" => Some(Color::Yellow), + "blue" => Some(Color::Blue), + "magenta" => Some(Color::Magenta), + "cyan" => Some(Color::Cyan), + "gray" | "grey" => Some(Color::Gray), + "darkgray" | "darkgrey" => Some(Color::DarkGray), + "lightred" => Some(Color::LightRed), + "lightgreen" => Some(Color::LightGreen), + "lightyellow" => Some(Color::LightYellow), + "lightblue" => Some(Color::LightBlue), + "lightmagenta" => Some(Color::LightMagenta), + "lightcyan" => Some(Color::LightCyan), + "white" => Some(Color::White), + "reset" | "transparent" => Some(Color::Reset), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn theme_file_overrides_defaults() { + let f: ThemeFile = toml::from_str( + r##" +accent = "#ff0000" +desktop_bg = "17" +dim_unfocused = false +"##, + ) + .unwrap(); + let t = Theme::from_file(f); + assert_eq!(t.accent, Color::Rgb(255, 0, 0)); + assert_eq!(t.desktop_bg, Color::Indexed(17)); + assert!(!t.dim_unfocused); + // Ej satta fält behåller default + assert_eq!(t.panel_bg, Theme::default().panel_bg); + } + + #[test] + fn parse_color_variants() { + assert_eq!(parse_color("cyan"), Some(Color::Cyan)); + assert_eq!(parse_color("235"), Some(Color::Indexed(235))); + assert_eq!(parse_color("#1e1e2e"), Some(Color::Rgb(0x1e, 0x1e, 0x2e))); + assert_eq!(parse_color("blorp"), None); + } +} diff --git a/tests/integration.py b/tests/integration.py index 0df10e9..9a98237 100644 --- a/tests/integration.py +++ b/tests/integration.py @@ -213,6 +213,27 @@ def main(): f"frame-text: {text[-200:]!r}") app.send({"type": "close_window", "window_id": emoji_open["id"]}) + # ── 4b2. Resize får inte radera fönstrets innehåll ─────────────── + app.send({"type": "spawn_window", + "command": "printf 'RESIZE-KEEP\\n'; exec cat", + "request_id": "w-resize"}) + rz_open = app.recv_until("window_opened") + disp.frame_text(timeout=5.0, contains="RESIZE-KEEP") + app.send({"type": "list_windows", "request_id": "lr"}) + wlr = app.recv_until("window_list") + rw = next(w for w in wlr["windows"] if w["id"] == rz_open["id"]) + # dra i nedre högra hörnet: krymp fönstret 4 kolumner/2 rader + corner_x = rw["x"] + rw["width"] - 1 + corner_y = rw["y"] + rw["height"] - 1 + disp.send(mouse_event({"Down": "Left"}, corner_x, corner_y)) + disp.send(mouse_event({"Drag": "Left"}, corner_x - 4, corner_y - 2)) + disp.send(mouse_event({"Up": "Left"}, corner_x - 4, corner_y - 2)) + time.sleep(0.5) + text = disp.frame_text(timeout=5.0, contains="RESIZE-KEEP") + check("innehållet överlever fönster-resize", "RESIZE-KEEP" in text, + f"frame-tail: {text[-200:]!r}") + app.send({"type": "close_window", "window_id": rz_open["id"]}) + # ── 4c. Scrollback: mushjul utan mus-tracking scrollar historiken ─ app.send({"type": "spawn_window", "command": "seq 1 200; exec cat", diff --git a/themes/dark.toml b/themes/dark.toml new file mode 100644 index 0000000..9f9c4c9 --- /dev/null +++ b/themes/dark.toml @@ -0,0 +1,19 @@ +# TUI-WM standardtema (mörkt). +# Välj tema i config.toml: theme = "themes/dark.toml" +# Alla fält är valfria — utelämnade fält får inbyggda standardvärden. +# Färger: namn ("cyan"), index ("235") eller hex ("#1e1e2e"). + +accent = "cyan" # fokusram, logga, markeringar +resize = "yellow" # ram under pågående storleksändring +resize_hover = "220" # ram vid hover över kant +border_dim = "242" # ofokuserad fönsterram +desktop_bg = "235" # skrivbordsbakgrund +panel_bg = "238" # panelens bakgrund +menu_bg = "236" # dropdown-menyns bakgrund +text = "white" +panel_text = "252" +hint = "240" +ok = "green" +danger = "red" +shadow_color = "233" # fönsterskuggans färg +dim_unfocused = true # dämpa innehåll i ofokuserade fönster diff --git a/themes/light.toml b/themes/light.toml new file mode 100644 index 0000000..4136240 --- /dev/null +++ b/themes/light.toml @@ -0,0 +1,17 @@ +# TUI-WM ljust tema — exempel på eget tema. +# Välj i config.toml: theme = "themes/light.toml" + +accent = "blue" +resize = "166" +resize_hover = "208" +border_dim = "249" +desktop_bg = "253" +panel_bg = "251" +menu_bg = "254" +text = "black" +panel_text = "236" +hint = "245" +ok = "28" +danger = "124" +shadow_color = "248" +dim_unfocused = true