Merge modernize: ratatui 0.30, nested-terminal fixes, safe icons, theme module, TUI-WM open integration
All checks were successful
release / build-release (push) Successful in 2m15s

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-16 00:24:38 +02:00
13 changed files with 1606 additions and 287 deletions

View File

@@ -0,0 +1,68 @@
name: release
# På varje push till master/main: kör tester, bygg release-binärer för
# linux arm64 (nativt på Pi5-runnern) + linux x64 (cross) och lägg dem
# på en rullande "latest"-release på släppsidan i Gitea.
on:
push:
branches: [master, main]
workflow_dispatch: {}
jobs:
build-release:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Installera Rust + cross-toolchain
run: |
set -e
sudo apt-get update -qq
sudo apt-get install -y -qq gcc-x86-64-linux-gnu jq curl
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \
| sh -s -- -y --profile minimal --default-toolchain stable
. "$HOME/.cargo/env"
rustup target add x86_64-unknown-linux-gnu
- name: Testa + bygg (arm64 nativt + x64 cross)
run: |
set -e
. "$HOME/.cargo/env"
# Snäll mot Pi5:n — låg prio och max 2 parallella jobb
export CARGO_BUILD_JOBS=2
nice -n 10 cargo test --release
nice -n 10 cargo build --release
export CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_LINKER=x86_64-linux-gnu-gcc
nice -n 10 cargo build --release --target x86_64-unknown-linux-gnu
mkdir -p build
cp target/release/tui-fm build/tui-fm-linux-arm64
cp target/x86_64-unknown-linux-gnu/release/tui-fm build/tui-fm-linux-x64
(cd build && sha256sum tui-fm-linux-* > checksums.txt && ls -la)
- name: Skapa/uppdatera latest-release + ladda upp binärer
env:
API: http://gitea-d:3000/api/v1
REPO: ${{ github.repository }}
TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -e
TAG="latest"
BODY="Rullande bygge från senaste master. Commit: ${{ github.sha }}. Arkitekturer: linux x64 + arm64 (Pi5)."
rid=$(curl -s -X POST "$API/repos/$REPO/releases" \
-H "Authorization: token $TOKEN" -H 'Content-Type: application/json' \
-d "{\"tag_name\":\"$TAG\",\"name\":\"$TAG\",\"body\":\"$BODY\"}" | jq -r '.id // empty')
[ -z "$rid" ] && rid=$(curl -s "$API/repos/$REPO/releases/tags/$TAG" \
-H "Authorization: token $TOKEN" | jq -r '.id')
echo "release id: $rid"
for f in tui-fm-linux-x64 tui-fm-linux-arm64 checksums.txt; do
aid=$(curl -s "$API/repos/$REPO/releases/$rid/assets" \
-H "Authorization: token $TOKEN" | jq -r ".[] | select(.name==\"$f\") | .id")
if [ -n "$aid" ] && [ "$aid" != "null" ]; then
curl -s -X DELETE "$API/repos/$REPO/releases/$rid/assets/$aid" \
-H "Authorization: token $TOKEN" -o /dev/null
fi
curl -s -X POST "$API/repos/$REPO/releases/$rid/assets?name=$f" \
-H "Authorization: token $TOKEN" \
-F "attachment=@build/$f" -o /dev/null -w " $f -> HTTP %{http_code}\n"
done

9
.vscode/tasks.json vendored
View File

