Add file operations and UI components for file management application
This commit is contained in:
5
.gitignore
vendored
5
.gitignore
vendored
@@ -16,3 +16,8 @@ target/
|
|||||||
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
||||||
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||||
#.idea/
|
#.idea/
|
||||||
|
|
||||||
|
|
||||||
|
# Added by cargo
|
||||||
|
|
||||||
|
/target
|
||||||
|
|||||||
36
.vscode/tasks.json
vendored
Normal file
36
.vscode/tasks.json
vendored
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
{
|
||||||
|
"version": "2.0.0",
|
||||||
|
"tasks": [
|
||||||
|
{
|
||||||
|
"label": "Build - Linux",
|
||||||
|
"type": "shell",
|
||||||
|
"command": "cargo build --release && mkdir -p build/linux && cp target/release/tui-fm build/linux/tui-fm",
|
||||||
|
"group": {
|
||||||
|
"kind": "build",
|
||||||
|
"isDefault": true
|
||||||
|
},
|
||||||
|
"presentation": {
|
||||||
|
"echo": true,
|
||||||
|
"reveal": "always",
|
||||||
|
"focus": false,
|
||||||
|
"panel": "shared"
|
||||||
|
},
|
||||||
|
"problemMatcher": "$rustc",
|
||||||
|
"detail": "Bygger tui-fm för Linux och kopierar binären till build/linux/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Build - Windows",
|
||||||
|
"type": "shell",
|
||||||
|
"command": "cargo build --release --target x86_64-pc-windows-gnu && mkdir -p build/windows && cp target/x86_64-pc-windows-gnu/release/tui-fm.exe build/windows/tui-fm.exe",
|
||||||
|
"group": "build",
|
||||||
|
"presentation": {
|
||||||
|
"echo": true,
|
||||||
|
"reveal": "always",
|
||||||
|
"focus": false,
|
||||||
|
"panel": "shared"
|
||||||
|
},
|
||||||
|
"problemMatcher": "$rustc",
|
||||||
|
"detail": "Bygger tui-fm för Windows (kräver mingw-w64 och rustup target add x86_64-pc-windows-gnu)"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
811
Cargo.lock
generated
Normal file
811
Cargo.lock
generated
Normal file
@@ -0,0 +1,811 @@
|
|||||||
|
# This file is automatically @generated by Cargo.
|
||||||
|
# It is not intended for manual editing.
|
||||||
|
version = 4
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "allocator-api2"
|
||||||
|
version = "0.2.21"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bitflags"
|
||||||
|
version = "2.11.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cassowary"
|
||||||
|
version = "0.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "castaway"
|
||||||
|
version = "0.2.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a"
|
||||||
|
dependencies = [
|
||||||
|
"rustversion",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cfg-if"
|
||||||
|
version = "1.0.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "compact_str"
|
||||||
|
version = "0.8.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3b79c4069c6cad78e2e0cdfcbd26275770669fb39fd308a752dc110e83b9af32"
|
||||||
|
dependencies = [
|
||||||
|
"castaway",
|
||||||
|
"cfg-if",
|
||||||
|
"itoa",
|
||||||
|
"rustversion",
|
||||||
|
"ryu",
|
||||||
|
"static_assertions",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "crossterm"
|
||||||
|
version = "0.28.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags",
|
||||||
|
"crossterm_winapi",
|
||||||
|
"futures-core",
|
||||||
|
"mio",
|
||||||
|
"parking_lot",
|
||||||
|
"rustix",
|
||||||
|
"signal-hook",
|
||||||
|
"signal-hook-mio",
|
||||||
|
"winapi",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "crossterm_winapi"
|
||||||
|
version = "0.9.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b"
|
||||||
|
dependencies = [
|
||||||
|
"winapi",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "darling"
|
||||||
|
version = "0.23.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d"
|
||||||
|
dependencies = [
|
||||||
|
"darling_core",
|
||||||
|
"darling_macro",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "darling_core"
|
||||||
|
version = "0.23.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0"
|
||||||
|
dependencies = [
|
||||||
|
"ident_case",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"strsim",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "darling_macro"
|
||||||
|
version = "0.23.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d"
|
||||||
|
dependencies = [
|
||||||
|
"darling_core",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "dirs"
|
||||||
|
version = "5.0.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225"
|
||||||
|
dependencies = [
|
||||||
|
"dirs-sys",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "dirs-sys"
|
||||||
|
version = "0.4.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"option-ext",
|
||||||
|
"redox_users",
|
||||||
|
"windows-sys 0.48.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "either"
|
||||||
|
version = "1.15.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "equivalent"
|
||||||
|
version = "1.0.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "errno"
|
||||||
|
version = "0.3.14"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "foldhash"
|
||||||
|
version = "0.1.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fs_extra"
|
||||||
|
version = "1.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "futures-core"
|
||||||
|
version = "0.3.32"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "getrandom"
|
||||||
|
version = "0.2.17"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"libc",
|
||||||
|
"wasi",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hashbrown"
|
||||||
|
version = "0.15.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
|
||||||
|
dependencies = [
|
||||||
|
"allocator-api2",
|
||||||
|
"equivalent",
|
||||||
|
"foldhash",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "heck"
|
||||||
|
version = "0.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ident_case"
|
||||||
|
version = "1.0.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "indoc"
|
||||||
|
version = "2.0.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706"
|
||||||
|
dependencies = [
|
||||||
|
"rustversion",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "instability"
|
||||||
|
version = "0.3.12"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971"
|
||||||
|
dependencies = [
|
||||||
|
"darling",
|
||||||
|
"indoc",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "itertools"
|
||||||
|
version = "0.13.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186"
|
||||||
|
dependencies = [
|
||||||
|
"either",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "itoa"
|
||||||
|
version = "1.0.18"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "libc"
|
||||||
|
version = "0.2.183"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "libredox"
|
||||||
|
version = "0.1.15"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7ddbf48fd451246b1f8c2610bd3b4ac0cc6e149d89832867093ab69a17194f08"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "linux-raw-sys"
|
||||||
|
version = "0.4.15"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "lock_api"
|
||||||
|
version = "0.4.14"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
|
||||||
|
dependencies = [
|
||||||
|
"scopeguard",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "log"
|
||||||
|
version = "0.4.29"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "lru"
|
||||||
|
version = "0.12.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38"
|
||||||
|
dependencies = [
|
||||||
|
"hashbrown",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "memchr"
|
||||||
|
version = "2.8.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "mio"
|
||||||
|
version = "1.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"log",
|
||||||
|
"wasi",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "option-ext"
|
||||||
|
version = "0.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "parking_lot"
|
||||||
|
version = "0.12.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
|
||||||
|
dependencies = [
|
||||||
|
"lock_api",
|
||||||
|
"parking_lot_core",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "parking_lot_core"
|
||||||
|
version = "0.9.12"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"libc",
|
||||||
|
"redox_syscall",
|
||||||
|
"smallvec",
|
||||||
|
"windows-link",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "paste"
|
||||||
|
version = "1.0.15"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "proc-macro2"
|
||||||
|
version = "1.0.106"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
|
||||||
|
dependencies = [
|
||||||
|
"unicode-ident",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "quote"
|
||||||
|
version = "1.0.45"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ratatui"
|
||||||
|
version = "0.29.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags",
|
||||||
|
"cassowary",
|
||||||
|
"compact_str",
|
||||||
|
"crossterm",
|
||||||
|
"indoc",
|
||||||
|
"instability",
|
||||||
|
"itertools",
|
||||||
|
"lru",
|
||||||
|
"paste",
|
||||||
|
"strum",
|
||||||
|
"unicode-segmentation",
|
||||||
|
"unicode-truncate",
|
||||||
|
"unicode-width 0.2.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "redox_syscall"
|
||||||
|
version = "0.5.18"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "redox_users"
|
||||||
|
version = "0.4.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43"
|
||||||
|
dependencies = [
|
||||||
|
"getrandom",
|
||||||
|
"libredox",
|
||||||
|
"thiserror",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustix"
|
||||||
|
version = "0.38.44"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags",
|
||||||
|
"errno",
|
||||||
|
"libc",
|
||||||
|
"linux-raw-sys",
|
||||||
|
"windows-sys 0.59.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustversion"
|
||||||
|
version = "1.0.22"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ryu"
|
||||||
|
version = "1.0.23"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "scopeguard"
|
||||||
|
version = "1.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde"
|
||||||
|
version = "1.0.228"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
|
||||||
|
dependencies = [
|
||||||
|
"serde_core",
|
||||||
|
"serde_derive",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_core"
|
||||||
|
version = "1.0.228"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
|
||||||
|
dependencies = [
|
||||||
|
"serde_derive",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_derive"
|
||||||
|
version = "1.0.228"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_json"
|
||||||
|
version = "1.0.149"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
|
||||||
|
dependencies = [
|
||||||
|
"itoa",
|
||||||
|
"memchr",
|
||||||
|
"serde",
|
||||||
|
"serde_core",
|
||||||
|
"zmij",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "signal-hook"
|
||||||
|
version = "0.3.18"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"signal-hook-registry",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "signal-hook-mio"
|
||||||
|
version = "0.2.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"mio",
|
||||||
|
"signal-hook",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "signal-hook-registry"
|
||||||
|
version = "1.4.8"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
|
||||||
|
dependencies = [
|
||||||
|
"errno",
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "smallvec"
|
||||||
|
version = "1.15.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "static_assertions"
|
||||||
|
version = "1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "strsim"
|
||||||
|
version = "0.11.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "strum"
|
||||||
|
version = "0.26.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06"
|
||||||
|
dependencies = [
|
||||||
|
"strum_macros",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "strum_macros"
|
||||||
|
version = "0.26.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be"
|
||||||
|
dependencies = [
|
||||||
|
"heck",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"rustversion",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "syn"
|
||||||
|
version = "2.0.117"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"unicode-ident",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "thiserror"
|
||||||
|
version = "1.0.69"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
|
||||||
|
dependencies = [
|
||||||
|
"thiserror-impl",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "thiserror-impl"
|
||||||
|
version = "1.0.69"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tui-fm"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"crossterm",
|
||||||
|
"dirs",
|
||||||
|
"fs_extra",
|
||||||
|
"ratatui",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"unicode-width 0.2.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicode-ident"
|
||||||
|
version = "1.0.24"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicode-segmentation"
|
||||||
|
version = "1.13.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicode-truncate"
|
||||||
|
version = "1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf"
|
||||||
|
dependencies = [
|
||||||
|
"itertools",
|
||||||
|
"unicode-segmentation",
|
||||||
|
"unicode-width 0.1.14",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicode-width"
|
||||||
|
version = "0.1.14"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicode-width"
|
||||||
|
version = "0.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wasi"
|
||||||
|
version = "0.11.1+wasi-snapshot-preview1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "winapi"
|
||||||
|
version = "0.3.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
|
||||||
|
dependencies = [
|
||||||
|
"winapi-i686-pc-windows-gnu",
|
||||||
|
"winapi-x86_64-pc-windows-gnu",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "winapi-i686-pc-windows-gnu"
|
||||||
|
version = "0.4.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "winapi-x86_64-pc-windows-gnu"
|
||||||
|
version = "0.4.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-link"
|
||||||
|
version = "0.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-sys"
|
||||||
|
version = "0.48.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9"
|
||||||
|
dependencies = [
|
||||||
|
"windows-targets 0.48.5",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-sys"
|
||||||
|
version = "0.59.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
|
||||||
|
dependencies = [
|
||||||
|
"windows-targets 0.52.6",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-sys"
|
||||||
|
version = "0.61.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
|
||||||
|
dependencies = [
|
||||||
|
"windows-link",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-targets"
|
||||||
|
version = "0.48.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c"
|
||||||
|
dependencies = [
|
||||||
|
"windows_aarch64_gnullvm 0.48.5",
|
||||||
|
"windows_aarch64_msvc 0.48.5",
|
||||||
|
"windows_i686_gnu 0.48.5",
|
||||||
|
"windows_i686_msvc 0.48.5",
|
||||||
|
"windows_x86_64_gnu 0.48.5",
|
||||||
|
"windows_x86_64_gnullvm 0.48.5",
|
||||||
|
"windows_x86_64_msvc 0.48.5",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-targets"
|
||||||
|
version = "0.52.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
|
||||||
|
dependencies = [
|
||||||
|
"windows_aarch64_gnullvm 0.52.6",
|
||||||
|
"windows_aarch64_msvc 0.52.6",
|
||||||
|
"windows_i686_gnu 0.52.6",
|
||||||
|
"windows_i686_gnullvm",
|
||||||
|
"windows_i686_msvc 0.52.6",
|
||||||
|
"windows_x86_64_gnu 0.52.6",
|
||||||
|
"windows_x86_64_gnullvm 0.52.6",
|
||||||
|
"windows_x86_64_msvc 0.52.6",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_aarch64_gnullvm"
|
||||||
|
version = "0.48.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_aarch64_gnullvm"
|
||||||
|
version = "0.52.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_aarch64_msvc"
|
||||||
|
version = "0.48.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_aarch64_msvc"
|
||||||
|
version = "0.52.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_i686_gnu"
|
||||||
|
version = "0.48.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_i686_gnu"
|
||||||
|
version = "0.52.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_i686_gnullvm"
|
||||||
|
version = "0.52.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_i686_msvc"
|
||||||
|
version = "0.48.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_i686_msvc"
|
||||||
|
version = "0.52.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_x86_64_gnu"
|
||||||
|
version = "0.48.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_x86_64_gnu"
|
||||||
|
version = "0.52.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_x86_64_gnullvm"
|
||||||
|
version = "0.48.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_x86_64_gnullvm"
|
||||||
|
version = "0.52.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_x86_64_msvc"
|
||||||
|
version = "0.48.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_x86_64_msvc"
|
||||||
|
version = "0.52.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zmij"
|
||||||
|
version = "1.0.21"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
|
||||||
21
Cargo.toml
Normal file
21
Cargo.toml
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
[package]
|
||||||
|
name = "tui-fm"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "tui-fm"
|
||||||
|
path = "src/main.rs"
|
||||||
|
|
||||||
|
[profile.release]
|
||||||
|
opt-level = 3
|
||||||
|
strip = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
ratatui = "0.29"
|
||||||
|
crossterm = { version = "0.28", features = ["event-stream"] }
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
dirs = "5"
|
||||||
|
fs_extra = "1"
|
||||||
|
unicode-width = "0.2"
|
||||||
162
README.md
162
README.md
@@ -1,3 +1,163 @@
|
|||||||
# TUI-FM
|
# TUI-FM
|
||||||
|
|
||||||
en liten fil hanterare skriven i rust med full mouse stöd med sub menyer och ikon stöd
|
En terminalbaserad filhanterare skriven i Rust med fullt musstöd, ikonvisning och intuitiv navigation.
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ TUI-FM v0.1.0 │
|
||||||
|
├──────────────────┬──────────────────────────────────────────────────┤
|
||||||
|
│ FAVORITER │ /home/user/Documents │
|
||||||
|
│ ────────── │ ────────────────────────────────────────────── │
|
||||||
|
│ 🏠 Hem │ 📁 projekt/ │
|
||||||
|
│ 📁 Dokument ◄ │ 📁 bilder/ │
|
||||||
|
│ 📁 Nedladdnin │ 📄 rapport.pdf 120 KB │
|
||||||
|
│ 📁 Skrivbord │ 📄 anteckningar.txt 4 KB │
|
||||||
|
│ ────────── │ 🖼 foto.png 2.3 MB │
|
||||||
|
│ ROOT │ 📦 arkiv.zip 540 KB │
|
||||||
|
│ ────────── │ 🦀 main.rs 8 KB │
|
||||||
|
│ / │ │
|
||||||
|
│ /home │ │
|
||||||
|
│ /etc │ │
|
||||||
|
│ /usr │ │
|
||||||
|
│ │ ┌─────────────────────────────────┐ │
|
||||||
|
│ │ │ Högerklicksmeny │ │
|
||||||
|
│ [+ Favorit] │ │ ────────────────────────────── │ │
|
||||||
|
│ │ │ 📋 Kopiera │ │
|
||||||
|
│ │ │ ✂️ Klipp ut │ │
|
||||||
|
│ │ │ 📌 Klistra in │ │
|
||||||
|
│ │ │ ✏️ Byt namn │ │
|
||||||
|
│ │ │ ℹ️ Egenskaper │ │
|
||||||
|
│ │ │ ➕ Ny ▶ ┌──────────────────┐ │ │
|
||||||
|
│ │ └────────── │ 📄 Ny fil │ │ │
|
||||||
|
│ │ │ 📁 Ny mapp │ │ │
|
||||||
|
│ │ └──────────────────┘ │ │
|
||||||
|
├──────────────────┴──────────────────────────────────────────────────┤
|
||||||
|
│ Sökväg: /home/user/Documents/_________ [Enter för att navigera] │
|
||||||
|
└─────────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## Funktioner
|
||||||
|
|
||||||
|
### Huvudvy (mitten)
|
||||||
|
- Visar filer och mappar med ikoner baserat på filtyp
|
||||||
|
- **Dubbelklick** på mapp → navigerar in i mappen
|
||||||
|
- **Enkelklick** → markerar fil/mapp
|
||||||
|
- **Ctrl+klick** → flermarkering
|
||||||
|
- Filstorlek visas till höger
|
||||||
|
|
||||||
|
### Sidopanel (vänster)
|
||||||
|
- **Favoriter** – sparade sökvägar, klicka för att navigera direkt
|
||||||
|
- **Root-mappar** – snabbnavigering till `/`, `/home`, `/etc`, `/usr` etc.
|
||||||
|
- Knapp `[+ Favorit]` lägger till nuvarande mapp i favoriter
|
||||||
|
- Högerklick på favorit för att ta bort den
|
||||||
|
|
||||||
|
### Nedre statusfält
|
||||||
|
- Visar nuvarande sökväg
|
||||||
|
- Textruta för manuell sökväginmatning
|
||||||
|
- **Enter** navigerar till inskriven/inklistrad sökväg
|
||||||
|
- Stöd för kopiera/klistra in i textfältet
|
||||||
|
|
||||||
|
### Högerklicksmeny (kontextmeny)
|
||||||
|
Högerklick på fil(er)/mapp(ar) öppnar meny med:
|
||||||
|
- **Kopiera** – kopierar markerade filer
|
||||||
|
- **Klipp ut** – klipper ut markerade filer
|
||||||
|
- **Klistra in** – klistrar in i markerad mapp (kräver att en mapp är markerad)
|
||||||
|
- **Byt namn** – byter namn på fil/mapp
|
||||||
|
- **Egenskaper** – visar storlek, rättigheter, ägare
|
||||||
|
- Om flera är markerade visas gemensam storlek (beräknas rekursivt) och antal filer
|
||||||
|
- **Ny ▶** (submeny)
|
||||||
|
- Skapa ny fil
|
||||||
|
- Skapa ny mapp
|
||||||
|
|
||||||
|
### Musstöd
|
||||||
|
- Klick, dubbelklick, högerklick
|
||||||
|
- Scrollning i filvisaren och sidopanelen
|
||||||
|
- Markering av text i sökvägsrutan för kopiera/klistra in
|
||||||
|
|
||||||
|
### Ikontyper
|
||||||
|
| Typ | Ikon |
|
||||||
|
|------------|------|
|
||||||
|
| Mapp | 📁 |
|
||||||
|
| Textfil | 📄 |
|
||||||
|
| Bild | 🖼 |
|
||||||
|
| Video | 🎬 |
|
||||||
|
| Ljud | 🎵 |
|
||||||
|
| Arkiv/zip | 📦 |
|
||||||
|
| Rust-fil | 🦀 |
|
||||||
|
| Körbar | ⚙️ |
|
||||||
|
| Okänd | 📎 |
|
||||||
|
|
||||||
|
## Bygga appen
|
||||||
|
|
||||||
|
### Krav
|
||||||
|
- [Rust toolchain](https://rustup.rs/) (stable)
|
||||||
|
- För cross-kompilering till Windows: `mingw-w64` eller [cross](https://github.com/cross-rs/cross)
|
||||||
|
|
||||||
|
### Bygga för Linux
|
||||||
|
```bash
|
||||||
|
cargo build --release
|
||||||
|
# Binären hamnar i: build/linux/tui-fm
|
||||||
|
```
|
||||||
|
|
||||||
|
Eller via VS Code task: **Build - Linux**
|
||||||
|
|
||||||
|
### Bygga för Windows
|
||||||
|
```bash
|
||||||
|
# Lägg till Windows-target först:
|
||||||
|
rustup target add x86_64-pc-windows-gnu
|
||||||
|
# Installera mingw (Ubuntu/Debian):
|
||||||
|
sudo apt install gcc-mingw-w64-x86-64
|
||||||
|
|
||||||
|
cargo build --release --target x86_64-pc-windows-gnu
|
||||||
|
# Binären hamnar i: build/windows/tui-fm.exe
|
||||||
|
```
|
||||||
|
|
||||||
|
Eller via VS Code task: **Build - Windows**
|
||||||
|
|
||||||
|
### Output-mappar
|
||||||
|
```
|
||||||
|
build/
|
||||||
|
├── linux/
|
||||||
|
│ └── tui-fm
|
||||||
|
└── windows/
|
||||||
|
└── tui-fm.exe
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tangentbordsgenvägar
|
||||||
|
|
||||||
|
| Tangent | Funktion |
|
||||||
|
|---------------|-----------------------------------|
|
||||||
|
| `q` / `Esc` | Avsluta |
|
||||||
|
| `Enter` | Öppna mapp / navigera till sökväg |
|
||||||
|
| `Backspace` | Gå upp en nivå |
|
||||||
|
| `F2` | Byt namn |
|
||||||
|
| `Delete` | Ta bort markerade |
|
||||||
|
| `Ctrl+C` | Kopiera |
|
||||||
|
| `Ctrl+X` | Klipp ut |
|
||||||
|
| `Ctrl+V` | Klistra in |
|
||||||
|
| `Ctrl+A` | Markera alla |
|
||||||
|
| `Tab` | Växla fokus (panel ↔ filvy) |
|
||||||
|
|
||||||
|
## Beroenden (Rust crates)
|
||||||
|
|
||||||
|
| Crate | Version | Används för |
|
||||||
|
|-------|---------|------------|
|
||||||
|
| [`ratatui`](https://github.com/ratatui-org/ratatui) | 0.29 | TUI-ramverk för all rendering. Används med: `Table`+`TableState` (filvisaren med kolumner och stateful scroll), `Scrollbar`+`ScrollbarState` (scrollindikator i filvy och sidebar), `List`+`ListItem` (sidopanelen), `Block`+`Borders` (alla ramar), `Paragraph` (textelement och dialoger), `Layout`+`Constraint` (layoutberäkning), `Clear` (overlay för menyer/dialoger), `Line`/`Span`/`Style` (styling och färger) |
|
||||||
|
| [`crossterm`](https://github.com/crossterm-rs/crossterm) | 0.28 | Cross-platform terminalhantering (Windows + Linux/macOS). Hanterar: raw mode, alternate screen, mushändelser (`MouseEvent`, `MouseButton`, `MouseEventKind::Moved/Down/ScrollUp/ScrollDown`), tangentbordshändelser, terminalstorlek |
|
||||||
|
| [`serde`](https://serde.rs/) | 1 | Serialiserings-/deserialiseringsramverk med `#[derive(Serialize, Deserialize)]`. Används på `Config`-structen för att spara/läsa favoriter |
|
||||||
|
| [`serde_json`](https://docs.rs/serde_json) | 1 | JSON-backend till serde. Skriver konfigurationsfilen `~/.config/tui-fm/config.json` med `to_string_pretty` och läser den med `from_str` |
|
||||||
|
| [`dirs`](https://docs.rs/dirs) | 5 | Plattformsoberoende sökvägar till systemkataloger. Används för `dirs::home_dir()` (startkatalog) och `dirs::config_dir()` (var konfigurationsfilen sparas: `~/.config/` på Linux, `%APPDATA%` på Windows) |
|
||||||
|
| [`fs_extra`](https://docs.rs/fs_extra) | 1 | Utökade filoperationer som saknas i standardbiblioteket. Används för `dir::copy` och `dir::move_dir` – kopierar/flyttar kataloger rekursivt med alla underkataloger och filer |
|
||||||
|
| [`unicode-width`](https://docs.rs/unicode-width) | 0.2 | Beräknar visuell bredd av Unicode-tecken (t.ex. CJK-tecken är 2 kolumner breda). Används för korrekt texttrunkering i filnamn och sökvägar |
|
||||||
|
|
||||||
|
### Varför ratatui?
|
||||||
|
|
||||||
|
ratatui är det ledande Rust-biblioteket för terminalgränssnitt och valdes av dessa anledningar:
|
||||||
|
|
||||||
|
- **Stöder Windows + Linux** via crossterm-backenden – ett krav för detta projekt
|
||||||
|
- **`Table`-widget** ger kolumnjusterad visning utan manuell strängpadding; hanterar dynamiska terminalbredder automatiskt via `Constraint::Min` / `Constraint::Length`
|
||||||
|
- **`StatefulWidget`-mönstret** (`TableState`, `ScrollbarState`, `ListState`) separerar renderingstillstånd från applikationstillstånd – man lagrar bara `scroll_offset` i `App` och skapar en `TableState` per frame
|
||||||
|
- **`Scrollbar`-widget** renderar en komplett scrollindikator (spår, tumme, pilar) utan att man behöver beräkna positioner manuellt
|
||||||
|
- **`Clear`-widget** möjliggör popup-menyer och dialoger ovanpå befintligt innehåll
|
||||||
|
- **Immediate-mode rendering** – hela UI ritas om varje frame (~50 ms), vilket gör stathantering enkel: det finns ingen knapp-"tillståndsmaskin" att hålla synkroniserad
|
||||||
|
|
||||||
|
|||||||
BIN
build/linux/tui-fm
Executable file
BIN
build/linux/tui-fm
Executable file
Binary file not shown.
413
src/app.rs
Normal file
413
src/app.rs
Normal file
@@ -0,0 +1,413 @@
|
|||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use crate::config::Config;
|
||||||
|
use crate::file_ops::{self, FileEntry};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub enum Focus {
|
||||||
|
Sidebar,
|
||||||
|
FileView,
|
||||||
|
PathInput,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub enum ClipboardOp {
|
||||||
|
Copy,
|
||||||
|
Cut,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ClipboardEntry {
|
||||||
|
pub paths: Vec<PathBuf>,
|
||||||
|
pub op: ClipboardOp,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub enum ContextMenuItem {
|
||||||
|
Copy,
|
||||||
|
Cut,
|
||||||
|
Paste,
|
||||||
|
Rename,
|
||||||
|
Properties,
|
||||||
|
NewFile,
|
||||||
|
NewDir,
|
||||||
|
Delete,
|
||||||
|
AddFavorite,
|
||||||
|
RemoveFavorite(String),
|
||||||
|
Separator,
|
||||||
|
NewSubmenu,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ContextMenu {
|
||||||
|
pub x: u16,
|
||||||
|
pub y: u16,
|
||||||
|
pub items: Vec<ContextMenuItem>,
|
||||||
|
pub selected: usize,
|
||||||
|
pub show_new_submenu: bool,
|
||||||
|
pub sub_selected: Option<usize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub enum DialogMode {
|
||||||
|
None,
|
||||||
|
Rename(String),
|
||||||
|
NewFile(String),
|
||||||
|
NewDir(String),
|
||||||
|
Properties(PropertiesInfo),
|
||||||
|
Error(String),
|
||||||
|
Confirm(String, ConfirmAction),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub enum ConfirmAction {
|
||||||
|
Delete,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub struct PropertiesInfo {
|
||||||
|
pub name: String,
|
||||||
|
pub path: String,
|
||||||
|
pub size: u64,
|
||||||
|
pub file_count: u64,
|
||||||
|
pub permissions: String,
|
||||||
|
pub owner_user: String,
|
||||||
|
pub owner_group: String,
|
||||||
|
pub is_multi: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct App {
|
||||||
|
pub current_path: PathBuf,
|
||||||
|
pub entries: Vec<FileEntry>,
|
||||||
|
pub selected_indices: Vec<usize>,
|
||||||
|
pub scroll_offset: usize,
|
||||||
|
pub sidebar_scroll: usize,
|
||||||
|
pub focus: Focus,
|
||||||
|
pub config: Config,
|
||||||
|
pub clipboard: Option<ClipboardEntry>,
|
||||||
|
pub context_menu: Option<ContextMenu>,
|
||||||
|
pub dialog: DialogMode,
|
||||||
|
pub path_input: String,
|
||||||
|
pub path_input_cursor: usize,
|
||||||
|
pub show_hidden: bool,
|
||||||
|
pub status_message: Option<String>,
|
||||||
|
pub last_click_entry: Option<(usize, std::time::Instant)>,
|
||||||
|
pub sidebar_selected: Option<usize>,
|
||||||
|
pub rename_original: Option<PathBuf>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl App {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
let config = Config::load();
|
||||||
|
let start_path = dirs::home_dir()
|
||||||
|
.unwrap_or_else(|| PathBuf::from("/"));
|
||||||
|
let entries = file_ops::read_dir(&start_path);
|
||||||
|
let path_input = start_path.to_string_lossy().to_string();
|
||||||
|
|
||||||
|
Self {
|
||||||
|
current_path: start_path,
|
||||||
|
entries,
|
||||||
|
selected_indices: Vec::new(),
|
||||||
|
scroll_offset: 0,
|
||||||
|
sidebar_scroll: 0,
|
||||||
|
focus: Focus::FileView,
|
||||||
|
config,
|
||||||
|
clipboard: None,
|
||||||
|
context_menu: None,
|
||||||
|
dialog: DialogMode::None,
|
||||||
|
path_input,
|
||||||
|
path_input_cursor: 0,
|
||||||
|
show_hidden: false,
|
||||||
|
status_message: None,
|
||||||
|
last_click_entry: None,
|
||||||
|
sidebar_selected: None,
|
||||||
|
rename_original: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn navigate_to(&mut self, path: PathBuf) {
|
||||||
|
if path.is_dir() {
|
||||||
|
self.current_path = path.clone();
|
||||||
|
self.entries = file_ops::read_dir(&path);
|
||||||
|
if !self.show_hidden {
|
||||||
|
self.entries.retain(|e| !e.is_hidden);
|
||||||
|
}
|
||||||
|
self.selected_indices.clear();
|
||||||
|
self.scroll_offset = 0;
|
||||||
|
self.path_input = path.to_string_lossy().to_string();
|
||||||
|
self.path_input_cursor = self.path_input.len();
|
||||||
|
self.context_menu = None;
|
||||||
|
} else {
|
||||||
|
self.set_status(format!("Not a directory: {}", path.display()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn refresh(&mut self) {
|
||||||
|
self.entries = file_ops::read_dir(&self.current_path.clone());
|
||||||
|
if !self.show_hidden {
|
||||||
|
self.entries.retain(|e| !e.is_hidden);
|
||||||
|
}
|
||||||
|
// Keep valid selections
|
||||||
|
let count = self.entries.len();
|
||||||
|
self.selected_indices.retain(|&i| i < count);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_status(&mut self, msg: String) {
|
||||||
|
self.status_message = Some(msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn navigate_up(&mut self) {
|
||||||
|
if let Some(parent) = self.current_path.parent() {
|
||||||
|
let p = parent.to_path_buf();
|
||||||
|
self.navigate_to(p);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn toggle_selection(&mut self, index: usize) {
|
||||||
|
if self.selected_indices.contains(&index) {
|
||||||
|
self.selected_indices.retain(|&i| i != index);
|
||||||
|
} else {
|
||||||
|
self.selected_indices.push(index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn select_only(&mut self, index: usize) {
|
||||||
|
self.selected_indices = vec![index];
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn select_all(&mut self) {
|
||||||
|
self.selected_indices = (0..self.entries.len()).collect();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn copy_selected(&mut self) {
|
||||||
|
let paths: Vec<PathBuf> = self
|
||||||
|
.selected_indices
|
||||||
|
.iter()
|
||||||
|
.filter_map(|&i| self.entries.get(i))
|
||||||
|
.map(|e| e.path.clone())
|
||||||
|
.collect();
|
||||||
|
if !paths.is_empty() {
|
||||||
|
self.clipboard = Some(ClipboardEntry { paths, op: ClipboardOp::Copy });
|
||||||
|
self.set_status("Copied to clipboard".to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn cut_selected(&mut self) {
|
||||||
|
let paths: Vec<PathBuf> = self
|
||||||
|
.selected_indices
|
||||||
|
.iter()
|
||||||
|
.filter_map(|&i| self.entries.get(i))
|
||||||
|
.map(|e| e.path.clone())
|
||||||
|
.collect();
|
||||||
|
if !paths.is_empty() {
|
||||||
|
self.clipboard = Some(ClipboardEntry { paths, op: ClipboardOp::Cut });
|
||||||
|
self.set_status("Cut to clipboard".to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Paste into given destination directory
|
||||||
|
pub fn paste_into(&mut self, dst: &std::path::Path) {
|
||||||
|
let Some(clipboard) = self.clipboard.clone() else {
|
||||||
|
self.set_status("Nothing in clipboard".to_string());
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut errors = Vec::new();
|
||||||
|
for src in &clipboard.paths {
|
||||||
|
let result = match clipboard.op {
|
||||||
|
ClipboardOp::Copy => file_ops::copy_entry(src, dst),
|
||||||
|
ClipboardOp::Cut => file_ops::move_entry(src, dst),
|
||||||
|
};
|
||||||
|
if let Err(e) = result {
|
||||||
|
errors.push(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if clipboard.op == ClipboardOp::Cut {
|
||||||
|
self.clipboard = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
if errors.is_empty() {
|
||||||
|
self.set_status("Paste successful".to_string());
|
||||||
|
} else {
|
||||||
|
self.set_status(format!("Errors: {}", errors.join(", ")));
|
||||||
|
}
|
||||||
|
self.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn delete_selected(&mut self) {
|
||||||
|
let paths: Vec<PathBuf> = self
|
||||||
|
.selected_indices
|
||||||
|
.iter()
|
||||||
|
.filter_map(|&i| self.entries.get(i))
|
||||||
|
.map(|e| e.path.clone())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let mut errors = Vec::new();
|
||||||
|
for path in paths {
|
||||||
|
if let Err(e) = file_ops::delete_entry(&path) {
|
||||||
|
errors.push(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.selected_indices.clear();
|
||||||
|
if errors.is_empty() {
|
||||||
|
self.set_status("Deleted".to_string());
|
||||||
|
} else {
|
||||||
|
self.set_status(format!("Delete errors: {}", errors.join(", ")));
|
||||||
|
}
|
||||||
|
self.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn rename_entry(&mut self, entry_path: &std::path::Path, new_name: &str) {
|
||||||
|
let new_name = new_name.trim();
|
||||||
|
if new_name.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Prevent path traversal
|
||||||
|
if new_name.contains('/') || new_name.contains('\\') || new_name == ".." || new_name == "." {
|
||||||
|
self.set_status("Invalid name".to_string());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let new_path = entry_path
|
||||||
|
.parent()
|
||||||
|
.unwrap_or(std::path::Path::new("."))
|
||||||
|
.join(new_name);
|
||||||
|
if let Err(e) = std::fs::rename(entry_path, &new_path) {
|
||||||
|
self.set_status(format!("Rename error: {}", e));
|
||||||
|
} else {
|
||||||
|
self.set_status(format!("Renamed to {}", new_name));
|
||||||
|
}
|
||||||
|
self.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn create_file(&mut self, name: &str) {
|
||||||
|
let name = name.trim();
|
||||||
|
if name.is_empty() { return; }
|
||||||
|
if name.contains('/') || name.contains('\\') {
|
||||||
|
self.set_status("Invalid filename".to_string());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let path = self.current_path.join(name);
|
||||||
|
if let Err(e) = std::fs::File::create(&path) {
|
||||||
|
self.set_status(format!("Error: {}", e));
|
||||||
|
} else {
|
||||||
|
self.set_status(format!("Created file {}", name));
|
||||||
|
}
|
||||||
|
self.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn create_dir(&mut self, name: &str) {
|
||||||
|
let name = name.trim();
|
||||||
|
if name.is_empty() { return; }
|
||||||
|
if name.contains('/') || name.contains('\\') {
|
||||||
|
self.set_status("Invalid directory name".to_string());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let path = self.current_path.join(name);
|
||||||
|
if let Err(e) = std::fs::create_dir(&path) {
|
||||||
|
self.set_status(format!("Error: {}", e));
|
||||||
|
} else {
|
||||||
|
self.set_status(format!("Created directory {}", name));
|
||||||
|
}
|
||||||
|
self.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn compute_properties(&mut self) {
|
||||||
|
if self.selected_indices.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let is_multi = self.selected_indices.len() > 1;
|
||||||
|
|
||||||
|
if is_multi {
|
||||||
|
let mut total_size: u64 = 0;
|
||||||
|
let mut total_files: u64 = 0;
|
||||||
|
for &i in &self.selected_indices {
|
||||||
|
if let Some(e) = self.entries.get(i) {
|
||||||
|
let (s, c) = file_ops::recursive_info(&e.path);
|
||||||
|
total_size += s;
|
||||||
|
total_files += c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.dialog = DialogMode::Properties(PropertiesInfo {
|
||||||
|
name: format!("{} items selected", self.selected_indices.len()),
|
||||||
|
path: self.current_path.to_string_lossy().to_string(),
|
||||||
|
size: total_size,
|
||||||
|
file_count: total_files,
|
||||||
|
permissions: String::from("N/A"),
|
||||||
|
owner_user: String::from("N/A"),
|
||||||
|
owner_group: String::from("N/A"),
|
||||||
|
is_multi: true,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
let i = self.selected_indices[0];
|
||||||
|
if let Some(e) = self.entries.get(i) {
|
||||||
|
let (size, file_count) = file_ops::recursive_info(&e.path);
|
||||||
|
let permissions = file_ops::file_permissions(&e.path);
|
||||||
|
let (owner_user, owner_group) = file_ops::file_owner(&e.path);
|
||||||
|
self.dialog = DialogMode::Properties(PropertiesInfo {
|
||||||
|
name: e.name.clone(),
|
||||||
|
path: e.path.to_string_lossy().to_string(),
|
||||||
|
size,
|
||||||
|
file_count,
|
||||||
|
permissions,
|
||||||
|
owner_user,
|
||||||
|
owner_group,
|
||||||
|
is_multi: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
let mut items = Vec::new();
|
||||||
|
|
||||||
|
if is_sidebar_fav {
|
||||||
|
if let Some(path) = fav_path {
|
||||||
|
items.push(ContextMenuItem::RemoveFavorite(path));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if has_selection {
|
||||||
|
items.push(ContextMenuItem::Copy);
|
||||||
|
items.push(ContextMenuItem::Cut);
|
||||||
|
}
|
||||||
|
if can_paste {
|
||||||
|
items.push(ContextMenuItem::Paste);
|
||||||
|
}
|
||||||
|
if has_selection {
|
||||||
|
items.push(ContextMenuItem::Separator);
|
||||||
|
items.push(ContextMenuItem::Rename);
|
||||||
|
items.push(ContextMenuItem::Properties);
|
||||||
|
items.push(ContextMenuItem::Separator);
|
||||||
|
items.push(ContextMenuItem::Delete);
|
||||||
|
}
|
||||||
|
items.push(ContextMenuItem::Separator);
|
||||||
|
items.push(ContextMenuItem::NewSubmenu);
|
||||||
|
items.push(ContextMenuItem::AddFavorite);
|
||||||
|
}
|
||||||
|
|
||||||
|
ContextMenu { x, y, items, selected: 0, show_new_submenu: false, sub_selected: None }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns target directory for paste: selected dir or current path
|
||||||
|
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() {
|
||||||
|
return Some(e.path.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(self.current_path.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn visible_entry_count(&self) -> usize {
|
||||||
|
self.entries.len()
|
||||||
|
}
|
||||||
|
}
|
||||||
48
src/config.rs
Normal file
48
src/config.rs
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Default, Clone)]
|
||||||
|
pub struct Config {
|
||||||
|
pub favorites: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Config {
|
||||||
|
fn config_path() -> Option<PathBuf> {
|
||||||
|
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) {
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Config::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn save(&self) {
|
||||||
|
if let Some(path) = Self::config_path() {
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
let _ = std::fs::create_dir_all(parent);
|
||||||
|
}
|
||||||
|
if let Ok(data) = serde_json::to_string_pretty(self) {
|
||||||
|
let _ = std::fs::write(&path, data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn add_favorite(&mut self, path: &str) {
|
||||||
|
let s = path.to_string();
|
||||||
|
if !self.favorites.contains(&s) {
|
||||||
|
self.favorites.push(s);
|
||||||
|
self.save();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn remove_favorite(&mut self, path: &str) {
|
||||||
|
self.favorites.retain(|f| f != path);
|
||||||
|
self.save();
|
||||||
|
}
|
||||||
|
}
|
||||||
593
src/events.rs
Normal file
593
src/events.rs
Normal file
@@ -0,0 +1,593 @@
|
|||||||
|
use std::io;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use crossterm::event::{
|
||||||
|
self, Event, KeyCode, KeyModifiers, MouseButton, MouseEventKind,
|
||||||
|
};
|
||||||
|
use ratatui::{backend::Backend, Terminal};
|
||||||
|
|
||||||
|
use crate::app::{App, ConfirmAction, ContextMenuItem, DialogMode, Focus};
|
||||||
|
use crate::ui;
|
||||||
|
|
||||||
|
pub fn run_app<B: Backend>(terminal: &mut Terminal<B>, app: &mut App) -> io::Result<()> {
|
||||||
|
loop {
|
||||||
|
terminal.draw(|f| ui::draw(f, app))?;
|
||||||
|
|
||||||
|
if event::poll(Duration::from_millis(50))? {
|
||||||
|
let ev = event::read()?;
|
||||||
|
if handle_event(app, ev) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns true if the app should quit
|
||||||
|
fn handle_event(app: &mut App, ev: Event) -> bool {
|
||||||
|
match ev {
|
||||||
|
Event::Key(key) => handle_key(app, key),
|
||||||
|
Event::Mouse(mouse) => {
|
||||||
|
handle_mouse(app, mouse);
|
||||||
|
false
|
||||||
|
}
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_key(app: &mut App, key: crossterm::event::KeyEvent) -> bool {
|
||||||
|
// Close context menu on any key
|
||||||
|
if app.context_menu.is_some() {
|
||||||
|
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; }
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
KeyCode::Down => {
|
||||||
|
if let Some(ref mut m) = app.context_menu {
|
||||||
|
let max = m.items.len().saturating_sub(1);
|
||||||
|
if m.selected < max { m.selected += 1; }
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
KeyCode::Enter => {
|
||||||
|
execute_context_menu(app);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
_ => { app.context_menu = None; return false; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle dialogs
|
||||||
|
match &app.dialog.clone() {
|
||||||
|
DialogMode::Rename(current) => {
|
||||||
|
return handle_text_dialog_key(app, key, current.clone(), |app, s| {
|
||||||
|
if let Some(path) = app.rename_original.clone() {
|
||||||
|
app.rename_entry(&path, &s);
|
||||||
|
}
|
||||||
|
app.rename_original = None;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
DialogMode::NewFile(current) => {
|
||||||
|
return handle_text_dialog_key(app, key, current.clone(), |app, s| {
|
||||||
|
app.create_file(&s);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
DialogMode::NewDir(current) => {
|
||||||
|
return handle_text_dialog_key(app, key, current.clone(), |app, s| {
|
||||||
|
app.create_dir(&s);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
DialogMode::Properties(_) | DialogMode::Error(_) => {
|
||||||
|
if matches!(key.code, KeyCode::Esc | KeyCode::Enter | KeyCode::Char('q')) {
|
||||||
|
app.dialog = DialogMode::None;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
DialogMode::Confirm(_, action) => {
|
||||||
|
let action = action.clone();
|
||||||
|
match key.code {
|
||||||
|
KeyCode::Char('y') | KeyCode::Enter => {
|
||||||
|
app.dialog = DialogMode::None;
|
||||||
|
match action {
|
||||||
|
ConfirmAction::Delete => app.delete_selected(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => { app.dialog = DialogMode::None; }
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
DialogMode::None => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Path input mode
|
||||||
|
if app.focus == Focus::PathInput {
|
||||||
|
match key.code {
|
||||||
|
KeyCode::Esc => { app.focus = Focus::FileView; }
|
||||||
|
KeyCode::Enter => {
|
||||||
|
let p = std::path::PathBuf::from(&app.path_input);
|
||||||
|
app.navigate_to(p);
|
||||||
|
app.focus = Focus::FileView;
|
||||||
|
}
|
||||||
|
KeyCode::Backspace => {
|
||||||
|
if app.path_input_cursor > 0 {
|
||||||
|
let cursor = app.path_input_cursor;
|
||||||
|
app.path_input.remove(cursor - 1);
|
||||||
|
app.path_input_cursor -= 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Left => {
|
||||||
|
if app.path_input_cursor > 0 { app.path_input_cursor -= 1; }
|
||||||
|
}
|
||||||
|
KeyCode::Right => {
|
||||||
|
if app.path_input_cursor < app.path_input.len() { app.path_input_cursor += 1; }
|
||||||
|
}
|
||||||
|
KeyCode::Home => { app.path_input_cursor = 0; }
|
||||||
|
KeyCode::End => { app.path_input_cursor = app.path_input.len(); }
|
||||||
|
KeyCode::Char(c) => {
|
||||||
|
let cursor = app.path_input_cursor;
|
||||||
|
app.path_input.insert(cursor, c);
|
||||||
|
app.path_input_cursor += 1;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Global keys
|
||||||
|
if key.modifiers.contains(KeyModifiers::CONTROL) {
|
||||||
|
match key.code {
|
||||||
|
KeyCode::Char('c') => { app.copy_selected(); return false; }
|
||||||
|
KeyCode::Char('x') => { app.cut_selected(); return false; }
|
||||||
|
KeyCode::Char('v') => {
|
||||||
|
let dst = app.selected_target_dir().unwrap_or_else(|| app.current_path.clone());
|
||||||
|
app.paste_into(&dst.clone());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
KeyCode::Char('a') => { app.select_all(); return false; }
|
||||||
|
KeyCode::Char('h') => {
|
||||||
|
app.show_hidden = !app.show_hidden;
|
||||||
|
app.refresh();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
match key.code {
|
||||||
|
KeyCode::Char('q') | KeyCode::Esc => return true,
|
||||||
|
KeyCode::Tab => {
|
||||||
|
app.focus = match app.focus {
|
||||||
|
Focus::Sidebar => Focus::FileView,
|
||||||
|
Focus::FileView => Focus::PathInput,
|
||||||
|
Focus::PathInput => Focus::Sidebar,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
KeyCode::Backspace => {
|
||||||
|
app.navigate_up();
|
||||||
|
}
|
||||||
|
KeyCode::F(2) => {
|
||||||
|
if let Some(&i) = app.selected_indices.first() {
|
||||||
|
if 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() {
|
||||||
|
let msg = format!("Delete {} item(s)? (y/n)", app.selected_indices.len());
|
||||||
|
app.dialog = DialogMode::Confirm(msg, ConfirmAction::Delete);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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() {
|
||||||
|
app.navigate_to(e.path.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Up => {
|
||||||
|
if app.focus == Focus::FileView {
|
||||||
|
scroll_selection(app, -1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Down => {
|
||||||
|
if app.focus == Focus::FileView {
|
||||||
|
scroll_selection(app, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
fn scroll_selection(app: &mut App, delta: i32) {
|
||||||
|
let count = app.entries.len();
|
||||||
|
if count == 0 { return; }
|
||||||
|
|
||||||
|
let current = app.selected_indices.first().copied().unwrap_or(0) as i32;
|
||||||
|
let new_idx = (current + delta).max(0).min(count as i32 - 1) as usize;
|
||||||
|
app.selected_indices = vec![new_idx];
|
||||||
|
|
||||||
|
// Ensure visible
|
||||||
|
if new_idx < app.scroll_offset {
|
||||||
|
app.scroll_offset = new_idx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_text_dialog_key<F>(
|
||||||
|
app: &mut App,
|
||||||
|
key: crossterm::event::KeyEvent,
|
||||||
|
current: String,
|
||||||
|
on_confirm: F,
|
||||||
|
) -> bool
|
||||||
|
where
|
||||||
|
F: FnOnce(&mut App, String),
|
||||||
|
{
|
||||||
|
match key.code {
|
||||||
|
KeyCode::Esc => {
|
||||||
|
app.dialog = DialogMode::None;
|
||||||
|
app.rename_original = None;
|
||||||
|
}
|
||||||
|
KeyCode::Enter => {
|
||||||
|
let s = current.clone();
|
||||||
|
app.dialog = DialogMode::None;
|
||||||
|
on_confirm(app, s);
|
||||||
|
}
|
||||||
|
KeyCode::Backspace => {
|
||||||
|
let mut s = current;
|
||||||
|
s.pop();
|
||||||
|
update_dialog_text(app, s);
|
||||||
|
}
|
||||||
|
KeyCode::Char(c) => {
|
||||||
|
let mut s = current;
|
||||||
|
s.push(c);
|
||||||
|
update_dialog_text(app, s);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update_dialog_text(app: &mut App, new_text: String) {
|
||||||
|
match &app.dialog {
|
||||||
|
DialogMode::Rename(_) => app.dialog = DialogMode::Rename(new_text),
|
||||||
|
DialogMode::NewFile(_) => app.dialog = DialogMode::NewFile(new_text),
|
||||||
|
DialogMode::NewDir(_) => app.dialog = DialogMode::NewDir(new_text),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn execute_context_menu(app: &mut App) {
|
||||||
|
let menu = app.context_menu.clone();
|
||||||
|
if let Some(m) = menu {
|
||||||
|
let item = m.items.get(m.selected).cloned();
|
||||||
|
app.context_menu = None;
|
||||||
|
if let Some(item) = item {
|
||||||
|
match item {
|
||||||
|
ContextMenuItem::Copy => app.copy_selected(),
|
||||||
|
ContextMenuItem::Cut => app.cut_selected(),
|
||||||
|
ContextMenuItem::Paste => {
|
||||||
|
let dst = app.selected_target_dir().unwrap_or_else(|| app.current_path.clone());
|
||||||
|
app.paste_into(&dst.clone());
|
||||||
|
}
|
||||||
|
ContextMenuItem::Rename => {
|
||||||
|
if let Some(&i) = app.selected_indices.first() {
|
||||||
|
if 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()),
|
||||||
|
ContextMenuItem::NewDir => app.dialog = DialogMode::NewDir(String::new()),
|
||||||
|
ContextMenuItem::Delete => {
|
||||||
|
if !app.selected_indices.is_empty() {
|
||||||
|
let msg = format!("Delete {} item(s)? (y/n)", app.selected_indices.len());
|
||||||
|
app.dialog = DialogMode::Confirm(msg, ConfirmAction::Delete);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ContextMenuItem::AddFavorite => {
|
||||||
|
let path = app.current_path.to_string_lossy().to_string();
|
||||||
|
app.config.add_favorite(&path);
|
||||||
|
app.set_status("Added to favorites".to_string());
|
||||||
|
}
|
||||||
|
ContextMenuItem::RemoveFavorite(path) => {
|
||||||
|
app.config.remove_favorite(&path);
|
||||||
|
app.set_status("Removed from favorites".to_string());
|
||||||
|
}
|
||||||
|
ContextMenuItem::NewSubmenu => {
|
||||||
|
// Toggle submenu - handled in UI
|
||||||
|
}
|
||||||
|
ContextMenuItem::Separator => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_mouse(app: &mut App, mouse: crossterm::event::MouseEvent) {
|
||||||
|
match mouse.kind {
|
||||||
|
MouseEventKind::ScrollDown => handle_scroll(app, mouse.column, mouse.row, 3),
|
||||||
|
MouseEventKind::ScrollUp => handle_scroll(app, mouse.column, mouse.row, -3),
|
||||||
|
MouseEventKind::Down(MouseButton::Left) => handle_left_click(app, mouse.column, mouse.row),
|
||||||
|
MouseEventKind::Down(MouseButton::Right) => handle_right_click(app, mouse.column, mouse.row),
|
||||||
|
MouseEventKind::Moved => handle_mouse_move(app, mouse.column, mouse.row),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_scroll(app: &mut App, col: u16, _row: u16, delta: i32) {
|
||||||
|
if col < 20 {
|
||||||
|
// Sidebar scroll
|
||||||
|
let roots_len = crate::file_ops::get_root_dirs().len();
|
||||||
|
let favs_len = app.config.favorites.len();
|
||||||
|
let total = favs_len + roots_len + 5; // headings+padding
|
||||||
|
let max = total.saturating_sub(10);
|
||||||
|
let new_off = (app.sidebar_scroll as i32 + delta).max(0).min(max as i32) as usize;
|
||||||
|
app.sidebar_scroll = new_off;
|
||||||
|
} else {
|
||||||
|
// File view scroll
|
||||||
|
let count = app.entries.len();
|
||||||
|
let new_off = (app.scroll_offset as i32 + delta).max(0).min(count as i32) as usize;
|
||||||
|
app.scroll_offset = new_off;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hit test: given terminal coords, determine which area and entry index was clicked
|
||||||
|
pub fn hit_test_file_view(app: &App, col: u16, row: u16, layout: &ui::LayoutAreas) -> Option<usize> {
|
||||||
|
let area = layout.file_view;
|
||||||
|
// File list starts after the header row (row+2 from top of area)
|
||||||
|
if col < area.x || col >= area.x + area.width { return None; }
|
||||||
|
if row < area.y + 2 || row >= area.y + area.height { return None; }
|
||||||
|
let list_row = (row - area.y - 2) as usize;
|
||||||
|
let idx = list_row + app.scroll_offset;
|
||||||
|
if idx < app.entries.len() { Some(idx) } else { None }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn hit_test_sidebar(app: &App, col: u16, row: u16, layout: &ui::LayoutAreas) -> Option<SidebarItem> {
|
||||||
|
let area = layout.sidebar;
|
||||||
|
if col < area.x || col >= area.x + area.width { return None; }
|
||||||
|
// row must be inside the inner content area (skip top border at area.y)
|
||||||
|
if row <= area.y || row >= area.y + area.height { return None; }
|
||||||
|
|
||||||
|
// Map terminal row → list item index, accounting for top border and scroll
|
||||||
|
let list_idx = app.sidebar_scroll + (row - area.y - 1) as usize;
|
||||||
|
|
||||||
|
let favs_len = app.config.favorites.len();
|
||||||
|
// When empty, "(inga)" placeholder occupies 1 slot in the rendered list
|
||||||
|
let display_favs = favs_len.max(1);
|
||||||
|
let roots = crate::file_ops::get_root_dirs();
|
||||||
|
|
||||||
|
// idx 0 : [↑ Gå upp]
|
||||||
|
if list_idx == 0 { return Some(SidebarItem::GoUp); }
|
||||||
|
// idx 1 : FAVORITER heading
|
||||||
|
if list_idx == 1 { return None; }
|
||||||
|
// idx 2 .. 1+display_favs : favorites (or "(inga)" placeholder)
|
||||||
|
if list_idx >= 2 && list_idx <= 1 + display_favs {
|
||||||
|
let fav_idx = list_idx - 2;
|
||||||
|
if fav_idx < favs_len {
|
||||||
|
return Some(SidebarItem::Favorite(fav_idx, app.config.favorites[fav_idx].clone()));
|
||||||
|
}
|
||||||
|
return None; // "(inga)" placeholder – not clickable
|
||||||
|
}
|
||||||
|
// idx 2+display_favs : separator
|
||||||
|
if list_idx == 2 + display_favs { return None; }
|
||||||
|
// idx 3+display_favs : SÖKVÄGAR heading
|
||||||
|
if list_idx == 3 + display_favs { return None; }
|
||||||
|
// idx 4+display_favs .. 3+display_favs+roots_len : root dirs
|
||||||
|
let roots_start = 4 + display_favs;
|
||||||
|
if list_idx >= roots_start && list_idx < roots_start + roots.len() {
|
||||||
|
let idx = list_idx - roots_start;
|
||||||
|
return Some(SidebarItem::Root(roots[idx].clone()));
|
||||||
|
}
|
||||||
|
// idx roots_start+roots_len : separator
|
||||||
|
// idx roots_start+roots_len+1 : [+ Favorit]
|
||||||
|
if list_idx == roots_start + roots.len() + 1 {
|
||||||
|
return Some(SidebarItem::AddFavorite);
|
||||||
|
}
|
||||||
|
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
pub enum SidebarItem {
|
||||||
|
Favorite(usize, String),
|
||||||
|
Root(std::path::PathBuf),
|
||||||
|
AddFavorite,
|
||||||
|
GoUp,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_left_click(app: &mut App, col: u16, row: u16) {
|
||||||
|
const MENU_W: u16 = 24;
|
||||||
|
const SUB_W: u16 = 18;
|
||||||
|
const SUB_H: u16 = 4;
|
||||||
|
|
||||||
|
if let Some(m) = app.context_menu.clone() {
|
||||||
|
let mx = m.x;
|
||||||
|
let my = m.y;
|
||||||
|
let items_len = m.items.len() as u16;
|
||||||
|
let menu_height = items_len + 2; // top + bottom border
|
||||||
|
|
||||||
|
let in_main = col >= mx && col < mx + MENU_W
|
||||||
|
&& row >= my && row < my + menu_height;
|
||||||
|
|
||||||
|
// 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 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
|
||||||
|
&& row > sub_y && row < sub_y + SUB_H // > to skip top border row
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
};
|
||||||
|
|
||||||
|
if in_sub {
|
||||||
|
let ns_pos = ns_idx.unwrap();
|
||||||
|
let sub_y = my + 1 + ns_pos as u16;
|
||||||
|
// skip the top border row of the submenu box
|
||||||
|
if row <= sub_y { app.context_menu = None; return; }
|
||||||
|
let sub_item = row.saturating_sub(sub_y + 1) as usize;
|
||||||
|
app.context_menu = None;
|
||||||
|
match sub_item {
|
||||||
|
0 => app.dialog = DialogMode::NewFile(String::new()),
|
||||||
|
1 => app.dialog = DialogMode::NewDir(String::new()),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if in_main {
|
||||||
|
// Items live between the two border rows
|
||||||
|
if row > my && row <= my + items_len {
|
||||||
|
let item_row = (row - my - 1) as usize;
|
||||||
|
if let Some(ref mut menu) = app.context_menu {
|
||||||
|
menu.selected = item_row.min(menu.items.len().saturating_sub(1));
|
||||||
|
}
|
||||||
|
execute_context_menu(app);
|
||||||
|
} else {
|
||||||
|
// Clicked on a border row – dismiss
|
||||||
|
app.context_menu = None;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clicked outside everything – dismiss
|
||||||
|
app.context_menu = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let layout = ui::compute_layout(app);
|
||||||
|
|
||||||
|
// Check path input area
|
||||||
|
let pi = layout.path_input;
|
||||||
|
if row == pi.y && col >= pi.x && col < pi.x + pi.width {
|
||||||
|
app.focus = Focus::PathInput;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check sidebar
|
||||||
|
if col < layout.sidebar.x + layout.sidebar.width {
|
||||||
|
app.focus = Focus::Sidebar;
|
||||||
|
if let Some(item) = hit_test_sidebar(app, col, row, &layout) {
|
||||||
|
match item {
|
||||||
|
SidebarItem::Favorite(_, path) => {
|
||||||
|
let p = std::path::PathBuf::from(&path);
|
||||||
|
app.navigate_to(p);
|
||||||
|
}
|
||||||
|
SidebarItem::Root(p) => {
|
||||||
|
app.navigate_to(p);
|
||||||
|
}
|
||||||
|
SidebarItem::AddFavorite => {
|
||||||
|
let path = app.current_path.to_string_lossy().to_string();
|
||||||
|
app.config.add_favorite(&path);
|
||||||
|
app.set_status("Added to favorites".to_string());
|
||||||
|
}
|
||||||
|
SidebarItem::GoUp => {
|
||||||
|
app.navigate_up();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check file view
|
||||||
|
app.focus = Focus::FileView;
|
||||||
|
if let Some(idx) = hit_test_file_view(app, col, row, &layout) {
|
||||||
|
let now = Instant::now();
|
||||||
|
let is_double = if let Some((last_idx, last_time)) = app.last_click_entry {
|
||||||
|
last_idx == idx && now.duration_since(last_time) < Duration::from_millis(400)
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
};
|
||||||
|
app.last_click_entry = Some((idx, now));
|
||||||
|
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
app.select_only(idx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_right_click(app: &mut App, col: u16, row: u16) {
|
||||||
|
let layout = ui::compute_layout(app);
|
||||||
|
|
||||||
|
// 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 {
|
||||||
|
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) {
|
||||||
|
app.select_only(idx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let menu = app.build_context_menu(col, row, false, None);
|
||||||
|
app.context_menu = Some(menu);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_mouse_move(app: &mut App, col: u16, row: u16) {
|
||||||
|
const MENU_W: u16 = 24;
|
||||||
|
const SUB_W: u16 = 18;
|
||||||
|
|
||||||
|
let Some(ref mut menu) = app.context_menu else { return; };
|
||||||
|
|
||||||
|
let mx = menu.x;
|
||||||
|
let my = menu.y;
|
||||||
|
let items_len = menu.items.len() as u16;
|
||||||
|
|
||||||
|
// Hovering over a main-menu item (inside the border)
|
||||||
|
if col > mx && col < mx + MENU_W - 1
|
||||||
|
&& row > my && row <= my + items_len
|
||||||
|
{
|
||||||
|
let item_idx = (row - my - 1) as usize;
|
||||||
|
if item_idx < menu.items.len() {
|
||||||
|
menu.selected = item_idx;
|
||||||
|
menu.sub_selected = None;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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));
|
||||||
|
let sub_x = (mx + MENU_W).min(term_w.saturating_sub(SUB_W));
|
||||||
|
let sub_y = my + 1 + ns_pos as u16;
|
||||||
|
|
||||||
|
if col >= sub_x && col < sub_x + SUB_W
|
||||||
|
&& row >= sub_y && row < sub_y + 4
|
||||||
|
{
|
||||||
|
// Keep NewSubmenu highlighted and track which flyout row is hovered
|
||||||
|
if row > sub_y && row < sub_y + 3 {
|
||||||
|
menu.sub_selected = Some((row - sub_y - 1) as usize);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
267
src/file_ops.rs
Normal file
267
src/file_ops.rs
Normal file
@@ -0,0 +1,267 @@
|
|||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::fs;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
|
pub enum FileType {
|
||||||
|
Directory,
|
||||||
|
File,
|
||||||
|
Symlink,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct FileEntry {
|
||||||
|
pub name: String,
|
||||||
|
pub path: PathBuf,
|
||||||
|
pub file_type: FileType,
|
||||||
|
pub size: u64,
|
||||||
|
pub is_hidden: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FileEntry {
|
||||||
|
pub fn icon(&self) -> &'static str {
|
||||||
|
match &self.file_type {
|
||||||
|
FileType::Directory => "📁",
|
||||||
|
FileType::Symlink => "🔗",
|
||||||
|
FileType::File => {
|
||||||
|
let ext = self
|
||||||
|
.name
|
||||||
|
.rsplit('.')
|
||||||
|
.next()
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_lowercase();
|
||||||
|
match ext.as_str() {
|
||||||
|
"rs" => "🦀",
|
||||||
|
"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" => "⚙",
|
||||||
|
"sh" | "bash" | "zsh" | "fish" => "🐚",
|
||||||
|
"py" => "🐍",
|
||||||
|
"js" | "ts" => "📜",
|
||||||
|
"c" | "cpp" | "h" | "hpp" => "🔧",
|
||||||
|
"exe" | "bin" | "out" | "run" => "⚙",
|
||||||
|
_ => "📎",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_dir(&self) -> bool {
|
||||||
|
self.file_type == FileType::Directory
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn formatted_size(&self) -> String {
|
||||||
|
if self.file_type == FileType::Directory {
|
||||||
|
return String::from("<dir>");
|
||||||
|
}
|
||||||
|
format_size(self.size)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn format_size(bytes: u64) -> String {
|
||||||
|
const KB: u64 = 1024;
|
||||||
|
const MB: u64 = 1024 * KB;
|
||||||
|
const GB: u64 = 1024 * MB;
|
||||||
|
|
||||||
|
if bytes >= GB {
|
||||||
|
format!("{:.1} GB", bytes as f64 / GB as f64)
|
||||||
|
} else if bytes >= MB {
|
||||||
|
format!("{:.1} MB", bytes as f64 / MB as f64)
|
||||||
|
} else if bytes >= KB {
|
||||||
|
format!("{} KB", bytes / KB)
|
||||||
|
} else {
|
||||||
|
format!("{} B", bytes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn read_dir(path: &Path) -> Vec<FileEntry> {
|
||||||
|
let mut entries = Vec::new();
|
||||||
|
let Ok(dir) = fs::read_dir(path) else {
|
||||||
|
return entries;
|
||||||
|
};
|
||||||
|
|
||||||
|
for entry in dir.flatten() {
|
||||||
|
let name = entry.file_name().to_string_lossy().to_string();
|
||||||
|
let path = entry.path();
|
||||||
|
let is_hidden = name.starts_with('.');
|
||||||
|
|
||||||
|
let (file_type, size) = if let Ok(meta) = entry.metadata() {
|
||||||
|
let ft = if meta.is_symlink() {
|
||||||
|
FileType::Symlink
|
||||||
|
} else if meta.is_dir() {
|
||||||
|
FileType::Directory
|
||||||
|
} else {
|
||||||
|
FileType::File
|
||||||
|
};
|
||||||
|
(ft, meta.len())
|
||||||
|
} else {
|
||||||
|
(FileType::File, 0)
|
||||||
|
};
|
||||||
|
|
||||||
|
entries.push(FileEntry { name, path, file_type, size, is_hidden });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort: dirs first, then files, both alphabetically
|
||||||
|
entries.sort_by(|a, b| {
|
||||||
|
match (&a.file_type, &b.file_type) {
|
||||||
|
(FileType::Directory, FileType::Directory) => a.name.to_lowercase().cmp(&b.name.to_lowercase()),
|
||||||
|
(FileType::Directory, _) => std::cmp::Ordering::Less,
|
||||||
|
(_, FileType::Directory) => std::cmp::Ordering::Greater,
|
||||||
|
_ => a.name.to_lowercase().cmp(&b.name.to_lowercase()),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
entries
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Recursively compute total size and file count of a path
|
||||||
|
pub fn recursive_info(path: &Path) -> (u64, u64) {
|
||||||
|
let mut total_size: u64 = 0;
|
||||||
|
let mut file_count: u64 = 0;
|
||||||
|
|
||||||
|
if path.is_file() {
|
||||||
|
let size = path.metadata().map(|m| m.len()).unwrap_or(0);
|
||||||
|
return (size, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Ok(dir) = fs::read_dir(path) {
|
||||||
|
for entry in dir.flatten() {
|
||||||
|
let p = entry.path();
|
||||||
|
if p.is_dir() {
|
||||||
|
let (s, c) = recursive_info(&p);
|
||||||
|
total_size += s;
|
||||||
|
file_count += c;
|
||||||
|
} else {
|
||||||
|
total_size += p.metadata().map(|m| m.len()).unwrap_or(0);
|
||||||
|
file_count += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
(total_size, file_count)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
pub fn file_permissions(path: &Path) -> String {
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
let mode = path.metadata().map(|m| m.permissions().mode()).unwrap_or(0);
|
||||||
|
format!("{:o}", mode & 0o777)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
pub fn file_permissions(_path: &Path) -> String {
|
||||||
|
String::from("N/A")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn file_owner(path: &Path) -> (String, String) {
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::os::unix::fs::MetadataExt;
|
||||||
|
if let Ok(meta) = path.metadata() {
|
||||||
|
let uid = meta.uid();
|
||||||
|
let gid = meta.gid();
|
||||||
|
let user_name = lookup_name("/etc/passwd", uid)
|
||||||
|
.map(|n| format!("{} ({})", n, uid))
|
||||||
|
.unwrap_or_else(|| uid.to_string());
|
||||||
|
let group_name = lookup_name("/etc/group", gid)
|
||||||
|
.map(|n| format!("{} ({})", n, gid))
|
||||||
|
.unwrap_or_else(|| gid.to_string());
|
||||||
|
return (user_name, group_name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[cfg(windows)]
|
||||||
|
{
|
||||||
|
let _ = path;
|
||||||
|
}
|
||||||
|
(String::from("N/A"), String::from("N/A"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse /etc/passwd or /etc/group and return the name for a given numeric id.
|
||||||
|
/// Both files share the same format: `name:x:id:...`
|
||||||
|
#[cfg(unix)]
|
||||||
|
fn lookup_name(db_file: &str, id: u32) -> Option<String> {
|
||||||
|
let content = std::fs::read_to_string(db_file).ok()?;
|
||||||
|
for line in content.lines() {
|
||||||
|
let mut parts = line.splitn(4, ':');
|
||||||
|
let name = parts.next()?;
|
||||||
|
parts.next(); // password/placeholder
|
||||||
|
let entry_id: u32 = parts.next()?.parse().ok()?;
|
||||||
|
if entry_id == id {
|
||||||
|
return Some(name.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Copy a file or directory recursively
|
||||||
|
pub fn copy_entry(src: &Path, dst_dir: &Path) -> Result<(), String> {
|
||||||
|
let name = src.file_name().ok_or("No filename")?;
|
||||||
|
let dst = dst_dir.join(name);
|
||||||
|
|
||||||
|
if src.is_dir() {
|
||||||
|
fs_extra::dir::copy(
|
||||||
|
src,
|
||||||
|
dst_dir,
|
||||||
|
&fs_extra::dir::CopyOptions::new(),
|
||||||
|
)
|
||||||
|
.map(|_| ())
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
} else {
|
||||||
|
fs::copy(src, &dst).map(|_| ()).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Move (cut/paste) a file or directory
|
||||||
|
pub fn move_entry(src: &Path, dst_dir: &Path) -> Result<(), String> {
|
||||||
|
let name = src.file_name().ok_or("No filename")?;
|
||||||
|
let dst = dst_dir.join(name);
|
||||||
|
|
||||||
|
if src.is_dir() {
|
||||||
|
fs_extra::dir::move_dir(
|
||||||
|
src,
|
||||||
|
dst_dir,
|
||||||
|
&fs_extra::dir::CopyOptions::new(),
|
||||||
|
)
|
||||||
|
.map(|_| ())
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
} else {
|
||||||
|
fs::rename(src, &dst).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn delete_entry(path: &Path) -> Result<(), String> {
|
||||||
|
if path.is_dir() {
|
||||||
|
fs::remove_dir_all(path).map_err(|e| e.to_string())
|
||||||
|
} else {
|
||||||
|
fs::remove_file(path).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_root_dirs() -> Vec<PathBuf> {
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
let roots = ["/", "/home", "/etc", "/usr", "/var", "/opt", "/tmp"];
|
||||||
|
roots
|
||||||
|
.iter()
|
||||||
|
.filter_map(|r| {
|
||||||
|
let p = PathBuf::from(r);
|
||||||
|
if p.exists() { Some(p) } else { None }
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
#[cfg(windows)]
|
||||||
|
{
|
||||||
|
let mut drives = Vec::new();
|
||||||
|
for letter in b'A'..=b'Z' {
|
||||||
|
let d = format!("{}:\\", letter as char);
|
||||||
|
let p = PathBuf::from(&d);
|
||||||
|
if p.exists() {
|
||||||
|
drives.push(p);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
drives
|
||||||
|
}
|
||||||
|
}
|
||||||
41
src/main.rs
Normal file
41
src/main.rs
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
mod app;
|
||||||
|
mod config;
|
||||||
|
mod file_ops;
|
||||||
|
mod ui;
|
||||||
|
mod events;
|
||||||
|
|
||||||
|
use std::io;
|
||||||
|
use crossterm::{
|
||||||
|
event::{DisableMouseCapture, EnableMouseCapture},
|
||||||
|
execute,
|
||||||
|
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
|
||||||
|
};
|
||||||
|
use ratatui::{backend::CrosstermBackend, Terminal};
|
||||||
|
|
||||||
|
use app::App;
|
||||||
|
use events::run_app;
|
||||||
|
|
||||||
|
fn main() -> io::Result<()> {
|
||||||
|
enable_raw_mode()?;
|
||||||
|
let mut stdout = io::stdout();
|
||||||
|
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
|
||||||
|
let backend = CrosstermBackend::new(stdout);
|
||||||
|
let mut terminal = Terminal::new(backend)?;
|
||||||
|
|
||||||
|
let mut app = App::new();
|
||||||
|
let res = run_app(&mut terminal, &mut app);
|
||||||
|
|
||||||
|
disable_raw_mode()?;
|
||||||
|
execute!(
|
||||||
|
terminal.backend_mut(),
|
||||||
|
LeaveAlternateScreen,
|
||||||
|
DisableMouseCapture
|
||||||
|
)?;
|
||||||
|
terminal.show_cursor()?;
|
||||||
|
|
||||||
|
if let Err(err) = res {
|
||||||
|
eprintln!("Error: {:?}", err);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
614
src/ui.rs
Normal file
614
src/ui.rs
Normal file
@@ -0,0 +1,614 @@
|
|||||||
|
use ratatui::{
|
||||||
|
layout::{Constraint, Direction, Layout, Margin, Rect},
|
||||||
|
style::{Color, Modifier, Style},
|
||||||
|
text::{Line, Span},
|
||||||
|
widgets::{
|
||||||
|
Block, Borders, Cell, Clear, List, ListItem, Paragraph,
|
||||||
|
Row, Scrollbar, ScrollbarOrientation, ScrollbarState,
|
||||||
|
Table, TableState, Wrap,
|
||||||
|
},
|
||||||
|
Frame,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::app::{App, ContextMenuItem, DialogMode, Focus};
|
||||||
|
use crate::file_ops::{self, format_size};
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct LayoutAreas {
|
||||||
|
pub sidebar: Rect,
|
||||||
|
pub file_view: Rect,
|
||||||
|
pub status_bar: Rect,
|
||||||
|
pub path_input: Rect,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compute the layout areas for hit testing (without a frame)
|
||||||
|
pub fn compute_layout(_app: &App) -> LayoutAreas {
|
||||||
|
// We use a dummy size; in real rendering ratatui provides the actual size.
|
||||||
|
// For hit-testing, we just need approximate positions.
|
||||||
|
// We'll use the terminal size from crossterm.
|
||||||
|
let (width, height) = crossterm::terminal::size().unwrap_or((120, 40));
|
||||||
|
let full = Rect::new(0, 0, width, height);
|
||||||
|
|
||||||
|
split_layout(full)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn split_layout(area: Rect) -> LayoutAreas {
|
||||||
|
let main_chunks = Layout::default()
|
||||||
|
.direction(Direction::Vertical)
|
||||||
|
.constraints([
|
||||||
|
Constraint::Min(5),
|
||||||
|
Constraint::Length(3),
|
||||||
|
])
|
||||||
|
.split(area);
|
||||||
|
|
||||||
|
let content_chunks = Layout::default()
|
||||||
|
.direction(Direction::Horizontal)
|
||||||
|
.constraints([
|
||||||
|
Constraint::Length(22),
|
||||||
|
Constraint::Min(10),
|
||||||
|
])
|
||||||
|
.split(main_chunks[0]);
|
||||||
|
|
||||||
|
let bottom_chunks = Layout::default()
|
||||||
|
.direction(Direction::Horizontal)
|
||||||
|
.constraints([
|
||||||
|
Constraint::Min(10),
|
||||||
|
])
|
||||||
|
.split(main_chunks[1]);
|
||||||
|
|
||||||
|
LayoutAreas {
|
||||||
|
sidebar: content_chunks[0],
|
||||||
|
file_view: content_chunks[1],
|
||||||
|
status_bar: main_chunks[1],
|
||||||
|
path_input: bottom_chunks[0],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn draw(f: &mut Frame, app: &mut App) {
|
||||||
|
let area = f.area();
|
||||||
|
let layout = split_layout(area);
|
||||||
|
|
||||||
|
draw_sidebar(f, app, layout.sidebar);
|
||||||
|
draw_file_view(f, app, layout.file_view);
|
||||||
|
draw_bottom_bar(f, app, layout.status_bar);
|
||||||
|
|
||||||
|
if let Some(ref menu) = app.context_menu.clone() {
|
||||||
|
draw_context_menu(f, app, menu.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
match &app.dialog.clone() {
|
||||||
|
DialogMode::Rename(text) => draw_input_dialog(f, "Byt namn", text, area),
|
||||||
|
DialogMode::NewFile(text) => draw_input_dialog(f, "Ny fil - Ange namn:", text, area),
|
||||||
|
DialogMode::NewDir(text) => draw_input_dialog(f, "Ny mapp - Ange namn:", text, area),
|
||||||
|
DialogMode::Properties(info) => draw_properties(f, info.clone(), area),
|
||||||
|
DialogMode::Error(msg) => draw_error_dialog(f, msg, area),
|
||||||
|
DialogMode::Confirm(msg, _) => draw_confirm_dialog(f, msg, area),
|
||||||
|
DialogMode::None => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 inner = block.inner(area);
|
||||||
|
f.render_widget(block, area);
|
||||||
|
|
||||||
|
let mut items: Vec<ListItem> = Vec::new();
|
||||||
|
|
||||||
|
// Up-level button
|
||||||
|
items.push(ListItem::new(Line::from(vec![
|
||||||
|
Span::styled(" ↑ Gå upp", Style::default().fg(Color::Magenta).add_modifier(Modifier::BOLD)),
|
||||||
|
])));
|
||||||
|
|
||||||
|
// Favorites section
|
||||||
|
items.push(ListItem::new(Line::from(vec![
|
||||||
|
Span::styled("FAVORITER", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
|
||||||
|
])));
|
||||||
|
|
||||||
|
if app.config.favorites.is_empty() {
|
||||||
|
items.push(ListItem::new(Line::from(vec![
|
||||||
|
Span::styled(" (inga)", Style::default().fg(Color::DarkGray)),
|
||||||
|
])));
|
||||||
|
} else {
|
||||||
|
for fav in &app.config.favorites {
|
||||||
|
let name = std::path::Path::new(fav)
|
||||||
|
.file_name()
|
||||||
|
.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)
|
||||||
|
};
|
||||||
|
items.push(ListItem::new(Line::from(vec![
|
||||||
|
Span::styled(format!(" ★ {}", name), style),
|
||||||
|
])));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
items.push(ListItem::new(Line::from(vec![
|
||||||
|
Span::styled("─────────────────", Style::default().fg(Color::DarkGray)),
|
||||||
|
])));
|
||||||
|
|
||||||
|
// Root directories
|
||||||
|
items.push(ListItem::new(Line::from(vec![
|
||||||
|
Span::styled("SÖKVÄGAR", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
|
||||||
|
])));
|
||||||
|
|
||||||
|
let roots = file_ops::get_root_dirs();
|
||||||
|
for root in &roots {
|
||||||
|
let label = root.to_string_lossy();
|
||||||
|
let is_current = root == &app.current_path;
|
||||||
|
let style = if is_current {
|
||||||
|
Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)
|
||||||
|
} else {
|
||||||
|
Style::default().fg(Color::White)
|
||||||
|
};
|
||||||
|
items.push(ListItem::new(Line::from(vec![
|
||||||
|
Span::styled(format!(" 📁 {}", label), style),
|
||||||
|
])));
|
||||||
|
}
|
||||||
|
|
||||||
|
items.push(ListItem::new(Line::from(vec![
|
||||||
|
Span::styled("─────────────────", Style::default().fg(Color::DarkGray)),
|
||||||
|
])));
|
||||||
|
|
||||||
|
// Add favorite button
|
||||||
|
items.push(ListItem::new(Line::from(vec![
|
||||||
|
Span::styled(" [+ Favorit]", Style::default().fg(Color::Green).add_modifier(Modifier::BOLD)),
|
||||||
|
])));
|
||||||
|
|
||||||
|
// Apply scroll offset
|
||||||
|
let visible: Vec<ListItem> = items
|
||||||
|
.into_iter()
|
||||||
|
.skip(app.sidebar_scroll)
|
||||||
|
.take(inner.height as usize)
|
||||||
|
.collect();
|
||||||
|
let total_sidebar = visible.len() + app.sidebar_scroll;
|
||||||
|
|
||||||
|
let list = List::new(visible);
|
||||||
|
f.render_widget(list, inner);
|
||||||
|
|
||||||
|
// Scrollbar for sidebar
|
||||||
|
if total_sidebar > inner.height as usize {
|
||||||
|
let mut sb_state = ScrollbarState::new(total_sidebar).position(app.sidebar_scroll);
|
||||||
|
let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight)
|
||||||
|
.begin_symbol(None)
|
||||||
|
.end_symbol(None)
|
||||||
|
.thumb_symbol("█");
|
||||||
|
f.render_stateful_widget(
|
||||||
|
scrollbar,
|
||||||
|
area.inner(Margin { vertical: 1, horizontal: 0 }),
|
||||||
|
&mut sb_state,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Build rows from ALL entries (TableState offset handles which rows are visible)
|
||||||
|
let rows: Vec<Row> = app
|
||||||
|
.entries
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(idx, entry)| {
|
||||||
|
let is_selected = app.selected_indices.contains(&idx);
|
||||||
|
let name_cell = format!("{} {}", entry.icon(), entry.name);
|
||||||
|
let size_cell = entry.formatted_size();
|
||||||
|
|
||||||
|
let style = if is_selected {
|
||||||
|
Style::default().bg(Color::Blue).fg(Color::White).add_modifier(Modifier::BOLD)
|
||||||
|
} else if entry.is_dir() {
|
||||||
|
Style::default().fg(Color::LightBlue)
|
||||||
|
} else {
|
||||||
|
Style::default().fg(Color::White)
|
||||||
|
};
|
||||||
|
|
||||||
|
Row::new(vec![
|
||||||
|
Cell::from(name_cell),
|
||||||
|
Cell::from(size_cell).style(Style::default().fg(if is_selected { Color::White } else { Color::DarkGray })),
|
||||||
|
])
|
||||||
|
.style(style)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let header = Row::new(vec![
|
||||||
|
Cell::from("Namn"),
|
||||||
|
Cell::from("Storlek"),
|
||||||
|
])
|
||||||
|
.style(Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD | 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);
|
||||||
|
|
||||||
|
// Column widths: name takes remaining space, size is fixed
|
||||||
|
let widths = [Constraint::Min(10), Constraint::Length(10)];
|
||||||
|
|
||||||
|
let table = Table::new(rows, widths)
|
||||||
|
.header(header)
|
||||||
|
.block(block)
|
||||||
|
.column_spacing(1);
|
||||||
|
|
||||||
|
let mut table_state = TableState::default();
|
||||||
|
*table_state.offset_mut() = app.scroll_offset;
|
||||||
|
|
||||||
|
f.render_stateful_widget(table, area, &mut table_state);
|
||||||
|
|
||||||
|
// Scrollbar — rendered over the right border of the block
|
||||||
|
let total = app.entries.len();
|
||||||
|
let visible = area.height.saturating_sub(3) as usize; // -2 borders -1 header
|
||||||
|
if total > visible {
|
||||||
|
let mut sb_state = ScrollbarState::new(total).position(app.scroll_offset);
|
||||||
|
let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight)
|
||||||
|
.begin_symbol(Some("▲"))
|
||||||
|
.end_symbol(Some("▼"))
|
||||||
|
.track_symbol(Some("│"))
|
||||||
|
.thumb_symbol("█");
|
||||||
|
f.render_stateful_widget(
|
||||||
|
scrollbar,
|
||||||
|
area.inner(Margin { vertical: 1, horizontal: 0 }),
|
||||||
|
&mut sb_state,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Selection count badge
|
||||||
|
if !app.selected_indices.is_empty() {
|
||||||
|
let sel_text = format!(" {} markerade ", app.selected_indices.len());
|
||||||
|
let inner = Block::default().borders(Borders::ALL).inner(area);
|
||||||
|
let badge_area = Rect::new(
|
||||||
|
inner.x + inner.width.saturating_sub(sel_text.len() as u16 + 2),
|
||||||
|
inner.y,
|
||||||
|
sel_text.len() as u16 + 2,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
f.render_widget(
|
||||||
|
Paragraph::new(sel_text).style(Style::default().fg(Color::Cyan).bg(Color::DarkGray)),
|
||||||
|
badge_area,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 inner = outer_block.inner(area);
|
||||||
|
f.render_widget(outer_block, area);
|
||||||
|
|
||||||
|
// Left: status message or hint
|
||||||
|
let hint = app
|
||||||
|
.status_message
|
||||||
|
.as_deref()
|
||||||
|
.unwrap_or("Tab=fokus Backspace=upp Ctrl+C/X/V=kopiera/klipp/klistra q=avsluta");
|
||||||
|
|
||||||
|
let hint_width = (inner.width / 3).min(hint.len() as u16 + 2);
|
||||||
|
let hint_area = Rect::new(inner.x, inner.y, hint_width, inner.height);
|
||||||
|
f.render_widget(
|
||||||
|
Paragraph::new(hint)
|
||||||
|
.style(Style::default().fg(Color::DarkGray))
|
||||||
|
.wrap(Wrap { trim: true }),
|
||||||
|
hint_area,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Right: path input
|
||||||
|
let input_x = inner.x + hint_width + 1;
|
||||||
|
let input_width = inner.width.saturating_sub(hint_width + 1);
|
||||||
|
if input_width < 4 { return; }
|
||||||
|
|
||||||
|
let input_area = Rect::new(input_x, inner.y, input_width, inner.height);
|
||||||
|
|
||||||
|
let label = "Sökväg: ";
|
||||||
|
let label_len = label.len() as u16;
|
||||||
|
let cursor_pos = app.path_input_cursor;
|
||||||
|
let avail = input_width.saturating_sub(label_len) as usize;
|
||||||
|
|
||||||
|
// Scroll the input text to show cursor
|
||||||
|
let (display_text, cursor_in_display) =
|
||||||
|
scroll_input_text(&app.path_input, cursor_pos, avail);
|
||||||
|
|
||||||
|
let mut spans = vec![
|
||||||
|
Span::styled(label, Style::default().fg(Color::Yellow)),
|
||||||
|
];
|
||||||
|
|
||||||
|
if focused {
|
||||||
|
// Render with cursor highlight
|
||||||
|
let before = &display_text[..cursor_in_display.min(display_text.len())];
|
||||||
|
let cursor_char = display_text
|
||||||
|
.chars()
|
||||||
|
.nth(cursor_in_display)
|
||||||
|
.map(|c| c.to_string())
|
||||||
|
.unwrap_or_else(|| " ".to_string());
|
||||||
|
let after = if cursor_in_display + 1 <= display_text.len() {
|
||||||
|
&display_text[cursor_in_display + cursor_char.len()..]
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
};
|
||||||
|
|
||||||
|
spans.push(Span::styled(before, Style::default().fg(Color::White)));
|
||||||
|
spans.push(Span::styled(cursor_char, Style::default().fg(Color::Black).bg(Color::White)));
|
||||||
|
spans.push(Span::styled(after, Style::default().fg(Color::White)));
|
||||||
|
} else {
|
||||||
|
spans.push(Span::styled(&display_text, Style::default().fg(Color::White)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let para = Paragraph::new(Line::from(spans));
|
||||||
|
f.render_widget(para, input_area);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn scroll_input_text(text: &str, cursor: usize, avail: usize) -> (String, usize) {
|
||||||
|
let len = text.len();
|
||||||
|
if len <= avail {
|
||||||
|
return (text.to_string(), cursor);
|
||||||
|
}
|
||||||
|
// Scroll to show cursor
|
||||||
|
let start = if cursor > avail { cursor - avail } else { 0 };
|
||||||
|
let end = (start + avail).min(len);
|
||||||
|
let slice = &text[start..end];
|
||||||
|
let cursor_in_display = cursor.saturating_sub(start);
|
||||||
|
(slice.to_string(), cursor_in_display)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_context_menu(f: &mut Frame, app: &App, menu: crate::app::ContextMenu) {
|
||||||
|
let items = &menu.items;
|
||||||
|
if items.is_empty() { return; }
|
||||||
|
|
||||||
|
// Calculate menu dimensions
|
||||||
|
let menu_width: u16 = 24;
|
||||||
|
let menu_height = items.len() as u16 + 2;
|
||||||
|
|
||||||
|
let term_area = f.area();
|
||||||
|
let x = menu.x.min(term_area.width.saturating_sub(menu_width));
|
||||||
|
let y = menu.y.min(term_area.height.saturating_sub(menu_height));
|
||||||
|
|
||||||
|
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 inner = block.inner(menu_area);
|
||||||
|
f.render_widget(block, menu_area);
|
||||||
|
|
||||||
|
let mut show_new_sub = false;
|
||||||
|
let mut new_sub_row: u16 = 0;
|
||||||
|
|
||||||
|
for (i, item) in items.iter().enumerate() {
|
||||||
|
let row = Rect::new(inner.x, inner.y + i as u16, inner.width, 1);
|
||||||
|
let is_sel = i == menu.selected;
|
||||||
|
|
||||||
|
let (text, style) = match item {
|
||||||
|
ContextMenuItem::Copy => (" 📋 Kopiera ", Style::default()),
|
||||||
|
ContextMenuItem::Cut => (" ✂ Klipp ut ", Style::default()),
|
||||||
|
ContextMenuItem::Paste => (" 📌 Klistra in ", Style::default()),
|
||||||
|
ContextMenuItem::Rename => (" ✏ Byt namn ", Style::default()),
|
||||||
|
ContextMenuItem::Properties => (" ℹ Egenskaper ", Style::default()),
|
||||||
|
ContextMenuItem::Delete => (" 🗑 Ta bort ", Style::default().fg(Color::Red)),
|
||||||
|
ContextMenuItem::NewSubmenu => {
|
||||||
|
if is_sel { show_new_sub = true; new_sub_row = inner.y + i as u16; }
|
||||||
|
(" ➕ Ny ▶ ", Style::default().fg(Color::Green))
|
||||||
|
}
|
||||||
|
ContextMenuItem::AddFavorite => (" ★ Lägg till fav ", Style::default().fg(Color::Yellow)),
|
||||||
|
ContextMenuItem::RemoveFavorite(_) => (" ✖ Ta bort favorit ", Style::default().fg(Color::Red)),
|
||||||
|
ContextMenuItem::Separator => (" ──────────────── ", Style::default().fg(Color::DarkGray)),
|
||||||
|
ContextMenuItem::NewFile => (" 📄 Ny fil ", Style::default().fg(Color::Green)),
|
||||||
|
ContextMenuItem::NewDir => (" 📁 Ny mapp ", Style::default().fg(Color::Green)),
|
||||||
|
};
|
||||||
|
|
||||||
|
let bg = if is_sel && !matches!(item, ContextMenuItem::Separator) {
|
||||||
|
Style::default().bg(Color::Blue).fg(Color::White)
|
||||||
|
} else {
|
||||||
|
style
|
||||||
|
};
|
||||||
|
|
||||||
|
f.render_widget(
|
||||||
|
Paragraph::new(text).style(bg),
|
||||||
|
row,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw submenu for "Ny" if selected
|
||||||
|
if show_new_sub {
|
||||||
|
let sub_x = x + menu_width;
|
||||||
|
let sub_area = Rect::new(sub_x.min(term_area.width.saturating_sub(18)), new_sub_row, 18, 4);
|
||||||
|
f.render_widget(Clear, sub_area);
|
||||||
|
|
||||||
|
let sub_block = Block::default()
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.border_style(Style::default().fg(Color::Green));
|
||||||
|
let sub_inner = sub_block.inner(sub_area);
|
||||||
|
f.render_widget(sub_block, sub_area);
|
||||||
|
|
||||||
|
let sub_item0_style = if menu.sub_selected == Some(0) {
|
||||||
|
Style::default().bg(Color::Blue).fg(Color::White)
|
||||||
|
} else {
|
||||||
|
Style::default().fg(Color::White)
|
||||||
|
};
|
||||||
|
let sub_item1_style = if menu.sub_selected == Some(1) {
|
||||||
|
Style::default().bg(Color::Blue).fg(Color::White)
|
||||||
|
} else {
|
||||||
|
Style::default().fg(Color::White)
|
||||||
|
};
|
||||||
|
f.render_widget(
|
||||||
|
Paragraph::new(" 📄 Ny fil ").style(sub_item0_style),
|
||||||
|
Rect::new(sub_inner.x, sub_inner.y, sub_inner.width, 1),
|
||||||
|
);
|
||||||
|
f.render_widget(
|
||||||
|
Paragraph::new(" 📁 Ny mapp ").style(sub_item1_style),
|
||||||
|
Rect::new(sub_inner.x, sub_inner.y + 1, sub_inner.width, 1),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_input_dialog(f: &mut Frame, title: &str, current: &str, area: Rect) {
|
||||||
|
let dlg_width = 50u16.min(area.width.saturating_sub(4));
|
||||||
|
let dlg_height = 5u16;
|
||||||
|
let x = (area.width.saturating_sub(dlg_width)) / 2;
|
||||||
|
let y = (area.height.saturating_sub(dlg_height)) / 2;
|
||||||
|
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 inner = block.inner(dlg);
|
||||||
|
f.render_widget(block, dlg);
|
||||||
|
|
||||||
|
// Input field with cursor
|
||||||
|
let display = format!("{}_", current);
|
||||||
|
f.render_widget(
|
||||||
|
Paragraph::new(display)
|
||||||
|
.style(Style::default().fg(Color::White)),
|
||||||
|
Rect::new(inner.x + 1, inner.y + 1, inner.width.saturating_sub(2), 1),
|
||||||
|
);
|
||||||
|
f.render_widget(
|
||||||
|
Paragraph::new("Enter=bekräfta Esc=avbryt")
|
||||||
|
.style(Style::default().fg(Color::DarkGray)),
|
||||||
|
Rect::new(inner.x + 1, inner.y + 2, inner.width.saturating_sub(2), 1),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_properties(f: &mut Frame, info: crate::app::PropertiesInfo, area: Rect) {
|
||||||
|
let dlg_width = 56u16.min(area.width.saturating_sub(4));
|
||||||
|
let dlg_height = 13u16;
|
||||||
|
let x = (area.width.saturating_sub(dlg_width)) / 2;
|
||||||
|
let y = (area.height.saturating_sub(dlg_height)) / 2;
|
||||||
|
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 inner = block.inner(dlg);
|
||||||
|
f.render_widget(block, dlg);
|
||||||
|
|
||||||
|
let label_style = Style::default().fg(Color::Yellow);
|
||||||
|
let value_style = Style::default().fg(Color::White);
|
||||||
|
let hint_style = Style::default().fg(Color::DarkGray);
|
||||||
|
let label_w = 14usize;
|
||||||
|
let val_w = (inner.width as usize).saturating_sub(label_w + 2);
|
||||||
|
|
||||||
|
let rows: &[(&str, String)] = &[
|
||||||
|
("Namn", info.name.clone()),
|
||||||
|
("Sökväg", truncate_str(&info.path, val_w)),
|
||||||
|
("Storlek", format_size(info.size).to_string()),
|
||||||
|
("Filer", if info.is_multi || info.file_count > 0 { info.file_count.to_string() } else { String::from("-") }),
|
||||||
|
("Rättigheter", info.permissions.clone()),
|
||||||
|
("Ägare", info.owner_user.clone()),
|
||||||
|
("Grupp", info.owner_group.clone()),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (i, (label, value)) in rows.iter().enumerate() {
|
||||||
|
if i as u16 + 1 >= inner.height { break; }
|
||||||
|
let row_y = inner.y + i as u16;
|
||||||
|
// Label
|
||||||
|
f.render_widget(
|
||||||
|
Paragraph::new(format!("{:<width$}", label, width = label_w)).style(label_style),
|
||||||
|
Rect::new(inner.x + 1, row_y, label_w as u16, 1),
|
||||||
|
);
|
||||||
|
// Value
|
||||||
|
f.render_widget(
|
||||||
|
Paragraph::new(value.as_str()).style(value_style),
|
||||||
|
Rect::new(inner.x + 1 + label_w as u16, row_y, val_w as u16, 1),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hint at the bottom
|
||||||
|
let hint_y = inner.y + inner.height.saturating_sub(1);
|
||||||
|
f.render_widget(
|
||||||
|
Paragraph::new("Stäng: Enter / Esc").style(hint_style),
|
||||||
|
Rect::new(inner.x + 1, hint_y, inner.width.saturating_sub(2), 1),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_error_dialog(f: &mut Frame, msg: &str, area: Rect) {
|
||||||
|
let dlg_width = 50u16.min(area.width.saturating_sub(4));
|
||||||
|
let dlg_height = 5u16;
|
||||||
|
let x = (area.width.saturating_sub(dlg_width)) / 2;
|
||||||
|
let y = (area.height.saturating_sub(dlg_height)) / 2;
|
||||||
|
let dlg = Rect::new(x, y, dlg_width, dlg_height);
|
||||||
|
|
||||||
|
f.render_widget(Clear, dlg);
|
||||||
|
let block = Block::default()
|
||||||
|
.title(" Fel ")
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.border_style(Style::default().fg(Color::Red));
|
||||||
|
let inner = block.inner(dlg);
|
||||||
|
f.render_widget(block, dlg);
|
||||||
|
|
||||||
|
f.render_widget(
|
||||||
|
Paragraph::new(msg).style(Style::default().fg(Color::Red)),
|
||||||
|
Rect::new(inner.x + 1, inner.y, inner.width.saturating_sub(2), 1),
|
||||||
|
);
|
||||||
|
f.render_widget(
|
||||||
|
Paragraph::new("Stäng: Enter / Esc").style(Style::default().fg(Color::DarkGray)),
|
||||||
|
Rect::new(inner.x + 1, inner.y + 2, inner.width.saturating_sub(2), 1),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_confirm_dialog(f: &mut Frame, msg: &str, area: Rect) {
|
||||||
|
let dlg_width = 50u16.min(area.width.saturating_sub(4));
|
||||||
|
let dlg_height = 5u16;
|
||||||
|
let x = (area.width.saturating_sub(dlg_width)) / 2;
|
||||||
|
let y = (area.height.saturating_sub(dlg_height)) / 2;
|
||||||
|
let dlg = Rect::new(x, y, dlg_width, dlg_height);
|
||||||
|
|
||||||
|
f.render_widget(Clear, dlg);
|
||||||
|
let block = Block::default()
|
||||||
|
.title(" Bekräfta ")
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.border_style(Style::default().fg(Color::Yellow));
|
||||||
|
let inner = block.inner(dlg);
|
||||||
|
f.render_widget(block, dlg);
|
||||||
|
|
||||||
|
f.render_widget(
|
||||||
|
Paragraph::new(msg).style(Style::default().fg(Color::White)),
|
||||||
|
Rect::new(inner.x + 1, inner.y, inner.width.saturating_sub(2), 1),
|
||||||
|
);
|
||||||
|
f.render_widget(
|
||||||
|
Paragraph::new("y=ja Annan tangent=avbryt").style(Style::default().fg(Color::DarkGray)),
|
||||||
|
Rect::new(inner.x + 1, inner.y + 2, inner.width.saturating_sub(2), 1),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn truncate_str(s: &str, max: usize) -> String {
|
||||||
|
if s.len() <= max {
|
||||||
|
s.to_string()
|
||||||
|
} else if max <= 3 {
|
||||||
|
s[..max].to_string()
|
||||||
|
} else {
|
||||||
|
format!("{}...", &s[..max.saturating_sub(3)])
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user