Introduce central theme module; rounded borders and consistent styling

All colors/styles now live in src/theme.rs (palette + pane/dialog
block builders): sidebar, file view, bottom bar, context menu and all
dialogs share one look that can be changed in one place. Rounded
borders throughout.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-15 09:39:39 +02:00
parent fde2087274
commit 5d15cde675
3 changed files with 114 additions and 67 deletions

View File

@@ -1,6 +1,7 @@
mod app; mod app;
mod config; mod config;
mod file_ops; mod file_ops;
mod theme;
mod ui; mod ui;
mod events; mod events;

95
src/theme.rs Normal file
View File

@@ -0,0 +1,95 @@
//! Centralt tema för TUI-FM.
//!
//! ALLA färger och återkommande stilar definieras här — ändra utseendet
//! på hela appen från en enda fil. UI-koden ska aldrig hårdkoda
//! `Color::`-literaler för sådant som temat täcker.
//!
//! OBS: använd bara tecken med entydig bredd i UI:t — se
//! `file_ops::tests::emoji_icons_are_wide`.
use ratatui::style::{Color, Modifier, Style};
use ratatui::widgets::{Block, BorderType, Borders};
// ─── Palett ──────────────────────────────────────────────────────────────
/// Fokusmarkering och markerade rader
pub const ACCENT: Color = Color::Cyan;
/// Rubriker och interaktiva knappar
pub const HEADER: Color = Color::Yellow;
/// Navigering ("Gå upp" m.m.)
pub const NAV: Color = Color::Magenta;
/// Positiv handling (skapa, lägg till)
pub const OK: Color = Color::Green;
/// Destruktiv handling (ta bort, stäng)
pub const DANGER: Color = Color::Red;
/// Nedtonad text: hints, avgränsare, ofokuserade ramar
pub const DIM: Color = Color::DarkGray;
/// Normal text
pub const TEXT: Color = Color::White;
// ─── Stilar ──────────────────────────────────────────────────────────────
pub fn header() -> Style {
Style::default().fg(HEADER).add_modifier(Modifier::BOLD)
}
pub fn nav() -> Style {
Style::default().fg(NAV).add_modifier(Modifier::BOLD)
}
pub fn hint() -> Style {
Style::default().fg(DIM)
}
pub fn button() -> Style {
Style::default().fg(OK).add_modifier(Modifier::BOLD)
}
pub fn selected_row() -> Style {
Style::default()
.fg(Color::Black)
.bg(ACCENT)
.add_modifier(Modifier::BOLD)
}
pub fn current_item() -> Style {
Style::default().fg(ACCENT).add_modifier(Modifier::BOLD)
}
pub fn text() -> Style {
Style::default().fg(TEXT)
}
// ─── Block/ramar ─────────────────────────────────────────────────────────
/// Standardram för en yta: rundade hörn, accentfärg vid fokus.
pub fn pane_block(title: &str, focused: bool) -> Block<'static> {
let border = if focused {
Style::default().fg(ACCENT)
} else {
Style::default().fg(DIM)
};
let block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(border);
if title.is_empty() {
block
} else {
block
.title(format!(" {} ", title))
.title_style(Style::default().fg(TEXT).add_modifier(Modifier::BOLD))
}
}
/// Ram för dialoger/popups: rundade hörn, färgad ram + rubrik.
pub fn dialog_block_colored(title: &str, color: Color) -> Block<'static> {
Block::default()
.title(format!(" {} ", title))
.title_style(Style::default().fg(color).add_modifier(Modifier::BOLD))
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(color))
}
/// Standarddialog: accentfärgad.
pub fn dialog_block(title: &str) -> Block<'static> {
dialog_block_colored(title, ACCENT)
}

View File

