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:
11
src/app.rs
11
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<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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
162
src/render.rs
162
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<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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
287
src/theme.rs
287
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<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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
19
themes/dark.toml
Normal 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
17
themes/light.toml
Normal 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
|
||||
Reference in New Issue
Block a user