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
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:
68
.gitea/workflows/release.yaml
Normal file
68
.gitea/workflows/release.yaml
Normal 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
9
.vscode/tasks.json
vendored
@@ -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
1294
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -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.
48
src/app.rs
48
src/app.rs
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
107
src/events.rs
107
src/events.rs
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
95
src/theme.rs
Normal 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
69
src/tui_wm.rs
Normal 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
115
src/ui.rs
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user