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",
|
"version": "2.0.0",
|
||||||
"tasks": [
|
"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",
|
"label": "Build - Linux",
|
||||||
"type": "shell",
|
"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
|
strip = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
ratatui = "0.29"
|
ratatui = "0.30"
|
||||||
crossterm = { version = "0.28", features = ["event-stream"] }
|
crossterm = { version = "0.29", features = ["event-stream"] }
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
dirs = "5"
|
dirs = "5"
|
||||||
|
|||||||
Binary file not shown.
48
src/app.rs
48
src/app.rs
@@ -1,6 +1,6 @@
|
|||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
use crate::config::{AppliesTo, Config, CustomMenuItem};
|
use crate::config::{AppliesTo, Config};
|
||||||
use crate::file_ops::{self, FileEntry};
|
use crate::file_ops::{self, FileEntry};
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq)]
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
@@ -24,6 +24,8 @@ pub struct ClipboardEntry {
|
|||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq)]
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
pub enum ContextMenuItem {
|
pub enum ContextMenuItem {
|
||||||
|
/// Öppna filen (via TUI-WM om möjligt) eller gå in i katalogen
|
||||||
|
Open,
|
||||||
Copy,
|
Copy,
|
||||||
Cut,
|
Cut,
|
||||||
Paste,
|
Paste,
|
||||||
@@ -107,6 +109,14 @@ pub struct App {
|
|||||||
pub settings_hover_button: Option<u8>,
|
pub settings_hover_button: Option<u8>,
|
||||||
// Terminal capabilities
|
// Terminal capabilities
|
||||||
pub unicode_support: bool,
|
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
|
// Which field inside the settings editor is being edited
|
||||||
@@ -181,6 +191,32 @@ impl App {
|
|||||||
settings_custom_selected: 0,
|
settings_custom_selected: 0,
|
||||||
settings_hover_button: None,
|
settings_hover_button: None,
|
||||||
unicode_support: detect_unicode_support(),
|
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 {
|
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_selection = !self.selected_indices.is_empty();
|
||||||
let has_clipboard = self.clipboard.is_some();
|
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
|
// Determine if selected items are files/dirs for custom item filtering
|
||||||
let selected_is_dir = self.selected_indices.iter().all(|&i| {
|
let selected_is_dir = self.selected_indices.iter().all(|&i| {
|
||||||
@@ -441,6 +479,7 @@ impl App {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if has_selection {
|
if has_selection {
|
||||||
|
items.push(ContextMenuItem::Open);
|
||||||
items.push(ContextMenuItem::Copy);
|
items.push(ContextMenuItem::Copy);
|
||||||
items.push(ContextMenuItem::Cut);
|
items.push(ContextMenuItem::Cut);
|
||||||
}
|
}
|
||||||
@@ -486,12 +525,11 @@ impl App {
|
|||||||
pub fn selected_target_dir(&self) -> Option<PathBuf> {
|
pub fn selected_target_dir(&self) -> Option<PathBuf> {
|
||||||
if self.selected_indices.len() == 1 {
|
if self.selected_indices.len() == 1 {
|
||||||
let i = self.selected_indices[0];
|
let i = self.selected_indices[0];
|
||||||
if let Some(e) = self.entries.get(i) {
|
if let Some(e) = self.entries.get(i)
|
||||||
if e.is_dir() {
|
&& e.is_dir() {
|
||||||
return Some(e.path.clone());
|
return Some(e.path.clone());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
Some(self.current_path.clone())
|
Some(self.current_path.clone())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,9 +2,11 @@ use serde::{Deserialize, Serialize};
|
|||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
|
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
|
||||||
|
#[derive(Default)]
|
||||||
pub enum AppliesTo {
|
pub enum AppliesTo {
|
||||||
File,
|
File,
|
||||||
Dir,
|
Dir,
|
||||||
|
#[default]
|
||||||
Both,
|
Both,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -25,9 +27,6 @@ impl AppliesTo {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for AppliesTo {
|
|
||||||
fn default() -> Self { AppliesTo::Both }
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Clone, Default, Debug)]
|
#[derive(Serialize, Deserialize, Clone, Default, Debug)]
|
||||||
pub struct CustomMenuItem {
|
pub struct CustomMenuItem {
|
||||||
@@ -49,22 +48,19 @@ impl Config {
|
|||||||
/// Preferred path: config.json next to the running binary.
|
/// 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.
|
/// Falls back to ~/.config/tui-fm/config.json if the exe path can't be determined.
|
||||||
fn config_path() -> Option<PathBuf> {
|
fn config_path() -> Option<PathBuf> {
|
||||||
if let Ok(exe) = std::env::current_exe() {
|
if let Ok(exe) = std::env::current_exe()
|
||||||
if let Some(dir) = exe.parent() {
|
&& let Some(dir) = exe.parent() {
|
||||||
return Some(dir.join("config.json"));
|
return Some(dir.join("config.json"));
|
||||||
}
|
}
|
||||||
}
|
|
||||||
dirs::config_dir().map(|d| d.join("tui-fm").join("config.json"))
|
dirs::config_dir().map(|d| d.join("tui-fm").join("config.json"))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn load() -> Self {
|
pub fn load() -> Self {
|
||||||
if let Some(path) = Self::config_path() {
|
if let Some(path) = Self::config_path()
|
||||||
if let Ok(data) = std::fs::read_to_string(&path) {
|
&& let Ok(data) = std::fs::read_to_string(&path)
|
||||||
if let Ok(config) = serde_json::from_str::<Config>(&data) {
|
&& let Ok(config) = serde_json::from_str::<Config>(&data) {
|
||||||
return config;
|
return config;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
Config::default()
|
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::config::{AppliesTo, CustomMenuItem};
|
||||||
use crate::ui;
|
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 {
|
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))?;
|
terminal.draw(|f| ui::draw(f, app))?;
|
||||||
|
|
||||||
if event::poll(Duration::from_millis(50))? {
|
if event::poll(Duration::from_millis(50))? {
|
||||||
@@ -32,6 +56,10 @@ fn handle_event(app: &mut App, ev: Event) -> bool {
|
|||||||
handle_mouse(app, mouse);
|
handle_mouse(app, mouse);
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
Event::Resize(w, h) => {
|
||||||
|
app.term_size = (w, h);
|
||||||
|
false
|
||||||
|
}
|
||||||
_ => false,
|
_ => false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -42,9 +70,8 @@ fn handle_key(app: &mut App, key: crossterm::event::KeyEvent) -> bool {
|
|||||||
match key.code {
|
match key.code {
|
||||||
KeyCode::Esc => { app.context_menu = None; return false; }
|
KeyCode::Esc => { app.context_menu = None; return false; }
|
||||||
KeyCode::Up => {
|
KeyCode::Up => {
|
||||||
if let Some(ref mut m) = app.context_menu {
|
if let Some(ref mut m) = app.context_menu
|
||||||
if m.selected > 0 { m.selected -= 1; }
|
&& m.selected > 0 { m.selected -= 1; }
|
||||||
}
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
KeyCode::Down => {
|
KeyCode::Down => {
|
||||||
@@ -166,8 +193,8 @@ fn handle_key(app: &mut App, key: crossterm::event::KeyEvent) -> bool {
|
|||||||
app.path_input_sel_anchor = None;
|
app.path_input_sel_anchor = None;
|
||||||
}
|
}
|
||||||
KeyCode::Backspace => {
|
KeyCode::Backspace => {
|
||||||
if !delete_selection(app) {
|
if !delete_selection(app)
|
||||||
if app.path_input_cursor > 0 {
|
&& app.path_input_cursor > 0 {
|
||||||
let remove_pos = app.path_input_cursor - 1;
|
let remove_pos = app.path_input_cursor - 1;
|
||||||
let new: String = app.path_input.chars().enumerate()
|
let new: String = app.path_input.chars().enumerate()
|
||||||
.filter(|&(i, _)| i != remove_pos)
|
.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 = new;
|
||||||
app.path_input_cursor -= 1;
|
app.path_input_cursor -= 1;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
app.path_input_sel_anchor = None;
|
app.path_input_sel_anchor = None;
|
||||||
}
|
}
|
||||||
KeyCode::Delete => {
|
KeyCode::Delete => {
|
||||||
if !delete_selection(app) {
|
if !delete_selection(app)
|
||||||
if app.path_input_cursor < char_count {
|
&& app.path_input_cursor < char_count {
|
||||||
let new: String = app.path_input.chars().enumerate()
|
let new: String = app.path_input.chars().enumerate()
|
||||||
.filter(|&(i, _)| i != app.path_input_cursor)
|
.filter(|&(i, _)| i != app.path_input_cursor)
|
||||||
.map(|(_, c)| c)
|
.map(|(_, c)| c)
|
||||||
.collect();
|
.collect();
|
||||||
app.path_input = new;
|
app.path_input = new;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
app.path_input_sel_anchor = None;
|
app.path_input_sel_anchor = None;
|
||||||
}
|
}
|
||||||
KeyCode::Left => {
|
KeyCode::Left => {
|
||||||
@@ -280,13 +305,12 @@ fn handle_key(app: &mut App, key: crossterm::event::KeyEvent) -> bool {
|
|||||||
app.navigate_up();
|
app.navigate_up();
|
||||||
}
|
}
|
||||||
KeyCode::F(2) => {
|
KeyCode::F(2) => {
|
||||||
if let Some(&i) = app.selected_indices.first() {
|
if let Some(&i) = app.selected_indices.first()
|
||||||
if let Some(e) = app.entries.get(i) {
|
&& let Some(e) = app.entries.get(i) {
|
||||||
app.rename_original = Some(e.path.clone());
|
app.rename_original = Some(e.path.clone());
|
||||||
app.dialog = DialogMode::Rename(e.name.clone());
|
app.dialog = DialogMode::Rename(e.name.clone());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
KeyCode::Delete => {
|
KeyCode::Delete => {
|
||||||
if !app.selected_indices.is_empty() {
|
if !app.selected_indices.is_empty() {
|
||||||
let msg = format!("Delete {} item(s)? (y/n)", app.selected_indices.len());
|
let msg = format!("Delete {} item(s)? (y/n)", app.selected_indices.len());
|
||||||
@@ -294,16 +318,13 @@ fn handle_key(app: &mut App, key: crossterm::event::KeyEvent) -> bool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
KeyCode::Enter => {
|
KeyCode::Enter => {
|
||||||
if app.focus == Focus::FileView {
|
if app.focus == Focus::FileView
|
||||||
if let Some(&i) = app.selected_indices.first() {
|
&& let Some(&i) = app.selected_indices.first()
|
||||||
if let Some(e) = app.entries.get(i).cloned() {
|
&& let Some(e) = app.entries.get(i).cloned()
|
||||||
if e.is_dir() {
|
&& e.is_dir() {
|
||||||
app.navigate_to(e.path.clone());
|
app.navigate_to(e.path.clone());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
KeyCode::Up => {
|
KeyCode::Up => {
|
||||||
if app.focus == Focus::FileView {
|
if app.focus == Focus::FileView {
|
||||||
scroll_selection(app, -1);
|
scroll_selection(app, -1);
|
||||||
@@ -384,6 +405,12 @@ fn execute_context_menu(app: &mut App) {
|
|||||||
app.context_menu = None;
|
app.context_menu = None;
|
||||||
if let Some(item) = item {
|
if let Some(item) = item {
|
||||||
match 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::Copy => app.copy_selected(),
|
||||||
ContextMenuItem::Cut => app.cut_selected(),
|
ContextMenuItem::Cut => app.cut_selected(),
|
||||||
ContextMenuItem::Paste => {
|
ContextMenuItem::Paste => {
|
||||||
@@ -391,13 +418,12 @@ fn execute_context_menu(app: &mut App) {
|
|||||||
app.paste_into(&dst.clone());
|
app.paste_into(&dst.clone());
|
||||||
}
|
}
|
||||||
ContextMenuItem::Rename => {
|
ContextMenuItem::Rename => {
|
||||||
if let Some(&i) = app.selected_indices.first() {
|
if let Some(&i) = app.selected_indices.first()
|
||||||
if let Some(e) = app.entries.get(i) {
|
&& let Some(e) = app.entries.get(i) {
|
||||||
app.rename_original = Some(e.path.clone());
|
app.rename_original = Some(e.path.clone());
|
||||||
app.dialog = DialogMode::Rename(e.name.clone());
|
app.dialog = DialogMode::Rename(e.name.clone());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
ContextMenuItem::Properties => app.compute_properties(),
|
ContextMenuItem::Properties => app.compute_properties(),
|
||||||
ContextMenuItem::NewFile => app.dialog = DialogMode::NewFile(String::new()),
|
ContextMenuItem::NewFile => app.dialog = DialogMode::NewFile(String::new()),
|
||||||
ContextMenuItem::NewDir => app.dialog = DialogMode::NewDir(String::new()),
|
ContextMenuItem::NewDir => app.dialog = DialogMode::NewDir(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)
|
// 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 ns_idx = m.items.iter().position(|i| matches!(i, ContextMenuItem::NewSubmenu));
|
||||||
let in_sub = if let Some(ns_pos) = ns_idx {
|
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_x = (mx + MENU_W).min(term_w.saturating_sub(SUB_W));
|
||||||
let sub_y = my + 1 + ns_pos as u16;
|
let sub_y = my + 1 + ns_pos as u16;
|
||||||
col >= sub_x && col < sub_x + SUB_W
|
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 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 settings is already open and we click inside the dialog – handled by settings_click
|
||||||
if app.dialog == DialogMode::Settings {
|
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);
|
settings_click(app, col, row, dlg);
|
||||||
} else {
|
} else {
|
||||||
app.dialog = DialogMode::Settings;
|
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 settings dialog is open, route all clicks into it
|
||||||
if app.dialog == DialogMode::Settings {
|
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);
|
settings_click(app, col, row, dlg);
|
||||||
return;
|
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 char_count = app.path_input.chars().count();
|
||||||
let input_w = inner_w.saturating_sub(hint_width + 1 + 6);
|
let input_w = inner_w.saturating_sub(hint_width + 1 + 6);
|
||||||
let avail = input_w.saturating_sub(label_display_w) as usize;
|
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);
|
let new_cursor = (scroll_start + click_offset).min(char_count);
|
||||||
app.path_input_cursor = new_cursor;
|
app.path_input_cursor = new_cursor;
|
||||||
}
|
}
|
||||||
@@ -880,9 +906,8 @@ fn handle_left_click(app: &mut App, col: u16, row: u16) {
|
|||||||
if is_double {
|
if is_double {
|
||||||
app.last_click_entry = None;
|
app.last_click_entry = None;
|
||||||
if let Some(e) = app.entries.get(idx).cloned() {
|
if let Some(e) = app.entries.get(idx).cloned() {
|
||||||
if e.is_dir() {
|
// Kataloger navigeras; filer öppnas via TUI-WM/editor
|
||||||
app.navigate_to(e.path.clone());
|
app.open_path(&e.path.clone());
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
app.select_only(idx);
|
app.select_only(idx);
|
||||||
@@ -895,22 +920,20 @@ fn handle_right_click(app: &mut App, col: u16, row: u16) {
|
|||||||
|
|
||||||
// Right-click on sidebar favorite
|
// Right-click on sidebar favorite
|
||||||
if col < layout.sidebar.x + layout.sidebar.width {
|
if col < layout.sidebar.x + layout.sidebar.width {
|
||||||
if let Some(item) = hit_test_sidebar(app, col, row, &layout) {
|
if let Some(item) = hit_test_sidebar(app, col, row, &layout)
|
||||||
if let SidebarItem::Favorite(_, path) = item {
|
&& let SidebarItem::Favorite(_, path) = item {
|
||||||
let menu = app.build_context_menu(col, row, true, Some(path));
|
let menu = app.build_context_menu(col, row, true, Some(path));
|
||||||
app.context_menu = Some(menu);
|
app.context_menu = Some(menu);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Right-click on file
|
// Right-click on file
|
||||||
if let Some(idx) = hit_test_file_view(app, col, row, &layout) {
|
if let Some(idx) = hit_test_file_view(app, col, row, &layout)
|
||||||
if !app.selected_indices.contains(&idx) {
|
&& !app.selected_indices.contains(&idx) {
|
||||||
app.select_only(idx);
|
app.select_only(idx);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
let menu = app.build_context_menu(col, row, false, None);
|
let menu = app.build_context_menu(col, row, false, None);
|
||||||
app.context_menu = Some(menu);
|
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
|
// Settings hover: highlight rows when mouse moves over the dialog
|
||||||
if app.dialog == DialogMode::Settings {
|
if app.dialog == DialogMode::Settings {
|
||||||
let layout = ui::compute_layout(app);
|
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)
|
if col > dlg.x && col < dlg.x + dlg.width.saturating_sub(1)
|
||||||
&& row > dlg.y && row < dlg.y + dlg.height.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
|
// 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 let Some(ns_pos) = menu.items.iter().position(|i| matches!(i, ContextMenuItem::NewSubmenu))
|
||||||
if menu.selected == ns_pos {
|
&& menu.selected == ns_pos {
|
||||||
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_x = (mx + MENU_W).min(term_w.saturating_sub(SUB_W));
|
||||||
let sub_y = my + 1 + ns_pos as u16;
|
let sub_y = my + 1 + ns_pos as u16;
|
||||||
|
|
||||||
@@ -994,8 +1017,6 @@ fn handle_mouse_move(app: &mut App, col: u16, row: u16) {
|
|||||||
if row > sub_y && row < sub_y + 3 {
|
if row > sub_y && row < sub_y + 3 {
|
||||||
menu.sub_selected = Some((row - sub_y - 1) as usize);
|
menu.sub_selected = Some((row - sub_y - 1) as usize);
|
||||||
}
|
}
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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 click_offset = (col - text_start_x) as usize;
|
||||||
let char_count = app.path_input.chars().count();
|
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 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);
|
app.path_input_cursor = (scroll_start + click_offset).min(char_count);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,19 +30,24 @@ impl FileEntry {
|
|||||||
.unwrap_or("")
|
.unwrap_or("")
|
||||||
.to_lowercase();
|
.to_lowercase();
|
||||||
match ext.as_str() {
|
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" => "🦀",
|
"rs" => "🦀",
|
||||||
"png" | "jpg" | "jpeg" | "gif" | "bmp" | "svg" | "webp" | "ico" => "🖼",
|
"png" | "jpg" | "jpeg" | "gif" | "bmp" | "svg" | "webp" | "ico" => "📷",
|
||||||
"mp4" | "mkv" | "avi" | "mov" | "webm" => "🎬",
|
"mp4" | "mkv" | "avi" | "mov" | "webm" => "🎬",
|
||||||
"mp3" | "flac" | "ogg" | "wav" | "aac" => "🎵",
|
"mp3" | "flac" | "ogg" | "wav" | "aac" => "🎵",
|
||||||
"zip" | "tar" | "gz" | "bz2" | "xz" | "7z" | "rar" => "📦",
|
"zip" | "tar" | "gz" | "bz2" | "xz" | "7z" | "rar" => "📦",
|
||||||
"pdf" => "📕",
|
"pdf" => "📕",
|
||||||
"txt" | "md" | "log" => "📄",
|
"txt" | "md" | "log" => "📄",
|
||||||
"json" | "yaml" | "yml" | "toml" | "xml" => "⚙",
|
"json" | "yaml" | "yml" | "toml" | "xml" => "🔩",
|
||||||
"sh" | "bash" | "zsh" | "fish" => "🐚",
|
"sh" | "bash" | "zsh" | "fish" => "🐚",
|
||||||
"py" => "🐍",
|
"py" => "🐍",
|
||||||
"js" | "ts" => "📜",
|
"js" | "ts" => "📜",
|
||||||
"c" | "cpp" | "h" | "hpp" => "🔧",
|
"c" | "cpp" | "h" | "hpp" => "🔧",
|
||||||
"exe" | "bin" | "out" | "run" => "⚙",
|
"exe" | "bin" | "out" | "run" => "⚡",
|
||||||
_ => "📎",
|
_ => "📎",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -294,3 +299,56 @@ pub fn get_root_dirs() -> Vec<PathBuf> {
|
|||||||
drives
|
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 app;
|
||||||
mod config;
|
mod config;
|
||||||
mod file_ops;
|
mod file_ops;
|
||||||
|
mod theme;
|
||||||
|
mod tui_wm;
|
||||||
mod ui;
|
mod ui;
|
||||||
mod events;
|
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 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
|
||||||
@@ -56,9 +57,11 @@ pub struct LayoutAreas {
|
|||||||
pub path_input: Rect,
|
pub path_input: Rect,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Compute the layout areas for hit testing (without a frame)
|
/// Compute the layout areas for hit testing (without a frame).
|
||||||
pub fn compute_layout(_app: &App) -> LayoutAreas {
|
/// Uses the last size seen by draw() instead of querying the terminal,
|
||||||
let (width, height) = crossterm::terminal::size().unwrap_or((120, 40));
|
/// 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);
|
let full = Rect::new(0, 0, width, height);
|
||||||
split_layout(full)
|
split_layout(full)
|
||||||
}
|
}
|
||||||
@@ -97,6 +100,7 @@ fn split_layout(area: Rect) -> LayoutAreas {
|
|||||||
|
|
||||||
pub fn draw(f: &mut Frame, app: &mut App) {
|
pub fn draw(f: &mut Frame, app: &mut App) {
|
||||||
let area = f.area();
|
let area = f.area();
|
||||||
|
app.term_size = (area.width, area.height);
|
||||||
let layout = split_layout(area);
|
let layout = split_layout(area);
|
||||||
|
|
||||||
draw_sidebar(f, app, layout.sidebar);
|
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) {
|
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)
|
||||||
@@ -153,9 +148,12 @@ fn draw_sidebar(f: &mut Frame, app: &App, area: Rect) {
|
|||||||
|
|
||||||
// ── Go-up button ──────────────────────────────────────────────────────────
|
// ── Go-up button ──────────────────────────────────────────────────────────
|
||||||
if app.unicode_support {
|
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![
|
lines.push(Line::from(vec![
|
||||||
Span::raw(" "),
|
Span::raw(" "),
|
||||||
Span::styled("⬆", go_up_s),
|
Span::styled("↑", go_up_s),
|
||||||
Span::styled(" Gå upp", go_up_s),
|
Span::styled(" Gå upp", go_up_s),
|
||||||
]));
|
]));
|
||||||
} else {
|
} else {
|
||||||
@@ -181,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);
|
||||||
@@ -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) {
|
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
|
||||||
@@ -321,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)];
|
||||||
|
|
||||||
@@ -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) {
|
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);
|
||||||
@@ -441,7 +421,7 @@ fn draw_bottom_bar(f: &mut Frame, app: &App, area: Rect) {
|
|||||||
];
|
];
|
||||||
|
|
||||||
if focused {
|
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 {
|
if let Some((sel_start, sel_end)) = sel_range {
|
||||||
// Render with selection highlight
|
// Render with selection highlight
|
||||||
@@ -493,8 +473,11 @@ fn draw_bottom_bar(f: &mut Frame, app: &App, area: Rect) {
|
|||||||
|
|
||||||
// Gear button at bottom-right
|
// Gear button at bottom-right
|
||||||
let gear_x = inner.x + inner.width.saturating_sub(5);
|
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 {
|
let (gear_label, gear_w) = if app.unicode_support {
|
||||||
(" \u{2699} ", 4u16)
|
(" \u{2261} ", 4u16)
|
||||||
} else {
|
} else {
|
||||||
(" [S] ", 5u16)
|
(" [S] ", 5u16)
|
||||||
};
|
};
|
||||||
@@ -514,7 +497,7 @@ fn scroll_input_text(text: &str, cursor: usize, avail: usize) -> (String, usize)
|
|||||||
if char_count <= avail {
|
if char_count <= avail {
|
||||||
return (text.to_string(), cursor);
|
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 end = (start + avail).min(char_count);
|
||||||
let slice: String = text.chars().skip(start).take(end - start).collect();
|
let slice: String = text.chars().skip(start).take(end - start).collect();
|
||||||
let cursor_in_display = cursor.saturating_sub(start);
|
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);
|
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);
|
||||||
@@ -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
|
// Use 2-char ASCII icon codes — emoji (U+1F000+) are 2-wide in terminals and
|
||||||
// cause ghost characters and column drift when partially overwritten.
|
// cause ghost characters and column drift when partially overwritten.
|
||||||
let (icon, label, item_style): (&str, &str, Style) = match item {
|
let (icon, label, item_style): (&str, &str, Style) = match item {
|
||||||
|
ContextMenuItem::Open =>
|
||||||
|
("O ", "Öppna", Style::default().add_modifier(Modifier::BOLD)),
|
||||||
ContextMenuItem::Copy =>
|
ContextMenuItem::Copy =>
|
||||||
("C ", "Kopiera", Style::default()),
|
("C ", "Kopiera", Style::default()),
|
||||||
ContextMenuItem::Cut =>
|
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_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);
|
||||||
|
|
||||||
@@ -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.
|
/// 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_width: u16 = 72;
|
||||||
let dlg_height: u16 = 26;
|
let dlg_height: u16 = 26;
|
||||||
let x = right_edge.saturating_sub(dlg_width + 1);
|
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);
|
let y = term_h.saturating_sub(dlg_height + 3);
|
||||||
Rect::new(x, y, dlg_width, dlg_height)
|
Rect::new(x, y, dlg_width, dlg_height)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn draw_settings(f: &mut Frame, app: &App, right_edge: u16) {
|
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 term_area = f.area();
|
||||||
let area = Rect::new(
|
let area = Rect::new(
|
||||||
area.x.min(term_area.width.saturating_sub(area.width)),
|
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 {
|
} 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);
|
||||||
|
|
||||||
@@ -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);
|
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);
|
||||||
|
|
||||||
@@ -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);
|
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);
|
||||||
|
|
||||||
@@ -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);
|
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);
|
||||||
|
|
||||||
@@ -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);
|
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);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user