@@ -1,6 +1,15 @@
{
"version": "2.0.0",
"tasks": [
{
"label": "Build alla (Linux + Windows)",
"type": "shell",
"command": "cargo build --release && cargo build --release --target x86_64-pc-windows-gnu && mkdir -p build/linux build/windows && cp target/release/tui-fm build/linux/tui-fm && cp target/x86_64-pc-windows-gnu/release/tui-fm.exe build/windows/tui-fm.exe && echo 'Done: build/linux/tui-fm + build/windows/tui-fm.exe'",
"group": "build",
"presentation": { "reveal": "always", "panel": "shared" },
"problemMatcher": "$rustc",
"detail": "Bygger båda binärerna: Linux (native) + Windows (kräver mingw-w64)"
},
{
"label": "Build - Linux",
"type": "shell",

1294
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -12,8 +12,8 @@ opt-level = 3
strip = true
[dependencies]
ratatui = "0.29"
crossterm = { version = "0.28", features = ["event-stream"] }
ratatui = "0.30"
crossterm = { version = "0.29", features = ["event-stream"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
dirs = "5"

Binary file not shown.

View File

@@ -1,6 +1,6 @@
use std::path::PathBuf;
use crate::config::{AppliesTo, Config, CustomMenuItem};
use crate::config::{AppliesTo, Config};
use crate::file_ops::{self, FileEntry};
#[derive(Debug, Clone, PartialEq)]
@@ -24,6 +24,8 @@ pub struct ClipboardEntry {
#[derive(Debug, Clone, PartialEq)]
pub enum ContextMenuItem {
/// Öppna filen (via TUI-WM om möjligt) eller gå in i katalogen
Open,
Copy,
Cut,
Paste,
@@ -107,6 +109,14 @@ pub struct App {
pub settings_hover_button: Option<u8>,
// Terminal capabilities
pub unicode_support: bool,
/// Senast kända terminalstorlek (kolumner, rader). Uppdateras från
/// frame-arean vid varje draw och från Event::Resize — pollar aldrig
/// terminalen, vilket gör hit-testing korrekt även i nästlade
/// terminaler (t.ex. inne i TUI-WM).
pub term_size: (u16, u16),
/// Editor-kommando + fil som ska köras genom att TUI:t tillfälligt
/// suspenderas (fallback när TUI-WM inte kör). Hanteras i run_app.
pub pending_spawn: Option<(String, PathBuf)>,
}
// Which field inside the settings editor is being edited
@@ -181,6 +191,32 @@ impl App {
settings_custom_selected: 0,
settings_hover_button: None,
unicode_support: detect_unicode_support(),
term_size: crossterm::terminal::size().unwrap_or((80, 24)),
pending_spawn: None,
}
}
/// Öppna en fil: via TUI-WM:s socket om vi kör i ett TUI-WM-fönster,
/// annars med den konfigurerade editorn (TUI:t suspenderas under tiden).
pub fn open_path(&mut self, path: &PathBuf) {
if path.is_dir() {
self.navigate_to(path.clone());
return;
}
if crate::tui_wm::available() {
match crate::tui_wm::open_file(path) {
Ok(()) => self.set_status(format!("Öppnar {} via TUI-WM", path.display())),
Err(e) => self.set_status(format!("TUI-WM-öppning misslyckades: {}", e)),
}
return;
}
let editor = self.config.text_editor.trim().to_string();
if editor.is_empty() {
self.set_status(
"Ingen editor konfigurerad (kugghjulet ≡) och TUI-WM kör inte".to_string(),
);
} else {
self.pending_spawn = Some((editor, path.clone()));
}
}
@@ -423,7 +459,9 @@ impl App {
pub fn build_context_menu(&self, x: u16, y: u16, is_sidebar_fav: bool, fav_path: Option<String>) -> ContextMenu {
let has_selection = !self.selected_indices.is_empty();
let has_clipboard = self.clipboard.is_some();
let can_paste = has_clipboard && (self.selected_target_dir().is_some() || true);
// Inklistring går till markerad katalog om en sådan finns, annars
// aktuell katalog — så clipboard-innehåll räcker.
let can_paste = has_clipboard;
// Determine if selected items are files/dirs for custom item filtering
let selected_is_dir = self.selected_indices.iter().all(|&i| {
@@ -441,6 +479,7 @@ impl App {
}
} else {
if has_selection {
items.push(ContextMenuItem::Open);
items.push(ContextMenuItem::Copy);
items.push(ContextMenuItem::Cut);
}
@@ -486,11 +525,10 @@ impl App {
pub fn selected_target_dir(&self) -> Option<PathBuf> {
if self.selected_indices.len() == 1 {
let i = self.selected_indices[0];
if let Some(e) = self.entries.get(i) {
if e.is_dir() {
if let Some(e) = self.entries.get(i)
&& e.is_dir() {
return Some(e.path.clone());
}
}
}
Some(self.current_path.clone())
}

View File

@@ -2,9 +2,11 @@ use serde::{Deserialize, Serialize};
use std::path::PathBuf;
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
#[derive(Default)]
pub enum AppliesTo {
File,
Dir,
#[default]
Both,
}
@@ -25,9 +27,6 @@ impl AppliesTo {
}
}
impl Default for AppliesTo {
fn default() -> Self { AppliesTo::Both }
}
#[derive(Serialize, Deserialize, Clone, Default, Debug)]
pub struct CustomMenuItem {
@@ -49,22 +48,19 @@ impl Config {
/// Preferred path: config.json next to the running binary.
/// Falls back to ~/.config/tui-fm/config.json if the exe path can't be determined.
fn config_path() -> Option<PathBuf> {
if let Ok(exe) = std::env::current_exe() {
if let Some(dir) = exe.parent() {
if let Ok(exe) = std::env::current_exe()
&& let Some(dir) = exe.parent() {
return Some(dir.join("config.json"));
}
}
dirs::config_dir().map(|d| d.join("tui-fm").join("config.json"))
}
pub fn load() -> Self {
if let Some(path) = Self::config_path() {
if let Ok(data) = std::fs::read_to_string(&path) {
if let Ok(config) = serde_json::from_str::<Config>(&data) {
if let Some(path) = Self::config_path()
&& let Ok(data) = std::fs::read_to_string(&path)
&& let Ok(config) = serde_json::from_str::<Config>(&data) {
return config;
}
}
}
Config::default()
}

View File

@@ -10,8 +10,32 @@ use crate::app::{App, ConfirmAction, ContextMenuItem, DialogMode, Focus, Setting
use crate::config::{AppliesTo, CustomMenuItem};
use crate::ui;
pub fn run_app<B: Backend>(terminal: &mut Terminal<B>, app: &mut App) -> io::Result<()> {
pub fn run_app<B: Backend<Error = io::Error>>(terminal: &mut Terminal<B>, app: &mut App) -> io::Result<()> {
loop {
// Fallback-öppning utanför TUI-WM: suspendera TUI:t, kör editorn
// på samma terminal, återuppta och rita om.
if let Some((editor, path)) = app.pending_spawn.take() {
use crossterm::event::{DisableMouseCapture, EnableMouseCapture};
use crossterm::terminal::{
disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
};
disable_raw_mode()?;
crossterm::execute!(io::stdout(), LeaveAlternateScreen, DisableMouseCapture)?;
let status = std::process::Command::new("sh")
.arg("-c")
.arg(format!("{} '{}'", editor, path.display().to_string().replace('\'', r"'\''")))
.status();
enable_raw_mode()?;
crossterm::execute!(io::stdout(), EnterAlternateScreen, EnableMouseCapture)?;
terminal.clear()?;
match status {
Ok(s) if s.success() => {}
Ok(s) => app.set_status(format!("Editorn avslutades med {}", s)),
Err(e) => app.set_status(format!("Kunde inte starta editorn: {}", e)),
}
app.refresh();
}
terminal.draw(|f| ui::draw(f, app))?;
if event::poll(Duration::from_millis(50))? {
@@ -32,6 +56,10 @@ fn handle_event(app: &mut App, ev: Event) -> bool {
handle_mouse(app, mouse);
false
}
Event::Resize(w, h) => {
app.term_size = (w, h);
false
}
_ => false,
}
}
@@ -42,9 +70,8 @@ fn handle_key(app: &mut App, key: crossterm::event::KeyEvent) -> bool {
match key.code {
KeyCode::Esc => { app.context_menu = None; return false; }
KeyCode::Up => {
if let Some(ref mut m) = app.context_menu {
if m.selected > 0 { m.selected -= 1; }
}
if let Some(ref mut m) = app.context_menu
&& m.selected > 0 { m.selected -= 1; }
return false;
}
KeyCode::Down => {
@@ -166,8 +193,8 @@ fn handle_key(app: &mut App, key: crossterm::event::KeyEvent) -> bool {
app.path_input_sel_anchor = None;
}
KeyCode::Backspace => {
if !delete_selection(app) {
if app.path_input_cursor > 0 {
if !delete_selection(app)
&& app.path_input_cursor > 0 {
let remove_pos = app.path_input_cursor - 1;
let new: String = app.path_input.chars().enumerate()
.filter(|&(i, _)| i != remove_pos)
@@ -176,19 +203,17 @@ fn handle_key(app: &mut App, key: crossterm::event::KeyEvent) -> bool {
app.path_input = new;
app.path_input_cursor -= 1;
}
}
app.path_input_sel_anchor = None;
}
KeyCode::Delete => {
if !delete_selection(app) {
if app.path_input_cursor < char_count {
if !delete_selection(app)
&& app.path_input_cursor < char_count {
let new: String = app.path_input.chars().enumerate()
.filter(|&(i, _)| i != app.path_input_cursor)
.map(|(_, c)| c)
.collect();
app.path_input = new;
}
}
app.path_input_sel_anchor = None;
}
KeyCode::Left => {
@@ -280,12 +305,11 @@ fn handle_key(app: &mut App, key: crossterm::event::KeyEvent) -> bool {
app.navigate_up();
}
KeyCode::F(2) => {
if let Some(&i) = app.selected_indices.first() {
if let Some(e) = app.entries.get(i) {
if let Some(&i) = app.selected_indices.first()
&& let Some(e) = app.entries.get(i) {
app.rename_original = Some(e.path.clone());
app.dialog = DialogMode::Rename(e.name.clone());
}
}
}
KeyCode::Delete => {
if !app.selected_indices.is_empty() {
@@ -294,15 +318,12 @@ fn handle_key(app: &mut App, key: crossterm::event::KeyEvent) -> bool {
}
}
KeyCode::Enter => {
if app.focus == Focus::FileView {
if let Some(&i) = app.selected_indices.first() {
if let Some(e) = app.entries.get(i).cloned() {
if e.is_dir() {
if app.focus == Focus::FileView
&& let Some(&i) = app.selected_indices.first()
&& let Some(e) = app.entries.get(i).cloned()
&& e.is_dir() {
app.navigate_to(e.path.clone());
}
}
}
}
}
KeyCode::Up => {
if app.focus == Focus::FileView {
@@ -384,6 +405,12 @@ fn execute_context_menu(app: &mut App) {
app.context_menu = None;
if let Some(item) = item {
match item {
ContextMenuItem::Open => {
if let Some(&i) = app.selected_indices.first()
&& let Some(e) = app.entries.get(i) {
app.open_path(&e.path.clone());
}
}
ContextMenuItem::Copy => app.copy_selected(),
ContextMenuItem::Cut => app.cut_selected(),
ContextMenuItem::Paste => {
@@ -391,12 +418,11 @@ fn execute_context_menu(app: &mut App) {
app.paste_into(&dst.clone());
}
ContextMenuItem::Rename => {
if let Some(&i) = app.selected_indices.first() {
if let Some(e) = app.entries.get(i) {
if let Some(&i) = app.selected_indices.first()
&& let Some(e) = app.entries.get(i) {
app.rename_original = Some(e.path.clone());
app.dialog = DialogMode::Rename(e.name.clone());
}
}
}
ContextMenuItem::Properties => app.compute_properties(),
ContextMenuItem::NewFile => app.dialog = DialogMode::NewFile(String::new()),
@@ -744,7 +770,7 @@ fn handle_left_click(app: &mut App, col: u16, row: u16) {
// Check if click lands in the submenu (only visible when NewSubmenu is highlighted)
let ns_idx = m.items.iter().position(|i| matches!(i, ContextMenuItem::NewSubmenu));
let in_sub = if let Some(ns_pos) = ns_idx {
let (term_w, _) = crossterm::terminal::size().unwrap_or((120, 40));
let (term_w, _) = app.term_size;
let sub_x = (mx + MENU_W).min(term_w.saturating_sub(SUB_W));
let sub_y = my + 1 + ns_pos as u16;
col >= sub_x && col < sub_x + SUB_W
@@ -800,7 +826,7 @@ fn handle_left_click(app: &mut App, col: u16, row: u16) {
if row == gear_row && col >= gear_col && col < gear_col + 3 {
// If settings is already open and we click inside the dialog handled by settings_click
if app.dialog == DialogMode::Settings {
let dlg = ui::settings_dialog_rect(sb.x + sb.width);
let dlg = ui::settings_dialog_rect(sb.x + sb.width, app.term_size.1);
settings_click(app, col, row, dlg);
} else {
app.dialog = DialogMode::Settings;
@@ -810,7 +836,7 @@ fn handle_left_click(app: &mut App, col: u16, row: u16) {
// If settings dialog is open, route all clicks into it
if app.dialog == DialogMode::Settings {
let dlg = ui::settings_dialog_rect(sb.x + sb.width);
let dlg = ui::settings_dialog_rect(sb.x + sb.width, app.term_size.1);
settings_click(app, col, row, dlg);
return;
}
@@ -834,7 +860,7 @@ fn handle_left_click(app: &mut App, col: u16, row: u16) {
let char_count = app.path_input.chars().count();
let input_w = inner_w.saturating_sub(hint_width + 1 + 6);
let avail = input_w.saturating_sub(label_display_w) as usize;
let scroll_start = if app.path_input_cursor > avail { app.path_input_cursor - avail } else { 0 };
let scroll_start = app.path_input_cursor.saturating_sub(avail);
let new_cursor = (scroll_start + click_offset).min(char_count);
app.path_input_cursor = new_cursor;
}
@@ -880,9 +906,8 @@ fn handle_left_click(app: &mut App, col: u16, row: u16) {
if is_double {
app.last_click_entry = None;
if let Some(e) = app.entries.get(idx).cloned() {
if e.is_dir() {
app.navigate_to(e.path.clone());
}
// Kataloger navigeras; filer öppnas via TUI-WM/editor
app.open_path(&e.path.clone());
}
} else {
app.select_only(idx);
@@ -895,22 +920,20 @@ fn handle_right_click(app: &mut App, col: u16, row: u16) {
// Right-click on sidebar favorite
if col < layout.sidebar.x + layout.sidebar.width {
if let Some(item) = hit_test_sidebar(app, col, row, &layout) {
if let SidebarItem::Favorite(_, path) = item {
if let Some(item) = hit_test_sidebar(app, col, row, &layout)
&& let SidebarItem::Favorite(_, path) = item {
let menu = app.build_context_menu(col, row, true, Some(path));
app.context_menu = Some(menu);
return;
}
}
return;
}
// Right-click on file
if let Some(idx) = hit_test_file_view(app, col, row, &layout) {
if !app.selected_indices.contains(&idx) {
if let Some(idx) = hit_test_file_view(app, col, row, &layout)
&& !app.selected_indices.contains(&idx) {
app.select_only(idx);
}
}
let menu = app.build_context_menu(col, row, false, None);
app.context_menu = Some(menu);
@@ -920,7 +943,7 @@ fn handle_mouse_move(app: &mut App, col: u16, row: u16) {
// Settings hover: highlight rows when mouse moves over the dialog
if app.dialog == DialogMode::Settings {
let layout = ui::compute_layout(app);
let dlg = ui::settings_dialog_rect(layout.status_bar.x + layout.status_bar.width);
let dlg = ui::settings_dialog_rect(layout.status_bar.x + layout.status_bar.width, app.term_size.1);
if col > dlg.x && col < dlg.x + dlg.width.saturating_sub(1)
&& row > dlg.y && row < dlg.y + dlg.height.saturating_sub(1)
{
@@ -981,9 +1004,9 @@ fn handle_mouse_move(app: &mut App, col: u16, row: u16) {
}
// When NewSubmenu is highlighted, check if the mouse moves into the flyout
if let Some(ns_pos) = menu.items.iter().position(|i| matches!(i, ContextMenuItem::NewSubmenu)) {
if menu.selected == ns_pos {
let (term_w, _) = crossterm::terminal::size().unwrap_or((120, 40));
if let Some(ns_pos) = menu.items.iter().position(|i| matches!(i, ContextMenuItem::NewSubmenu))
&& menu.selected == ns_pos {
let (term_w, _) = app.term_size;
let sub_x = (mx + MENU_W).min(term_w.saturating_sub(SUB_W));
let sub_y = my + 1 + ns_pos as u16;
@@ -994,10 +1017,8 @@ fn handle_mouse_move(app: &mut App, col: u16, row: u16) {
if row > sub_y && row < sub_y + 3 {
menu.sub_selected = Some((row - sub_y - 1) as usize);
}
return;
}
}
}
}
/// Handle mouse drag for text selection in the path input bar.
@@ -1028,7 +1049,7 @@ fn handle_mouse_drag(app: &mut App, col: u16, row: u16) {
let click_offset = (col - text_start_x) as usize;
let char_count = app.path_input.chars().count();
let avail = inner_w.saturating_sub(hint_width + 1 + 6).saturating_sub(label_display_w) as usize;
let scroll_start = if app.path_input_cursor > avail { app.path_input_cursor - avail } else { 0 };
let scroll_start = app.path_input_cursor.saturating_sub(avail);
app.path_input_cursor = (scroll_start + click_offset).min(char_count);
}
}

View File

@@ -30,19 +30,24 @@ impl FileEntry {
.unwrap_or("")
.to_lowercase();
match ext.as_str() {
// OBS: alla emoji-ikoner MÅSTE vara East-Asian-Width Wide
// (mäts som 2 kolumner av unicode-width). Tecken med
// EAW=Neutral/Ambiguous (t.ex. 🖼 U+1F5BC, ⚙ U+2699)
// mäts som 1 men ritas 2 breda av emoji-fonter →
// raden förskjuts åt höger. Se icon_widths-testet.
"rs" => "🦀",
"png" | "jpg" | "jpeg" | "gif" | "bmp" | "svg" | "webp" | "ico" => "🖼",
"png" | "jpg" | "jpeg" | "gif" | "bmp" | "svg" | "webp" | "ico" => "📷",
"mp4" | "mkv" | "avi" | "mov" | "webm" => "🎬",
"mp3" | "flac" | "ogg" | "wav" | "aac" => "🎵",
"zip" | "tar" | "gz" | "bz2" | "xz" | "7z" | "rar" => "📦",
"pdf" => "📕",
"txt" | "md" | "log" => "📄",
"json" | "yaml" | "yml" | "toml" | "xml" => "",
"json" | "yaml" | "yml" | "toml" | "xml" => "🔩",
"sh" | "bash" | "zsh" | "fish" => "🐚",
"py" => "🐍",
"js" | "ts" => "📜",
"c" | "cpp" | "h" | "hpp" => "🔧",
"exe" | "bin" | "out" | "run" => "",
"exe" | "bin" | "out" | "run" => "",
_ => "📎",
}
}
@@ -294,3 +299,56 @@ pub fn get_root_dirs() -> Vec<PathBuf> {
drives
}
}
#[cfg(test)]
mod tests {
use super::*;
use unicode_width::UnicodeWidthStr;
fn entry(name: &str, ft: FileType) -> FileEntry {
FileEntry {
name: name.to_string(),
path: PathBuf::from(name),
file_type: ft,
size: 0,
is_hidden: false,
}
}
/// Alla emoji-ikoner måste vara East-Asian-Width Wide (mäts som
/// 2 kolumner av unicode-width). En ikon med EAW=Neutral/Ambiguous
/// (t.ex. 🖼 U+1F5BC eller ⚙ U+2699) mäts som 1 men ritas 2 bred av
/// emoji-fonter — då förskjuts allt efter ikonen åt höger i UI:t.
#[test]
fn emoji_icons_are_wide() {
let mut samples: Vec<FileEntry> = vec![
entry("dir", FileType::Directory),
entry("link", FileType::Symlink),
];
for ext in [
"rs", "png", "mp4", "mp3", "zip", "pdf", "txt", "json", "sh",
"py", "js", "c", "exe", "unknown",
] {
samples.push(entry(&format!("f.{}", ext), FileType::File));
}
for e in &samples {
let icon = e.icon();
assert_eq!(
UnicodeWidthStr::width(icon), 2,
"ikonen {:?} (för {:?}) är inte EAW Wide — den kommer \
förskjuta raden åt höger i terminaler med emoji-font",
icon, e.name
);
}
}
/// ASCII-fallback-ikonerna ska alltid vara exakt 3 tecken ([/],
/// [s], ...) så att kolumnerna ligger fast i ASCII-läge.
#[test]
fn ascii_icons_are_three_cols() {
let e = entry("f.rs", FileType::File);
assert_eq!(UnicodeWidthStr::width(e.icon_ascii()), 3);
let d = entry("dir", FileType::Directory);
assert_eq!(UnicodeWidthStr::width(d.icon_ascii()), 3);
}
}

View File

@@ -1,6 +1,8 @@
mod app;
mod config;
mod file_ops;
mod theme;
mod tui_wm;
mod ui;
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)
}

69
src/tui_wm.rs Normal file
View File

@@ -0,0 +1,69 @@
//! Integration med TUI-WM:s socket-API.
//!
//! När TUI-FM körs i ett TUI-WM-fönster sätts miljövariabeln
//! `TUI_WM_SOCKET`. Via den kan filer öppnas i egna fönster med
//! användarens filassociationer/standard-editor (TUI-WM visar en
//! "Öppna med"-dialog för okända format).
use std::path::Path;
/// Kör TUI-FM inne i TUI-WM?
pub fn available() -> bool {
std::env::var("TUI_WM_SOCKET").is_ok()
}
/// Be TUI-WM öppna en fil. Blockerar bara för handskakningen.
pub fn open_file(path: &Path) -> Result<(), String> {
let sock = std::env::var("TUI_WM_SOCKET").map_err(|_| "TUI_WM_SOCKET saknas".to_string())?;
open_via_socket(&sock, path)
}
#[cfg(unix)]
fn open_via_socket(sock: &str, path: &Path) -> Result<(), String> {
use std::io::{Read, Write};
use std::os::unix::net::UnixStream;
let mut stream = UnixStream::connect(sock).map_err(|e| format!("anslutning: {}", e))?;
stream
.set_read_timeout(Some(std::time::Duration::from_secs(2)))
.ok();
let write_msg = |s: &mut UnixStream, v: serde_json::Value| -> Result<(), String> {
let data = serde_json::to_vec(&v).map_err(|e| e.to_string())?;
s.write_all(&(data.len() as u32).to_be_bytes())
.and_then(|_| s.write_all(&data))
.map_err(|e| e.to_string())
};
let read_msg = |s: &mut UnixStream| -> Result<serde_json::Value, String> {
let mut len = [0u8; 4];
s.read_exact(&mut len).map_err(|e| e.to_string())?;
let mut buf = vec![0u8; u32::from_be_bytes(len) as usize];
s.read_exact(&mut buf).map_err(|e| e.to_string())?;
serde_json::from_slice(&buf).map_err(|e| e.to_string())
};
write_msg(
&mut stream,
serde_json::json!({"type": "hello", "role": "app", "width": 0, "height": 0}),
)?;
let hello = read_msg(&mut stream)?;
if hello.get("type").and_then(|t| t.as_str()) != Some("hello_ok") {
return Err("oväntat svar från TUI-WM".to_string());
}
write_msg(
&mut stream,
serde_json::json!({
"type": "open_file",
"path": path.to_string_lossy(),
"request_id": "tui-fm-open",
}),
)?;
// Vänta in kvittensen så meddelandet hinner behandlas
let _ = read_msg(&mut stream);
Ok(())
}
#[cfg(not(unix))]
fn open_via_socket(_sock: &str, _path: &Path) -> Result<(), String> {
Err("socket-öppning stöds inte på denna plattform ännu".to_string())
}

115
src/ui.rs
View File

@@ -12,6 +12,7 @@ use ratatui::{
use unicode_width::UnicodeWidthStr;
use crate::app::{App, ContextMenuItem, DialogMode, Focus};
use crate::theme;
use crate::file_ops::{self, format_size};
/// Compute the display width of a string, accounting for emojis (2 columns) and
@@ -56,9 +57,11 @@ pub struct LayoutAreas {
pub path_input: Rect,
}
/// Compute the layout areas for hit testing (without a frame)
pub fn compute_layout(_app: &App) -> LayoutAreas {
let (width, height) = crossterm::terminal::size().unwrap_or((120, 40));
/// Compute the layout areas for hit testing (without a frame).
/// Uses the last size seen by draw() instead of querying the terminal,
/// so it stays correct inside nested terminals.
pub fn compute_layout(app: &App) -> LayoutAreas {
let (width, height) = app.term_size;
let full = Rect::new(0, 0, width, height);
split_layout(full)
}
@@ -97,6 +100,7 @@ fn split_layout(area: Rect) -> LayoutAreas {
pub fn draw(f: &mut Frame, app: &mut App) {
let area = f.area();
app.term_size = (area.width, area.height);
let layout = split_layout(area);
draw_sidebar(f, app, layout.sidebar);
@@ -121,24 +125,15 @@ pub fn draw(f: &mut Frame, app: &mut App) {
fn draw_sidebar(f: &mut Frame, app: &App, area: Rect) {
let focused = app.focus == Focus::Sidebar;
let border_style = if 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 block = theme::pane_block("Navigering", focused);
let inner = block.inner(area);
f.render_widget(block, area);
let header_s = Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD);
let go_up_s = Style::default().fg(Color::Magenta).add_modifier(Modifier::BOLD);
let hint_s = Style::default().fg(Color::DarkGray);
let btn_s = Style::default().fg(Color::Green).add_modifier(Modifier::BOLD);
let header_s = theme::header();
let go_up_s = theme::nav();
let hint_s = theme::hint();
let btn_s = theme::button();
let sep_str: String = if app.unicode_support {
"\u{2500}".repeat(inner.width as usize)
@@ -153,9 +148,12 @@ fn draw_sidebar(f: &mut Frame, app: &App, area: Rect) {
// ── Go-up button ──────────────────────────────────────────────────────────
if app.unicode_support {
// ↑ U+2191 är ett rent textglyf (ingen emoji-presentation) →
// ritas alltid 1 kolumn brett, till skillnad från ⬆ U+2B06 som
// emoji-fonter ritar 2 brett fast unicode-width mäter 1.
lines.push(Line::from(vec![
Span::raw(" "),
Span::styled("", go_up_s),
Span::styled("", go_up_s),
Span::styled(" Gå upp", go_up_s),
]));
} else {
@@ -181,11 +179,7 @@ fn draw_sidebar(f: &mut Frame, app: &App, area: Rect) {
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| fav.clone());
let is_current = fav == &app.current_path.to_string_lossy().to_string();
let style = if is_current {
Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::White)
};
let style = if is_current { theme::current_item() } else { theme::text() };
// ★ U+2605 is East-Asian-Width Narrow → 1 display column, safe for alignment
let star = if app.unicode_support { "\u{2605}" } else { "*" };
let icon_w = display_width(star);
@@ -280,11 +274,6 @@ fn draw_sidebar(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 border_style = if focused {
Style::default().fg(Color::Cyan)
} else {
Style::default().fg(Color::DarkGray)
};
let rows: Vec<Row> = app
.entries
@@ -321,14 +310,11 @@ fn draw_file_view(f: &mut Frame, app: &App, area: Rect) {
Cell::from("Namn"),
Cell::from("Storlek"),
])
.style(Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD | Modifier::UNDERLINED))
.style(theme::header().add_modifier(Modifier::UNDERLINED))
.height(1);
let path_title = format!(" {} ", app.current_path.to_string_lossy());
let block = Block::default()
.title(path_title)
.borders(Borders::ALL)
.border_style(border_style);
let path_title = app.current_path.to_string_lossy();
let block = theme::pane_block(&path_title, focused);
let widths = [Constraint::Min(10), Constraint::Length(10)];
@@ -380,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) {
let focused = app.focus == Focus::PathInput;
let outer_block = Block::default()
.borders(Borders::ALL)
.border_style(if focused {
Style::default().fg(Color::Cyan)
} else {
Style::default().fg(Color::DarkGray)
});
let outer_block = theme::pane_block("", focused);
let inner = outer_block.inner(area);
f.render_widget(outer_block, area);
@@ -441,7 +421,7 @@ fn draw_bottom_bar(f: &mut Frame, app: &App, area: Rect) {
];
if focused {
let scroll_start = if cursor_pos > avail { cursor_pos - avail } else { 0 };
let scroll_start = cursor_pos.saturating_sub(avail);
if let Some((sel_start, sel_end)) = sel_range {
// Render with selection highlight
@@ -493,8 +473,11 @@ fn draw_bottom_bar(f: &mut Frame, app: &App, area: Rect) {
// Gear button at bottom-right
let gear_x = inner.x + inner.width.saturating_sub(5);
// ≡ U+2261 är ett textglyf som alla monospace-fonter ritar 1 brett;
// ⚙ U+2699 (EAW=Neutral) ritas 2 brett av emoji-fonter och förskjuter
// raden åt höger.
let (gear_label, gear_w) = if app.unicode_support {
(" \u{2699} ", 4u16)
(" \u{2261} ", 4u16)
} else {
(" [S] ", 5u16)
};
@@ -514,7 +497,7 @@ fn scroll_input_text(text: &str, cursor: usize, avail: usize) -> (String, usize)
if char_count <= avail {
return (text.to_string(), cursor);
}
let start = if cursor > avail { cursor - avail } else { 0 };
let start = cursor.saturating_sub(avail);
let end = (start + avail).min(char_count);
let slice: String = text.chars().skip(start).take(end - start).collect();
let cursor_in_display = cursor.saturating_sub(start);
@@ -535,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);
f.render_widget(Clear, menu_area);
let block = Block::default()
.title(" Meny ")
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Cyan));
let block = theme::dialog_block("Meny");
let inner = block.inner(menu_area);
f.render_widget(block, menu_area);
@@ -553,6 +533,8 @@ fn draw_context_menu(f: &mut Frame, app: &App, menu: crate::app::ContextMenu) {
// Use 2-char ASCII icon codes — emoji (U+1F000+) are 2-wide in terminals and
// cause ghost characters and column drift when partially overwritten.
let (icon, label, item_style): (&str, &str, Style) = match item {
ContextMenuItem::Open =>
("O ", "Öppna", Style::default().add_modifier(Modifier::BOLD)),
ContextMenuItem::Copy =>
("C ", "Kopiera", Style::default()),
ContextMenuItem::Cut =>
@@ -616,9 +598,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_area = Rect::new(sub_x, new_sub_row, sub_w, 4);
f.render_widget(Clear, sub_area);
let sub_block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Green));
let sub_block = theme::dialog_block_colored("", theme::OK);
let sub_inner = sub_block.inner(sub_area);
f.render_widget(sub_block, sub_area);
@@ -644,17 +624,16 @@ fn draw_context_menu(f: &mut Frame, app: &App, menu: crate::app::ContextMenu) {
}
/// Compute the settings dialog rect anchored to the bottom-right corner.
pub fn settings_dialog_rect(right_edge: u16) -> ratatui::layout::Rect {
pub fn settings_dialog_rect(right_edge: u16, term_h: u16) -> ratatui::layout::Rect {
let dlg_width: u16 = 72;
let dlg_height: u16 = 26;
let x = right_edge.saturating_sub(dlg_width + 1);
let (_, term_h) = crossterm::terminal::size().unwrap_or((120, 40));
let y = term_h.saturating_sub(dlg_height + 3);
Rect::new(x, y, dlg_width, dlg_height)
}
fn draw_settings(f: &mut Frame, app: &App, right_edge: u16) {
let area = settings_dialog_rect(right_edge);
let area = settings_dialog_rect(right_edge, f.area().height);
let term_area = f.area();
let area = Rect::new(
area.x.min(term_area.width.saturating_sub(area.width)),
@@ -670,11 +649,7 @@ fn draw_settings(f: &mut Frame, app: &App, right_edge: u16) {
} else {
" [S] Inställningar "
};
let block = Block::default()
.title(title)
.title_style(Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD))
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Yellow));
let block = theme::dialog_block_colored(title.trim(), theme::HEADER);
let inner = block.inner(area);
f.render_widget(block, area);
@@ -895,10 +870,7 @@ fn draw_input_dialog(f: &mut Frame, title: &str, current: &str, area: Rect) {
let dlg = Rect::new(x, y, dlg_width, dlg_height);
f.render_widget(Clear, dlg);
let block = Block::default()
.title(format!(" {} ", title))
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Cyan));
let block = theme::dialog_block(title);
let inner = block.inner(dlg);
f.render_widget(block, dlg);
@@ -936,10 +908,7 @@ fn draw_properties(f: &mut Frame, info: crate::app::PropertiesInfo, area: Rect)
let dlg = Rect::new(x, y, dlg_width, dlg_height);
f.render_widget(Clear, dlg);
let block = Block::default()
.title(" Egenskaper ")
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Cyan));
let block = theme::dialog_block("Egenskaper");
let inner = block.inner(dlg);
f.render_widget(block, dlg);
@@ -997,11 +966,7 @@ fn draw_error_dialog(f: &mut Frame, msg: &str, area: Rect) {
let dlg = Rect::new(x, y, dlg_width, dlg_height);
f.render_widget(Clear, dlg);
let block = Block::default()
.title(" Fel ")
.title_style(Style::default().fg(Color::Red).add_modifier(Modifier::BOLD))
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Red));
let block = theme::dialog_block_colored("Fel", theme::DANGER);
let inner = block.inner(dlg);
f.render_widget(block, dlg);
@@ -1036,11 +1001,7 @@ fn draw_confirm_dialog(f: &mut Frame, msg: &str, area: Rect) {
let dlg = Rect::new(x, y, dlg_width, dlg_height);
f.render_widget(Clear, dlg);
let block = Block::default()
.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 block = theme::dialog_block_colored("Bekräfta", theme::HEADER);
let inner = block.inner(dlg);
f.render_widget(block, dlg);