Themes as standalone config files + configurable shadow + dim unfocused + resize content-loss fix

- Theme is now a runtime struct loaded from its own TOML file
  (theme = "themes/dark.toml" in config.toml, like panels), all
  fields optional with built-in defaults; ships themes/dark.toml and
  themes/light.toml
- shadow = true/false in config.toml, shadow_color per theme
- dim_unfocused (per theme): unfocused window content is dimmed
- BUGFIX: resizing a window no longer erases its content and history —
  the vt100 parser was recreated on every resize; now uses
  screen_mut().set_size() which preserves the grid and scrollback
- New integration check: window content survives mouse resize (20/20)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-15 19:35:15 +02:00
parent 71952ab90b
commit 1c7999de09
7 changed files with 371 additions and 154 deletions

View File

@@ -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<Rect>,
@@ -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);
}
}

View File

@@ -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<String>,
/// Sökväg till temafil (t.ex. "themes/dark.toml"). Om None → inbyggt tema.
#[serde(default)]
pub theme: Option<String>,
/// 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(),

View File

@@ -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<usize>,
hovered_close: Option<usize>,
resize_hover: Option<ResizeEdge>,
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<usize>,
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<Color> {
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::<u8>() {
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,
}
}

View File

@@ -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<String>,
pub resize: Option<String>,
pub resize_hover: Option<String>,
pub border_dim: Option<String>,
pub desktop_bg: Option<String>,
pub panel_bg: Option<String>,
pub menu_bg: Option<String>,
pub text: Option<String>,
pub panel_text: Option<String>,
pub hint: Option<String>,
pub ok: Option<String>,
pub danger: Option<String>,
pub shadow_color: Option<String>,
pub dim_unfocused: Option<bool>,
}
/// 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<Theme> {
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<String>, 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<Color> {
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::<u8>() {
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);
}
}

View File

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

19
themes/dark.toml Normal file
View File

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

17
themes/light.toml Normal file
View File

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