@@ -12,6 +12,7 @@ use ratatui::{
use unicode_width::UnicodeWidthStr; use unicode_width::UnicodeWidthStr;
use crate::app::{App, ContextMenuItem, DialogMode, Focus}; use crate::app::{App, ContextMenuItem, DialogMode, Focus};
use crate::theme;
use crate::file_ops::{self, format_size}; use crate::file_ops::{self, format_size};
/// Compute the display width of a string, accounting for emojis (2 columns) and /// Compute the display width of a string, accounting for emojis (2 columns) and
@@ -124,24 +125,15 @@ pub fn draw(f: &mut Frame, app: &mut App) {
fn draw_sidebar(f: &mut Frame, app: &App, area: Rect) { fn draw_sidebar(f: &mut Frame, app: &App, area: Rect) {
let focused = app.focus == Focus::Sidebar; let focused = app.focus == Focus::Sidebar;
let border_style = if focused { let block = theme::pane_block("Navigering", focused);
Style::default().fg(Color::Cyan)
} else {
Style::default().fg(Color::DarkGray)
};
let block = Block::default()
.title(" Navigering ")
.borders(Borders::ALL)
.border_style(border_style);
let inner = block.inner(area); let inner = block.inner(area);
f.render_widget(block, area); f.render_widget(block, area);
let header_s = Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD); let header_s = theme::header();
let go_up_s = Style::default().fg(Color::Magenta).add_modifier(Modifier::BOLD); let go_up_s = theme::nav();
let hint_s = Style::default().fg(Color::DarkGray); let hint_s = theme::hint();
let btn_s = Style::default().fg(Color::Green).add_modifier(Modifier::BOLD); let btn_s = theme::button();
let sep_str: String = if app.unicode_support { let sep_str: String = if app.unicode_support {
"\u{2500}".repeat(inner.width as usize) "\u{2500}".repeat(inner.width as usize)
@@ -187,11 +179,7 @@ fn draw_sidebar(f: &mut Frame, app: &App, area: Rect) {
.map(|n| n.to_string_lossy().to_string()) .map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| fav.clone()); .unwrap_or_else(|| fav.clone());
let is_current = fav == &app.current_path.to_string_lossy().to_string(); let is_current = fav == &app.current_path.to_string_lossy().to_string();
let style = if is_current { let style = if is_current { theme::current_item() } else { theme::text() };
Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::White)
};
// ★ U+2605 is East-Asian-Width Narrow → 1 display column, safe for alignment // ★ U+2605 is East-Asian-Width Narrow → 1 display column, safe for alignment
let star = if app.unicode_support { "\u{2605}" } else { "*" }; let star = if app.unicode_support { "\u{2605}" } else { "*" };
let icon_w = display_width(star); let icon_w = display_width(star);
@@ -286,11 +274,6 @@ fn draw_sidebar(f: &mut Frame, app: &App, area: Rect) {
fn draw_file_view(f: &mut Frame, app: &App, area: Rect) { fn draw_file_view(f: &mut Frame, app: &App, area: Rect) {
let focused = app.focus == Focus::FileView; let focused = app.focus == Focus::FileView;
let border_style = if focused {
Style::default().fg(Color::Cyan)
} else {
Style::default().fg(Color::DarkGray)
};
let rows: Vec<Row> = app let rows: Vec<Row> = app
.entries .entries
@@ -327,14 +310,11 @@ fn draw_file_view(f: &mut Frame, app: &App, area: Rect) {
Cell::from("Namn"), Cell::from("Namn"),
Cell::from("Storlek"), Cell::from("Storlek"),
]) ])
.style(Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD | Modifier::UNDERLINED)) .style(theme::header().add_modifier(Modifier::UNDERLINED))
.height(1); .height(1);
let path_title = format!(" {} ", app.current_path.to_string_lossy()); let path_title = app.current_path.to_string_lossy();
let block = Block::default() let block = theme::pane_block(&path_title, focused);
.title(path_title)
.borders(Borders::ALL)
.border_style(border_style);
let widths = [Constraint::Min(10), Constraint::Length(10)]; let widths = [Constraint::Min(10), Constraint::Length(10)];
@@ -386,13 +366,7 @@ fn draw_file_view(f: &mut Frame, app: &App, area: Rect) {
fn draw_bottom_bar(f: &mut Frame, app: &App, area: Rect) { fn draw_bottom_bar(f: &mut Frame, app: &App, area: Rect) {
let focused = app.focus == Focus::PathInput; let focused = app.focus == Focus::PathInput;
let outer_block = Block::default() let outer_block = theme::pane_block("", focused);
.borders(Borders::ALL)
.border_style(if focused {
Style::default().fg(Color::Cyan)
} else {
Style::default().fg(Color::DarkGray)
});
let inner = outer_block.inner(area); let inner = outer_block.inner(area);
f.render_widget(outer_block, area); f.render_widget(outer_block, area);
@@ -544,10 +518,7 @@ fn draw_context_menu(f: &mut Frame, app: &App, menu: crate::app::ContextMenu) {
let menu_area = Rect::new(x, y, menu_width, menu_height); let menu_area = Rect::new(x, y, menu_width, menu_height);
f.render_widget(Clear, menu_area); f.render_widget(Clear, menu_area);
let block = Block::default() let block = theme::dialog_block("Meny");
.title(" Meny ")
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Cyan));
let inner = block.inner(menu_area); let inner = block.inner(menu_area);
f.render_widget(block, menu_area); f.render_widget(block, menu_area);
@@ -625,9 +596,7 @@ fn draw_context_menu(f: &mut Frame, app: &App, menu: crate::app::ContextMenu) {
let sub_x = (x + menu_width).min(term_area.width.saturating_sub(sub_w)); let sub_x = (x + menu_width).min(term_area.width.saturating_sub(sub_w));
let sub_area = Rect::new(sub_x, new_sub_row, sub_w, 4); let sub_area = Rect::new(sub_x, new_sub_row, sub_w, 4);
f.render_widget(Clear, sub_area); f.render_widget(Clear, sub_area);
let sub_block = Block::default() let sub_block = theme::dialog_block_colored("", theme::OK);
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Green));
let sub_inner = sub_block.inner(sub_area); let sub_inner = sub_block.inner(sub_area);
f.render_widget(sub_block, sub_area); f.render_widget(sub_block, sub_area);
@@ -678,11 +647,7 @@ fn draw_settings(f: &mut Frame, app: &App, right_edge: u16) {
} else { } else {
" [S] Inställningar " " [S] Inställningar "
}; };
let block = Block::default() let block = theme::dialog_block_colored(title.trim(), theme::HEADER);
.title(title)
.title_style(Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD))
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Yellow));
let inner = block.inner(area); let inner = block.inner(area);
f.render_widget(block, area); f.render_widget(block, area);
@@ -903,10 +868,7 @@ fn draw_input_dialog(f: &mut Frame, title: &str, current: &str, area: Rect) {
let dlg = Rect::new(x, y, dlg_width, dlg_height); let dlg = Rect::new(x, y, dlg_width, dlg_height);
f.render_widget(Clear, dlg); f.render_widget(Clear, dlg);
let block = Block::default() let block = theme::dialog_block(title);
.title(format!(" {} ", title))
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Cyan));
let inner = block.inner(dlg); let inner = block.inner(dlg);
f.render_widget(block, dlg); f.render_widget(block, dlg);
@@ -944,10 +906,7 @@ fn draw_properties(f: &mut Frame, info: crate::app::PropertiesInfo, area: Rect)
let dlg = Rect::new(x, y, dlg_width, dlg_height); let dlg = Rect::new(x, y, dlg_width, dlg_height);
f.render_widget(Clear, dlg); f.render_widget(Clear, dlg);
let block = Block::default() let block = theme::dialog_block("Egenskaper");
.title(" Egenskaper ")
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Cyan));
let inner = block.inner(dlg); let inner = block.inner(dlg);
f.render_widget(block, dlg); f.render_widget(block, dlg);
@@ -1005,11 +964,7 @@ fn draw_error_dialog(f: &mut Frame, msg: &str, area: Rect) {
let dlg = Rect::new(x, y, dlg_width, dlg_height); let dlg = Rect::new(x, y, dlg_width, dlg_height);
f.render_widget(Clear, dlg); f.render_widget(Clear, dlg);
let block = Block::default() let block = theme::dialog_block_colored("Fel", theme::DANGER);
.title(" Fel ")
.title_style(Style::default().fg(Color::Red).add_modifier(Modifier::BOLD))
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Red));
let inner = block.inner(dlg); let inner = block.inner(dlg);
f.render_widget(block, dlg); f.render_widget(block, dlg);
@@ -1044,11 +999,7 @@ fn draw_confirm_dialog(f: &mut Frame, msg: &str, area: Rect) {
let dlg = Rect::new(x, y, dlg_width, dlg_height); let dlg = Rect::new(x, y, dlg_width, dlg_height);
f.render_widget(Clear, dlg); f.render_widget(Clear, dlg);
let block = Block::default() let block = theme::dialog_block_colored("Bekräfta", theme::HEADER);
.title(" Bekräfta ")
.title_style(Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD))
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Yellow));
let inner = block.inner(dlg); let inner = block.inner(dlg);
f.render_widget(block, dlg); f.render_widget(block, dlg);