feat: add configuration management and panel rendering
- Introduced a new `Config` struct for loading application configuration from a TOML file. - Added support for keybindings and panel configurations, including status widgets. - Implemented `PtyTerminal` for handling pseudo-terminal interactions. - Created a rendering module to manage the display of application windows, panels, and dropdowns. - Refactored the main application loop to utilize the new configuration and rendering logic. - Removed legacy code related to event handling and rendering from `main.rs`.
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1 +1,2 @@
|
||||
/target
|
||||
/builds
|
||||
|
||||
40
.vscode/tasks.json
vendored
40
.vscode/tasks.json
vendored
@@ -4,54 +4,50 @@
|
||||
{
|
||||
"label": "Build (native)",
|
||||
"type": "shell",
|
||||
"command": "cargo build --release",
|
||||
"group": "build",
|
||||
"linux": {
|
||||
"command": "cargo build --release && mkdir -p builds/linux/panels && cp target/release/tui-wm builds/linux/tui-wm && cp dist/linux/config.toml builds/linux/config.toml && cp dist/linux/panels/topbar.toml builds/linux/panels/topbar.toml && echo 'Done: builds/linux/tui-wm'"
|
||||
},
|
||||
"windows": {
|
||||
"command": "& \"$env:USERPROFILE\\.cargo\\bin\\cargo.exe\" build --release; if ($LASTEXITCODE -eq 0) { New-Item -Force -ItemType Directory builds\\win\\panels | Out-Null; Copy-Item target\\release\\tui-wm.exe builds\\win\\tui-wm.exe -Force; Copy-Item dist\\win\\config.toml builds\\win\\config.toml -Force; Copy-Item dist\\win\\panels\\topbar.toml builds\\win\\panels\\topbar.toml -Force; Write-Host 'Done: builds\\win\\tui-wm.exe' }",
|
||||
"options": { "shell": { "executable": "powershell.exe", "args": ["-NoProfile", "-Command"] } }
|
||||
},
|
||||
"group": { "kind": "build", "isDefault": true },
|
||||
"presentation": { "reveal": "always", "panel": "shared" },
|
||||
"problemMatcher": ["$rustc"]
|
||||
},
|
||||
{
|
||||
"label": "Build Linux",
|
||||
"type": "shell",
|
||||
"command": "cargo build --release --target x86_64-unknown-linux-gnu",
|
||||
"group": "build",
|
||||
"command": "cargo build --release --target x86_64-unknown-linux-gnu && mkdir -p builds/linux/panels && cp target/x86_64-unknown-linux-gnu/release/tui-wm builds/linux/tui-wm && cp dist/linux/config.toml builds/linux/config.toml && cp dist/linux/panels/topbar.toml builds/linux/panels/topbar.toml && echo 'Done: builds/linux/tui-wm'",
|
||||
"options": { "shell": { "executable": "bash", "args": ["-c"] } },
|
||||
"group": "build",
|
||||
"presentation": { "reveal": "always", "panel": "shared" },
|
||||
"problemMatcher": ["$rustc"]
|
||||
},
|
||||
{
|
||||
"label": "Build Windows (cross från Linux, GNU)",
|
||||
"label": "Build Windows cross (GNU, från Linux)",
|
||||
"type": "shell",
|
||||
"command": "cargo build --release --target x86_64-pc-windows-gnu",
|
||||
"group": "build",
|
||||
"command": "cargo build --release --target x86_64-pc-windows-gnu && mkdir -p builds/win/panels && cp target/x86_64-pc-windows-gnu/release/tui-wm.exe builds/win/tui-wm.exe && cp dist/win/config.toml builds/win/config.toml && cp dist/win/panels/topbar.toml builds/win/panels/topbar.toml && echo 'Done: builds/win/tui-wm.exe'",
|
||||
"options": { "shell": { "executable": "bash", "args": ["-c"] } },
|
||||
"group": "build",
|
||||
"presentation": { "reveal": "always", "panel": "shared" },
|
||||
"problemMatcher": ["$rustc"]
|
||||
},
|
||||
{
|
||||
"label": "Build Windows (MSVC — kräver VS Build Tools)",
|
||||
"type": "shell",
|
||||
"command": "& \"$env:USERPROFILE\\.cargo\\bin\\cargo.exe\" build --release --target x86_64-pc-windows-msvc",
|
||||
"command": "& \"$env:USERPROFILE\\.cargo\\bin\\cargo.exe\" build --release --target x86_64-pc-windows-msvc; if ($LASTEXITCODE -eq 0) { New-Item -Force -ItemType Directory builds\\win\\panels | Out-Null; Copy-Item target\\x86_64-pc-windows-msvc\\release\\tui-wm.exe builds\\win\\tui-wm.exe -Force; Copy-Item dist\\win\\config.toml builds\\win\\config.toml -Force; Copy-Item dist\\win\\panels\\topbar.toml builds\\win\\panels\\topbar.toml -Force; Write-Host 'Done: builds\\win\\tui-wm.exe' }",
|
||||
"options": { "shell": { "executable": "powershell.exe", "args": ["-NoProfile", "-Command"] } },
|
||||
"group": "build",
|
||||
"options": {
|
||||
"shell": {
|
||||
"executable": "powershell.exe",
|
||||
"args": ["-NoProfile", "-Command"]
|
||||
}
|
||||
},
|
||||
"presentation": { "reveal": "always", "panel": "shared" },
|
||||
"problemMatcher": ["$rustc"]
|
||||
},
|
||||
{
|
||||
"label": "Build Windows (GNU — kräver msys2 + mingw-w64)",
|
||||
"type": "shell",
|
||||
"command": "& \"$env:USERPROFILE\\.cargo\\bin\\cargo.exe\" build --release --target x86_64-pc-windows-gnu",
|
||||
"command": "& \"$env:USERPROFILE\\.cargo\\bin\\cargo.exe\" build --release --target x86_64-pc-windows-gnu; if ($LASTEXITCODE -eq 0) { New-Item -Force -ItemType Directory builds\\win\\panels | Out-Null; Copy-Item target\\x86_64-pc-windows-gnu\\release\\tui-wm.exe builds\\win\\tui-wm.exe -Force; Copy-Item dist\\win\\config.toml builds\\win\\config.toml -Force; Copy-Item dist\\win\\panels\\topbar.toml builds\\win\\panels\\topbar.toml -Force; Write-Host 'Done: builds\\win\\tui-wm.exe' }",
|
||||
"options": { "shell": { "executable": "powershell.exe", "args": ["-NoProfile", "-Command"] } },
|
||||
"group": "build",
|
||||
"options": {
|
||||
"shell": {
|
||||
"executable": "powershell.exe",
|
||||
"args": ["-NoProfile", "-Command"]
|
||||
}
|
||||
},
|
||||
"presentation": { "reveal": "always", "panel": "shared" },
|
||||
"problemMatcher": ["$rustc"]
|
||||
},
|
||||
@@ -59,7 +55,7 @@
|
||||
"label": "Run",
|
||||
"type": "shell",
|
||||
"command": "cargo run",
|
||||
"group": { "kind": "build", "isDefault": true },
|
||||
"group": "build",
|
||||
"presentation": { "reveal": "always", "panel": "shared" },
|
||||
"problemMatcher": ["$rustc"]
|
||||
}
|
||||
|
||||
369
Cargo.lock
generated
369
Cargo.lock
generated
@@ -1,6 +1,6 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 3
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "allocator-api2"
|
||||
@@ -8,6 +8,30 @@ version = "0.2.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
|
||||
|
||||
[[package]]
|
||||
name = "anyhow"
|
||||
version = "1.0.102"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
||||
|
||||
[[package]]
|
||||
name = "arrayvec"
|
||||
version = "0.7.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
|
||||
|
||||
[[package]]
|
||||
name = "autocfg"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "1.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.11.0"
|
||||
@@ -55,7 +79,7 @@ version = "0.28.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"bitflags 2.11.0",
|
||||
"crossterm_winapi",
|
||||
"futures-core",
|
||||
"mio",
|
||||
@@ -109,6 +133,12 @@ dependencies = [
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "downcast-rs"
|
||||
version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2"
|
||||
|
||||
[[package]]
|
||||
name = "either"
|
||||
version = "1.15.0"
|
||||
@@ -131,6 +161,17 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filedescriptor"
|
||||
version = "0.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"thiserror",
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "foldhash"
|
||||
version = "0.1.5"
|
||||
@@ -154,6 +195,12 @@ dependencies = [
|
||||
"foldhash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.16.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
version = "0.5.0"
|
||||
@@ -166,6 +213,16 @@ version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "2.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017"
|
||||
dependencies = [
|
||||
"equivalent",
|
||||
"hashbrown 0.16.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "indoc"
|
||||
version = "2.0.7"
|
||||
@@ -188,6 +245,15 @@ dependencies = [
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ioctl-rs"
|
||||
version = "0.1.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f7970510895cee30b3e9128319f2cefd4bde883a39f38baa279567ba3a7eb97d"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itertools"
|
||||
version = "0.13.0"
|
||||
@@ -203,6 +269,12 @@ version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "lazy_static"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.183"
|
||||
@@ -236,7 +308,22 @@ version = "0.12.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38"
|
||||
dependencies = [
|
||||
"hashbrown",
|
||||
"hashbrown 0.15.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
|
||||
|
||||
[[package]]
|
||||
name = "memoffset"
|
||||
version = "0.6.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5aa361d4faea93603064a027415f07bd8e1d5c88c9fbf68bf56a285428fd79ce"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -251,6 +338,20 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nix"
|
||||
version = "0.25.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f346ff70e7dbfd675fe90590b92d59ef2de15a8779ae305ebcbfd3f0caf59be4"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
"bitflags 1.3.2",
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"memoffset",
|
||||
"pin-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "parking_lot"
|
||||
version = "0.12.5"
|
||||
@@ -280,6 +381,33 @@ version = "1.0.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
|
||||
|
||||
[[package]]
|
||||
name = "pin-utils"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
|
||||
|
||||
[[package]]
|
||||
name = "portable-pty"
|
||||
version = "0.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "806ee80c2a03dbe1a9fb9534f8d19e4c0546b790cde8fd1fea9d6390644cb0be"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bitflags 1.3.2",
|
||||
"downcast-rs",
|
||||
"filedescriptor",
|
||||
"lazy_static",
|
||||
"libc",
|
||||
"log",
|
||||
"nix",
|
||||
"serial",
|
||||
"shared_library",
|
||||
"shell-words",
|
||||
"winapi",
|
||||
"winreg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.106"
|
||||
@@ -304,7 +432,7 @@ version = "0.29.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"bitflags 2.11.0",
|
||||
"cassowary",
|
||||
"compact_str",
|
||||
"crossterm",
|
||||
@@ -325,7 +453,7 @@ version = "0.5.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"bitflags 2.11.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -334,7 +462,7 @@ version = "0.38.44"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"bitflags 2.11.0",
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys",
|
||||
@@ -359,6 +487,103 @@ 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_spanned"
|
||||
version = "0.6.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serial"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a1237a96570fc377c13baa1b88c7589ab66edced652e43ffb17088f003db3e86"
|
||||
dependencies = [
|
||||
"serial-core",
|
||||
"serial-unix",
|
||||
"serial-windows",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serial-core"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f46209b345401737ae2125fe5b19a77acce90cd53e1658cda928e4fe9a64581"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serial-unix"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f03fbca4c9d866e24a459cbca71283f545a37f8e3e002ad8c70593871453cab7"
|
||||
dependencies = [
|
||||
"ioctl-rs",
|
||||
"libc",
|
||||
"serial-core",
|
||||
"termios",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serial-windows"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "15c6d3b776267a75d31bbdfd5d36c0ca051251caafc285827052bc53bcdc8162"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"serial-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "shared_library"
|
||||
version = "0.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5a9e7e0f2bfae24d8a5b5a66c5b257a83c7412304311512a0c054cd5e619da11"
|
||||
dependencies = [
|
||||
"lazy_static",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "shell-words"
|
||||
version = "1.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77"
|
||||
|
||||
[[package]]
|
||||
name = "signal-hook"
|
||||
version = "0.3.18"
|
||||
@@ -441,12 +666,87 @@ dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "termios"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d5d9cf598a6d7ce700a4e6a9199da127e6819a61e64b68609683cc9a01b5683a"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[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 = "toml"
|
||||
version = "0.8.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_spanned",
|
||||
"toml_datetime",
|
||||
"toml_edit",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_datetime"
|
||||
version = "0.6.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_edit"
|
||||
version = "0.22.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a"
|
||||
dependencies = [
|
||||
"indexmap",
|
||||
"serde",
|
||||
"serde_spanned",
|
||||
"toml_datetime",
|
||||
"toml_write",
|
||||
"winnow",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_write"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
|
||||
|
||||
[[package]]
|
||||
name = "tui-wm"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"crossterm",
|
||||
"portable-pty",
|
||||
"ratatui",
|
||||
"serde",
|
||||
"toml",
|
||||
"vt100",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -484,6 +784,45 @@ version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd"
|
||||
|
||||
[[package]]
|
||||
name = "utf8parse"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
|
||||
|
||||
[[package]]
|
||||
name = "vt100"
|
||||
version = "0.15.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "84cd863bf0db7e392ba3bd04994be3473491b31e66340672af5d11943c6274de"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"log",
|
||||
"unicode-width 0.1.14",
|
||||
"vte",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vte"
|
||||
version = "0.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f5022b5fbf9407086c180e9557be968742d839e68346af7792b8592489732197"
|
||||
dependencies = [
|
||||
"arrayvec",
|
||||
"utf8parse",
|
||||
"vte_generate_state_changes",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vte_generate_state_changes"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2e369bee1b05d510a7b4ed645f5faa90619e05437111783ea5848f28d97d3c2e"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasi"
|
||||
version = "0.11.1+wasi-snapshot-preview1"
|
||||
@@ -599,3 +938,21 @@ name = "windows_x86_64_msvc"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
|
||||
|
||||
[[package]]
|
||||
name = "winnow"
|
||||
version = "0.7.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winreg"
|
||||
version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d"
|
||||
dependencies = [
|
||||
"winapi",
|
||||
]
|
||||
|
||||
@@ -10,3 +10,8 @@ path = "src/main.rs"
|
||||
[dependencies]
|
||||
crossterm = { version = "0.28", features = ["event-stream"] }
|
||||
ratatui = "0.29"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
toml = "0.8"
|
||||
portable-pty = "0.8"
|
||||
vt100 = "0.15"
|
||||
anyhow = "1"
|
||||
|
||||
27
config.toml
Normal file
27
config.toml
Normal file
@@ -0,0 +1,27 @@
|
||||
# TUI-WM konfiguration
|
||||
|
||||
[[panel]]
|
||||
position = "top"
|
||||
file = "panels/topbar.toml"
|
||||
|
||||
# ── Keybinds ──────────────────────────────────────────────────────────────────
|
||||
# scope = "global" → alltid aktiv, även när en terminal har fokus
|
||||
# scope = "wm" → bara aktiv när ingen terminal är fokuserad (default)
|
||||
|
||||
# Öppna Tui-run kommandodialog (global – funkar alltid)
|
||||
[[keybind]]
|
||||
key = "ctrl+space"
|
||||
scope = "global"
|
||||
action = { type = "spawn_run_dialog" }
|
||||
|
||||
# Starta ny terminal med Alt+Enter (global)
|
||||
[[keybind]]
|
||||
key = "alt+enter"
|
||||
scope = "global"
|
||||
action = { type = "spawn_terminal" }
|
||||
|
||||
# Avsluta TUI-WM när skrivbordet är fokuserat
|
||||
[[keybind]]
|
||||
key = "ctrl+q"
|
||||
scope = "wm"
|
||||
action = { type = "exit" }
|
||||
27
dist/linux/config.toml
vendored
Normal file
27
dist/linux/config.toml
vendored
Normal file
@@ -0,0 +1,27 @@
|
||||
# TUI-WM konfiguration – Linux
|
||||
|
||||
[[panel]]
|
||||
position = "top"
|
||||
file = "panels/topbar.toml"
|
||||
|
||||
# ── Keybinds ──────────────────────────────────────────────────────────────────
|
||||
# scope = "global" → alltid aktiv, även när en terminal har fokus
|
||||
# scope = "wm" → bara aktiv när ingen terminal är fokuserad (default)
|
||||
|
||||
# Öppna Tui-run kommandodialog (global – funkar alltid)
|
||||
[[keybind]]
|
||||
key = "ctrl+space"
|
||||
scope = "global"
|
||||
action = { type = "spawn_run_dialog" }
|
||||
|
||||
# Starta ny terminal med Alt+Enter
|
||||
[[keybind]]
|
||||
key = "alt+enter"
|
||||
scope = "global"
|
||||
action = { type = "spawn_terminal" }
|
||||
|
||||
# Avsluta TUI-WM när skrivbordet är fokuserat
|
||||
[[keybind]]
|
||||
key = "ctrl+q"
|
||||
scope = "wm"
|
||||
action = { type = "exit" }
|
||||
23
dist/linux/panels/topbar.toml
vendored
Normal file
23
dist/linux/panels/topbar.toml
vendored
Normal file
@@ -0,0 +1,23 @@
|
||||
# Topbar – Linux
|
||||
|
||||
[[item]]
|
||||
label = "Terminal"
|
||||
action = { type = "spawn_terminal" }
|
||||
|
||||
[[item]]
|
||||
label = "Exit"
|
||||
action = { type = "exit" }
|
||||
|
||||
# Klocka – uppdateras varje sekund
|
||||
[[status]]
|
||||
command = "date '+%H:%M:%S'"
|
||||
interval = 1
|
||||
width = 10
|
||||
align = "right"
|
||||
|
||||
# Datum – uppdateras varje minut
|
||||
[[status]]
|
||||
command = "date '+%Y-%m-%d'"
|
||||
interval = 60
|
||||
width = 12
|
||||
align = "right"
|
||||
27
dist/win/config.toml
vendored
Normal file
27
dist/win/config.toml
vendored
Normal file
@@ -0,0 +1,27 @@
|
||||
# TUI-WM konfiguration – Windows
|
||||
|
||||
[[panel]]
|
||||
position = "top"
|
||||
file = "panels/topbar.toml"
|
||||
|
||||
# ── Keybinds ──────────────────────────────────────────────────────────────────
|
||||
# scope = "global" → alltid aktiv, även när en terminal har fokus
|
||||
# scope = "wm" → bara aktiv när ingen terminal är fokuserad (default)
|
||||
|
||||
# Öppna Tui-run kommandodialog (global – funkar alltid)
|
||||
[[keybind]]
|
||||
key = "ctrl+space"
|
||||
scope = "global"
|
||||
action = { type = "spawn_run_dialog" }
|
||||
|
||||
# Starta ny PowerShell-terminal
|
||||
[[keybind]]
|
||||
key = "alt+enter"
|
||||
scope = "global"
|
||||
action = { type = "spawn_terminal", shell = "powershell.exe" }
|
||||
|
||||
# Avsluta TUI-WM när skrivbordet är fokuserat
|
||||
[[keybind]]
|
||||
key = "ctrl+q"
|
||||
scope = "wm"
|
||||
action = { type = "exit" }
|
||||
23
dist/win/panels/topbar.toml
vendored
Normal file
23
dist/win/panels/topbar.toml
vendored
Normal file
@@ -0,0 +1,23 @@
|
||||
# Topbar – Windows (PowerShell-kommandon)
|
||||
|
||||
[[item]]
|
||||
label = "Terminal"
|
||||
action = { type = "spawn_terminal", shell = "powershell.exe" }
|
||||
|
||||
[[item]]
|
||||
label = "Exit"
|
||||
action = { type = "exit" }
|
||||
|
||||
# Klocka – uppdateras varje sekund (PowerShell)
|
||||
[[status]]
|
||||
command = "Get-Date -Format 'HH:mm:ss'"
|
||||
interval = 1
|
||||
width = 10
|
||||
align = "right"
|
||||
|
||||
# Datum – uppdateras varje minut (PowerShell)
|
||||
[[status]]
|
||||
command = "Get-Date -Format 'yyyy-MM-dd'"
|
||||
interval = 60
|
||||
width = 12
|
||||
align = "right"
|
||||
23
panels/topbar.toml
Normal file
23
panels/topbar.toml
Normal file
@@ -0,0 +1,23 @@
|
||||
# Knappar i panelen
|
||||
[[item]]
|
||||
label = "Terminal"
|
||||
action = { type = "spawn_terminal" }
|
||||
|
||||
[[item]]
|
||||
label = "Exit"
|
||||
action = { type = "exit" }
|
||||
|
||||
# Status-widgets
|
||||
# Klocka – körs varje sekund, 10 tecken bred, dockad mot höger
|
||||
[[status]]
|
||||
command = "date '+%H:%M:%S'"
|
||||
interval = 1
|
||||
width = 10
|
||||
align = "right"
|
||||
|
||||
# Datum – uppdateras varje minut, 12 tecken bred, dockad mot höger
|
||||
[[status]]
|
||||
command = "date '+%Y-%m-%d'"
|
||||
interval = 60
|
||||
width = 12
|
||||
align = "right"
|
||||
979
src/app.rs
Normal file
979
src/app.rs
Normal file
@@ -0,0 +1,979 @@
|
||||
use crate::config::{Config, KeybindScope, MenuAction, MenuItem, PanelPosition};
|
||||
use crate::pty::PtyTerminal;
|
||||
use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEventKind};
|
||||
use ratatui::layout::Rect;
|
||||
use std::sync::mpsc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
// ─── Keybinds ────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Parsad version av en KeybindConfig-rad
|
||||
pub struct ParsedKeybind {
|
||||
pub code: KeyCode,
|
||||
pub modifiers: KeyModifiers,
|
||||
pub scope: KeybindScope,
|
||||
pub action: MenuAction,
|
||||
}
|
||||
|
||||
/// Parsar "ctrl+alt+t" → (KeyModifiers, KeyCode)
|
||||
fn parse_keybind(s: &str) -> Option<(KeyModifiers, KeyCode)> {
|
||||
let mut modifiers = KeyModifiers::NONE;
|
||||
let mut code: Option<KeyCode> = None;
|
||||
for part in s.to_lowercase().split('+') {
|
||||
match part.trim() {
|
||||
"ctrl" | "control" => modifiers |= KeyModifiers::CONTROL,
|
||||
"alt" => modifiers |= KeyModifiers::ALT,
|
||||
"shift" => modifiers |= KeyModifiers::SHIFT,
|
||||
other => code = Some(parse_key_code(other)?),
|
||||
}
|
||||
}
|
||||
Some((modifiers, code?))
|
||||
}
|
||||
|
||||
fn parse_key_code(s: &str) -> Option<KeyCode> {
|
||||
match s {
|
||||
"space" => Some(KeyCode::Char(' ')),
|
||||
"enter" | "return" => Some(KeyCode::Enter),
|
||||
"backspace" => Some(KeyCode::Backspace),
|
||||
"tab" => Some(KeyCode::Tab),
|
||||
"esc" | "escape" => Some(KeyCode::Esc),
|
||||
"up" => Some(KeyCode::Up),
|
||||
"down" => Some(KeyCode::Down),
|
||||
"left" => Some(KeyCode::Left),
|
||||
"right" => Some(KeyCode::Right),
|
||||
"home" => Some(KeyCode::Home),
|
||||
"end" => Some(KeyCode::End),
|
||||
"pageup" => Some(KeyCode::PageUp),
|
||||
"pagedown" => Some(KeyCode::PageDown),
|
||||
"delete" | "del" => Some(KeyCode::Delete),
|
||||
"insert" | "ins" => Some(KeyCode::Insert),
|
||||
s if s.starts_with('f') => s[1..].parse::<u8>().ok().map(KeyCode::F),
|
||||
s if s.chars().count() == 1 => s.chars().next().map(KeyCode::Char),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Resize ──────────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Debug)]
|
||||
pub enum ResizeEdge {
|
||||
Left,
|
||||
Right,
|
||||
Bottom,
|
||||
BottomLeft,
|
||||
BottomRight,
|
||||
TopLeft,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ResizeState {
|
||||
pub edge: ResizeEdge,
|
||||
pub start_mouse: (i32, i32),
|
||||
pub start_win: (i32, i32, u16, u16), // x, y, w, h
|
||||
}
|
||||
|
||||
// ─── Status-widget körningsläge ───────────────────────────────────────────────
|
||||
|
||||
pub struct StatusWidgetState {
|
||||
pub output: String,
|
||||
pub last_run: Option<Instant>, // None = aldrig kört, kör direkt
|
||||
}
|
||||
|
||||
// ─── App ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
pub struct App {
|
||||
pub should_quit: bool,
|
||||
pub config: Config,
|
||||
|
||||
// Layout – uppdateras varje frame
|
||||
pub panel_rects: Vec<Rect>,
|
||||
pub panel_item_rects: Vec<Vec<Rect>>,
|
||||
pub content_area: Rect,
|
||||
|
||||
// Hover-tillstånd
|
||||
pub hovered_panel_item: Option<(usize, usize)>,
|
||||
pub hovered_window_close: Option<usize>,
|
||||
pub hovered_resize: Option<(usize, ResizeEdge)>,
|
||||
|
||||
// Öppen dropdown
|
||||
pub dropdown: Option<DropdownState>,
|
||||
|
||||
// Flytande fönster (sista = överst)
|
||||
pub windows: Vec<FloatingWindow>,
|
||||
pub next_id: usize,
|
||||
pub focused_id: Option<usize>,
|
||||
|
||||
// Status-widget körtillstånd, indexerat [panel][widget]
|
||||
pub status_states: Vec<Vec<StatusWidgetState>>,
|
||||
|
||||
// Parsade keybinds från config
|
||||
pub parsed_keybinds: Vec<ParsedKeybind>,
|
||||
}
|
||||
|
||||
pub struct DropdownState {
|
||||
pub panel_idx: usize,
|
||||
pub items: Vec<MenuItem>,
|
||||
pub rect: Rect,
|
||||
pub item_rects: Vec<Rect>,
|
||||
pub hovered: Option<usize>,
|
||||
}
|
||||
|
||||
pub struct FloatingWindow {
|
||||
pub id: usize,
|
||||
pub x: i32,
|
||||
pub y: i32,
|
||||
pub width: u16,
|
||||
pub height: u16,
|
||||
pub content: WindowContent,
|
||||
pub dragging: Option<(i32, i32)>,
|
||||
pub resizing: Option<ResizeState>,
|
||||
/// Om false: inga resize-kanter, kan inte storleksändras
|
||||
pub resizable: bool,
|
||||
}
|
||||
|
||||
pub enum WindowContent {
|
||||
Terminal {
|
||||
pty: PtyTerminal,
|
||||
rx: mpsc::Receiver<Vec<u8>>,
|
||||
parser: vt100::Parser,
|
||||
alive: bool,
|
||||
title: String,
|
||||
},
|
||||
RunDialog {
|
||||
input: String,
|
||||
cursor_pos: usize,
|
||||
},
|
||||
}
|
||||
|
||||
enum RunDialogResult {
|
||||
Close,
|
||||
Execute(String),
|
||||
None,
|
||||
}
|
||||
|
||||
const MIN_WIN_W: u16 = 20;
|
||||
const MIN_WIN_H: u16 = 6;
|
||||
|
||||
impl FloatingWindow {
|
||||
pub fn rect(&self) -> Rect {
|
||||
Rect::new(self.x.max(0) as u16, self.y.max(0) as u16, self.width, self.height)
|
||||
}
|
||||
|
||||
pub fn close_btn_rect(&self) -> Rect {
|
||||
let x = (self.x + self.width as i32 - 4).max(0) as u16;
|
||||
Rect::new(x, self.y.max(0) as u16, 3, 1)
|
||||
}
|
||||
|
||||
pub fn content_rect(&self) -> Rect {
|
||||
Rect::new(
|
||||
(self.x + 1).max(0) as u16,
|
||||
(self.y + 1).max(0) as u16,
|
||||
self.width.saturating_sub(2),
|
||||
self.height.saturating_sub(2),
|
||||
)
|
||||
}
|
||||
|
||||
/// Returnerar vilken resize-kant (om någon) som (col, row) träffar
|
||||
pub fn resize_edge_at(&self, col: u16, row: u16) -> Option<ResizeEdge> {
|
||||
if !self.resizable {
|
||||
return None;
|
||||
}
|
||||
let wx = self.x.max(0) as u16;
|
||||
let wy = self.y.max(0) as u16;
|
||||
let wr = wx + self.width; // exklusiv höger
|
||||
let wb = wy + self.height; // exklusiv botten
|
||||
|
||||
if col >= wr || row >= wb || col < wx || row < wy {
|
||||
return None;
|
||||
}
|
||||
|
||||
let on_left = col == wx;
|
||||
let on_right = col == wr - 1;
|
||||
let on_top = row == wy;
|
||||
let on_bottom = row == wb - 1;
|
||||
|
||||
// Övre vänstra hörnet (men INTE övre höger = stäng-knapp-zonen)
|
||||
if on_top && on_left {
|
||||
return Some(ResizeEdge::TopLeft);
|
||||
}
|
||||
// Nedre hörn
|
||||
if on_bottom && on_left {
|
||||
return Some(ResizeEdge::BottomLeft);
|
||||
}
|
||||
if on_bottom && on_right {
|
||||
return Some(ResizeEdge::BottomRight);
|
||||
}
|
||||
// Kanter
|
||||
if on_left && !on_top {
|
||||
return Some(ResizeEdge::Left);
|
||||
}
|
||||
if on_right && !on_top {
|
||||
return Some(ResizeEdge::Right);
|
||||
}
|
||||
if on_bottom {
|
||||
return Some(ResizeEdge::Bottom);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn on_title_bar(&self, col: u16, row: u16) -> bool {
|
||||
let cb = self.close_btn_rect();
|
||||
let wx = self.x.max(0) as u16;
|
||||
let wy = self.y.max(0) as u16;
|
||||
row == wy
|
||||
&& col > wx // vänster kant är TopLeft-resize
|
||||
&& col < wx + self.width
|
||||
&& !(col >= cb.x && col < cb.x + cb.width)
|
||||
}
|
||||
|
||||
fn on_close_btn(&self, col: u16, row: u16) -> bool {
|
||||
let cb = self.close_btn_rect();
|
||||
row == cb.y && col >= cb.x && col < cb.x + cb.width
|
||||
}
|
||||
|
||||
fn in_content(&self, col: u16, row: u16) -> bool {
|
||||
let cr = self.content_rect();
|
||||
col >= cr.x && col < cr.x + cr.width && row >= cr.y && row < cr.y + cr.height
|
||||
}
|
||||
|
||||
/// Applicera resize baserat på nuvarande musposition
|
||||
fn apply_resize(&mut self, col: i32, row: i32) {
|
||||
let rs = match self.resizing.clone() {
|
||||
Some(r) => r,
|
||||
None => return,
|
||||
};
|
||||
let (sx, sy, sw, sh) = rs.start_win;
|
||||
let dx = col - rs.start_mouse.0;
|
||||
let dy = row - rs.start_mouse.1;
|
||||
|
||||
match rs.edge {
|
||||
ResizeEdge::Left => {
|
||||
let new_w = (sw as i32 - dx).max(MIN_WIN_W as i32) as u16;
|
||||
self.x = sx + (sw as i32 - new_w as i32);
|
||||
self.width = new_w;
|
||||
}
|
||||
ResizeEdge::Right => {
|
||||
self.width = (sw as i32 + dx).max(MIN_WIN_W as i32) as u16;
|
||||
}
|
||||
ResizeEdge::Bottom => {
|
||||
self.height = (sh as i32 + dy).max(MIN_WIN_H as i32) as u16;
|
||||
}
|
||||
ResizeEdge::BottomLeft => {
|
||||
let new_w = (sw as i32 - dx).max(MIN_WIN_W as i32) as u16;
|
||||
self.x = sx + (sw as i32 - new_w as i32);
|
||||
self.width = new_w;
|
||||
self.height = (sh as i32 + dy).max(MIN_WIN_H as i32) as u16;
|
||||
}
|
||||
ResizeEdge::BottomRight => {
|
||||
self.width = (sw as i32 + dx).max(MIN_WIN_W as i32) as u16;
|
||||
self.height = (sh as i32 + dy).max(MIN_WIN_H as i32) as u16;
|
||||
}
|
||||
ResizeEdge::TopLeft => {
|
||||
let new_w = (sw as i32 - dx).max(MIN_WIN_W as i32) as u16;
|
||||
let new_h = (sh as i32 - dy).max(MIN_WIN_H as i32) as u16;
|
||||
self.x = sx + (sw as i32 - new_w as i32);
|
||||
self.y = sy + (sh as i32 - new_h as i32);
|
||||
self.width = new_w;
|
||||
self.height = new_h;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── App impl ─────────────────────────────────────────────────────────────────
|
||||
|
||||
impl App {
|
||||
pub fn new(config: Config) -> Self {
|
||||
let status_states: Vec<Vec<StatusWidgetState>> = config
|
||||
.panels
|
||||
.iter()
|
||||
.map(|p| {
|
||||
p.status_widgets
|
||||
.iter()
|
||||
.map(|_| StatusWidgetState { output: String::new(), last_run: None })
|
||||
.collect()
|
||||
})
|
||||
.collect();
|
||||
|
||||
let parsed_keybinds: Vec<ParsedKeybind> = config
|
||||
.keybinds
|
||||
.iter()
|
||||
.filter_map(|kb| {
|
||||
let (modifiers, code) = parse_keybind(&kb.key)?;
|
||||
Some(ParsedKeybind {
|
||||
code,
|
||||
modifiers,
|
||||
scope: kb.scope.clone(),
|
||||
action: kb.action.clone(),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
App {
|
||||
should_quit: false,
|
||||
config,
|
||||
panel_rects: Vec::new(),
|
||||
panel_item_rects: Vec::new(),
|
||||
content_area: Rect::default(),
|
||||
hovered_panel_item: None,
|
||||
hovered_window_close: None,
|
||||
hovered_resize: None,
|
||||
dropdown: None,
|
||||
windows: Vec::new(),
|
||||
next_id: 0,
|
||||
focused_id: None,
|
||||
status_states,
|
||||
parsed_keybinds,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn update_layout(&mut self, area: Rect) {
|
||||
self.panel_rects.clear();
|
||||
self.panel_item_rects.clear();
|
||||
|
||||
let mut top_used = 0u16;
|
||||
let mut bottom_used = 0u16;
|
||||
|
||||
for panel in &self.config.panels {
|
||||
let rect = match panel.position {
|
||||
PanelPosition::Top => {
|
||||
let r = Rect::new(area.x, area.y + top_used, area.width, 1);
|
||||
top_used += 1;
|
||||
r
|
||||
}
|
||||
PanelPosition::Bottom => {
|
||||
bottom_used += 1;
|
||||
Rect::new(area.x, area.y + area.height.saturating_sub(bottom_used), area.width, 1)
|
||||
}
|
||||
PanelPosition::Left | PanelPosition::Right => Rect::default(),
|
||||
};
|
||||
|
||||
let mut item_rects = Vec::new();
|
||||
let mut x = rect.x + 10;
|
||||
for item in &panel.items {
|
||||
let w = item.label.len() as u16 + 2;
|
||||
item_rects.push(Rect::new(x, rect.y, w, 1));
|
||||
x += w + 1;
|
||||
}
|
||||
|
||||
self.panel_rects.push(rect);
|
||||
self.panel_item_rects.push(item_rects);
|
||||
}
|
||||
|
||||
self.content_area = Rect::new(
|
||||
area.x,
|
||||
area.y + top_used,
|
||||
area.width,
|
||||
area.height.saturating_sub(top_used + bottom_used),
|
||||
);
|
||||
|
||||
if let Some(dd) = &mut self.dropdown {
|
||||
let pi = dd.panel_idx;
|
||||
if let (Some(pr), Some(ir)) =
|
||||
(self.panel_rects.get(pi), self.panel_item_rects.get(pi))
|
||||
{
|
||||
let x = ir.first().map(|r| r.x).unwrap_or(pr.x);
|
||||
let y = pr.y + 1;
|
||||
let width = dd
|
||||
.items
|
||||
.iter()
|
||||
.map(|i| i.label.len() as u16 + 2)
|
||||
.max()
|
||||
.unwrap_or(12)
|
||||
.max(12);
|
||||
dd.item_rects = (0..dd.items.len())
|
||||
.map(|i| Rect::new(x, y + i as u16, width, 1))
|
||||
.collect();
|
||||
dd.rect = Rect::new(x, y, width, dd.items.len() as u16);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn tick(&mut self) {
|
||||
// ── PTY-data → vt100-parser ──────────────────────────────────────────
|
||||
for window in &mut self.windows {
|
||||
let WindowContent::Terminal { rx, parser, alive, .. } = &mut window.content else { continue };
|
||||
if !*alive {
|
||||
continue;
|
||||
}
|
||||
loop {
|
||||
match rx.try_recv() {
|
||||
Ok(data) => parser.process(&data),
|
||||
Err(mpsc::TryRecvError::Empty) => break,
|
||||
Err(mpsc::TryRecvError::Disconnected) => {
|
||||
*alive = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── PTY resize om fönsterstorlek ändrats ─────────────────────────────
|
||||
for window in &mut self.windows {
|
||||
let new_rows = window.height.saturating_sub(2).max(1);
|
||||
let new_cols = window.width.saturating_sub(2).max(1);
|
||||
let WindowContent::Terminal { pty, parser, alive, .. } = &mut window.content else { continue };
|
||||
if !*alive {
|
||||
continue;
|
||||
}
|
||||
let (cur_rows, cur_cols) = parser.screen().size();
|
||||
if cur_rows != new_rows || cur_cols != new_cols {
|
||||
let _ = pty.resize(new_rows, new_cols);
|
||||
*parser = vt100::Parser::new(new_rows, new_cols, 0);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Ta bort döda fönster ─────────────────────────────────────────────
|
||||
let prev_focused = self.focused_id;
|
||||
self.windows.retain(|w| match &w.content {
|
||||
WindowContent::Terminal { alive, .. } => *alive,
|
||||
WindowContent::RunDialog { .. } => true,
|
||||
});
|
||||
if let Some(fid) = prev_focused {
|
||||
if !self.windows.iter().any(|w| w.id == fid) {
|
||||
self.focused_id = self.windows.last().map(|w| w.id);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Status-widgets: kör kommandon vid behov ──────────────────────────
|
||||
for (pi, panel) in self.config.panels.iter().enumerate() {
|
||||
for (si, sw_cfg) in panel.status_widgets.iter().enumerate() {
|
||||
if let Some(state) = self
|
||||
.status_states
|
||||
.get_mut(pi)
|
||||
.and_then(|v| v.get_mut(si))
|
||||
{
|
||||
let should_run = match state.last_run {
|
||||
None => true,
|
||||
Some(t) => t.elapsed() >= Duration::from_secs(sw_cfg.interval),
|
||||
};
|
||||
if should_run {
|
||||
state.output = run_command(&sw_cfg.command);
|
||||
state.last_run = Some(Instant::now());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn handle_event(&mut self, event: Event) {
|
||||
match event {
|
||||
Event::Key(key) => self.handle_key(key),
|
||||
Event::Mouse(m) => self.handle_mouse(m.column, m.row, m.kind),
|
||||
Event::Resize(_, _) => {}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_key(&mut self, key: KeyEvent) {
|
||||
// 1. Globala keybinds – alltid, oavsett fokus
|
||||
if let Some(action) = self.match_keybind(key, KeybindScope::Global) {
|
||||
self.execute_action(action);
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. RunDialog hanterar sina egna tangenter
|
||||
if let Some(id) = self.focused_id {
|
||||
let is_dialog = self.windows.iter()
|
||||
.any(|w| w.id == id && matches!(w.content, WindowContent::RunDialog { .. }));
|
||||
if is_dialog {
|
||||
let result = self.handle_run_dialog_key(id, key);
|
||||
match result {
|
||||
RunDialogResult::Close => self.close_window(id),
|
||||
RunDialogResult::Execute(cmd) => {
|
||||
self.close_window(id);
|
||||
if !cmd.is_empty() {
|
||||
self.spawn_terminal(&cmd);
|
||||
}
|
||||
}
|
||||
RunDialogResult::None => {}
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Vidarebefordra till fokuserat terminalfönster
|
||||
if let Some(id) = self.focused_id {
|
||||
if let Some(bytes) = key_to_bytes(key) {
|
||||
if let Some(w) = self.windows.iter_mut().find(|w| w.id == id) {
|
||||
if let WindowContent::Terminal { pty, .. } = &mut w.content {
|
||||
let _ = pty.write_input(&bytes);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. WM-keybinds – bara när ingen terminal är fokuserad
|
||||
let terminal_focused = self.focused_id.map_or(false, |id| {
|
||||
self.windows.iter().any(|w| w.id == id && matches!(w.content, WindowContent::Terminal { .. }))
|
||||
});
|
||||
if !terminal_focused {
|
||||
if let Some(action) = self.match_keybind(key, KeybindScope::Wm) {
|
||||
self.execute_action(action);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Inbyggd fallback
|
||||
if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('q') {
|
||||
self.should_quit = true;
|
||||
}
|
||||
}
|
||||
|
||||
fn match_keybind(&self, key: KeyEvent, scope: KeybindScope) -> Option<MenuAction> {
|
||||
self.parsed_keybinds
|
||||
.iter()
|
||||
.find(|kb| kb.scope == scope && kb.code == key.code && kb.modifiers == key.modifiers)
|
||||
.map(|kb| kb.action.clone())
|
||||
}
|
||||
|
||||
fn handle_run_dialog_key(&mut self, id: usize, key: KeyEvent) -> RunDialogResult {
|
||||
let window = match self.windows.iter_mut().find(|w| w.id == id) {
|
||||
Some(w) => w,
|
||||
None => return RunDialogResult::None,
|
||||
};
|
||||
let WindowContent::RunDialog { input, cursor_pos } = &mut window.content else {
|
||||
return RunDialogResult::None;
|
||||
};
|
||||
|
||||
match key.code {
|
||||
KeyCode::Esc => RunDialogResult::Close,
|
||||
KeyCode::Enter => RunDialogResult::Execute(input.trim().to_string()),
|
||||
KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => {
|
||||
input.insert(*cursor_pos, c);
|
||||
*cursor_pos += c.len_utf8();
|
||||
RunDialogResult::None
|
||||
}
|
||||
KeyCode::Backspace => {
|
||||
if *cursor_pos > 0 {
|
||||
let prev = input[..*cursor_pos]
|
||||
.char_indices()
|
||||
.last()
|
||||
.map(|(i, _)| i)
|
||||
.unwrap_or(0);
|
||||
input.drain(prev..*cursor_pos);
|
||||
*cursor_pos = prev;
|
||||
}
|
||||
RunDialogResult::None
|
||||
}
|
||||
KeyCode::Delete => {
|
||||
if *cursor_pos < input.len() {
|
||||
input.remove(*cursor_pos);
|
||||
}
|
||||
RunDialogResult::None
|
||||
}
|
||||
KeyCode::Left => {
|
||||
if *cursor_pos > 0 {
|
||||
*cursor_pos = input[..*cursor_pos]
|
||||
.char_indices()
|
||||
.last()
|
||||
.map(|(i, _)| i)
|
||||
.unwrap_or(0);
|
||||
}
|
||||
RunDialogResult::None
|
||||
}
|
||||
KeyCode::Right => {
|
||||
if *cursor_pos < input.len() {
|
||||
let mut ci = input[*cursor_pos..].char_indices();
|
||||
ci.next();
|
||||
*cursor_pos += ci.next().map(|(i, _)| i).unwrap_or(input.len() - *cursor_pos);
|
||||
}
|
||||
RunDialogResult::None
|
||||
}
|
||||
KeyCode::Home => {
|
||||
*cursor_pos = 0;
|
||||
RunDialogResult::None
|
||||
}
|
||||
KeyCode::End => {
|
||||
*cursor_pos = input.len();
|
||||
RunDialogResult::None
|
||||
}
|
||||
_ => RunDialogResult::None,
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_mouse(&mut self, col: u16, row: u16, kind: MouseEventKind) {
|
||||
match kind {
|
||||
MouseEventKind::Moved => self.update_hover(col, row),
|
||||
MouseEventKind::Drag(MouseButton::Left) => self.handle_drag(col, row),
|
||||
MouseEventKind::Up(MouseButton::Left) => {
|
||||
for w in &mut self.windows {
|
||||
w.dragging = None;
|
||||
w.resizing = None;
|
||||
}
|
||||
}
|
||||
MouseEventKind::Down(MouseButton::Left) => self.handle_click(col, row),
|
||||
MouseEventKind::ScrollUp => self.handle_scroll(col, row, true),
|
||||
MouseEventKind::ScrollDown => self.handle_scroll(col, row, false),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn update_hover(&mut self, col: u16, row: u16) {
|
||||
self.hovered_panel_item = None;
|
||||
self.hovered_window_close = None;
|
||||
self.hovered_resize = None;
|
||||
|
||||
// Dropdown-hover
|
||||
if let Some(dd) = &mut self.dropdown {
|
||||
dd.hovered = None;
|
||||
for (i, r) in dd.item_rects.clone().iter().enumerate() {
|
||||
if in_rect(col, row, *r) {
|
||||
dd.hovered = Some(i);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Stäng-knapp (översta fönstret först)
|
||||
for w in self.windows.iter().rev() {
|
||||
if w.on_close_btn(col, row) {
|
||||
self.hovered_window_close = Some(w.id);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Resize-kant
|
||||
for w in self.windows.iter().rev() {
|
||||
if let Some(edge) = w.resize_edge_at(col, row) {
|
||||
// Kontrollera att det inte är stäng-knappen (redan hanterat ovan)
|
||||
if !w.on_close_btn(col, row) {
|
||||
self.hovered_resize = Some((w.id, edge));
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Panel-knappar
|
||||
for (pi, item_rects) in self.panel_item_rects.iter().enumerate() {
|
||||
for (ii, r) in item_rects.iter().enumerate() {
|
||||
if in_rect(col, row, *r) {
|
||||
self.hovered_panel_item = Some((pi, ii));
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_drag(&mut self, col: u16, row: u16) {
|
||||
let col = col as i32;
|
||||
let row = row as i32;
|
||||
|
||||
// Resize har prioritet över drag
|
||||
for w in self.windows.iter_mut().rev() {
|
||||
if w.resizing.is_some() {
|
||||
w.apply_resize(col, row);
|
||||
return;
|
||||
}
|
||||
}
|
||||
for w in self.windows.iter_mut().rev() {
|
||||
if let Some((ox, oy)) = w.dragging {
|
||||
w.x = col - ox;
|
||||
w.y = row - oy;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_click(&mut self, col: u16, row: u16) {
|
||||
// 1. Dropdown-item
|
||||
if let Some(idx) = self.dropdown.as_ref().and_then(|dd| dd.hovered) {
|
||||
let action = self
|
||||
.dropdown
|
||||
.as_ref()
|
||||
.and_then(|dd| dd.items.get(idx))
|
||||
.map(|i| i.action.clone());
|
||||
self.dropdown = None;
|
||||
if let Some(action) = action {
|
||||
self.execute_action(action);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if self.dropdown.is_some() {
|
||||
self.dropdown = None;
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Stäng-knapp
|
||||
let close_id = self.windows.iter().rev().find(|w| w.on_close_btn(col, row)).map(|w| w.id);
|
||||
if let Some(id) = close_id {
|
||||
self.close_window(id);
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. Resize-kant
|
||||
let resize_hit = self
|
||||
.windows
|
||||
.iter()
|
||||
.rev()
|
||||
.find_map(|w| w.resize_edge_at(col, row).map(|e| (w.id, w.x, w.y, w.width, w.height, e)));
|
||||
if let Some((id, wx, wy, ww, wh, edge)) = resize_hit {
|
||||
self.focus_window(id);
|
||||
if let Some(w) = self.windows.iter_mut().find(|w| w.id == id) {
|
||||
w.resizing = Some(ResizeState {
|
||||
edge,
|
||||
start_mouse: (col as i32, row as i32),
|
||||
start_win: (wx, wy, ww, wh),
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 4. Titel-rad → drag + fokus
|
||||
let title_hit = self
|
||||
.windows
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|w| w.on_title_bar(col, row))
|
||||
.map(|w| (w.id, col as i32 - w.x, row as i32 - w.y));
|
||||
if let Some((id, ox, oy)) = title_hit {
|
||||
self.focus_window(id);
|
||||
if let Some(w) = self.windows.iter_mut().find(|w| w.id == id) {
|
||||
w.dragging = Some((ox, oy));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 5. Innehållsyta → fokus
|
||||
let content_id = self.windows.iter().rev().find(|w| w.in_content(col, row)).map(|w| w.id);
|
||||
if let Some(id) = content_id {
|
||||
self.focus_window(id);
|
||||
return;
|
||||
}
|
||||
|
||||
// 6. Panel-knappar
|
||||
for (pi, item_rects) in self.panel_item_rects.clone().iter().enumerate() {
|
||||
for (ii, r) in item_rects.iter().enumerate() {
|
||||
if in_rect(col, row, *r) {
|
||||
let action = self.config.panels[pi].items[ii].action.clone();
|
||||
if let MenuAction::Submenu { items } = action {
|
||||
self.open_dropdown(pi, ii, items);
|
||||
} else {
|
||||
self.execute_action(action);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_scroll(&mut self, col: u16, row: u16, up: bool) {
|
||||
let id = self.windows.iter().rev().find(|w| in_rect(col, row, w.rect())).map(|w| w.id);
|
||||
if let Some(id) = id {
|
||||
let bytes: &[u8] = if up { b"\x1b[A\x1b[A\x1b[A" } else { b"\x1b[B\x1b[B\x1b[B" };
|
||||
if let Some(w) = self.windows.iter_mut().find(|w| w.id == id) {
|
||||
if let WindowContent::Terminal { pty, .. } = &mut w.content {
|
||||
let _ = pty.write_input(bytes);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn execute_action(&mut self, action: MenuAction) {
|
||||
match action {
|
||||
MenuAction::Exit => self.should_quit = true,
|
||||
MenuAction::SpawnTerminal { shell } => {
|
||||
let shell = shell.unwrap_or_else(default_shell);
|
||||
self.spawn_terminal(&shell);
|
||||
}
|
||||
MenuAction::RunScript { path } => self.spawn_terminal(&path),
|
||||
MenuAction::RunProgram { command, .. } => self.spawn_terminal(&command),
|
||||
MenuAction::SpawnRunDialog => self.spawn_run_dialog(),
|
||||
MenuAction::Submenu { .. } => {}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn spawn_run_dialog(&mut self) {
|
||||
let ca = self.content_area;
|
||||
let w = 46u16.min(ca.width.saturating_sub(4));
|
||||
let h = 5u16;
|
||||
let x = ca.x as i32 + (ca.width as i32 - w as i32) / 2;
|
||||
let y = ca.y as i32 + (ca.height as i32 - h as i32) / 2;
|
||||
|
||||
let id = self.next_id;
|
||||
self.next_id += 1;
|
||||
self.windows.push(FloatingWindow {
|
||||
id,
|
||||
x: x.max(0),
|
||||
y: y.max(0),
|
||||
width: w,
|
||||
height: h,
|
||||
content: WindowContent::RunDialog { input: String::new(), cursor_pos: 0 },
|
||||
dragging: None,
|
||||
resizing: None,
|
||||
resizable: false,
|
||||
});
|
||||
self.focus_window(id);
|
||||
}
|
||||
|
||||
fn open_dropdown(&mut self, panel_idx: usize, item_idx: usize, items: Vec<MenuItem>) {
|
||||
let pr = match self.panel_rects.get(panel_idx) {
|
||||
Some(r) => *r,
|
||||
None => return,
|
||||
};
|
||||
let ir = self.panel_item_rects.get(panel_idx)
|
||||
.and_then(|v| v.get(item_idx))
|
||||
.copied()
|
||||
.unwrap_or(pr);
|
||||
|
||||
let width = items.iter().map(|i| i.label.len() as u16 + 2).max().unwrap_or(12).max(12);
|
||||
let x = ir.x;
|
||||
let y = pr.y + 1;
|
||||
|
||||
let item_rects: Vec<Rect> =
|
||||
(0..items.len()).map(|i| Rect::new(x, y + i as u16, width, 1)).collect();
|
||||
|
||||
self.dropdown = Some(DropdownState {
|
||||
panel_idx,
|
||||
rect: Rect::new(x, y, width, items.len() as u16),
|
||||
item_rects,
|
||||
items,
|
||||
hovered: None,
|
||||
});
|
||||
}
|
||||
|
||||
pub fn spawn_terminal(&mut self, shell: &str) {
|
||||
let ca = self.content_area;
|
||||
let offset = (self.windows.len() as i32) * 2;
|
||||
let w = 82u16.min(ca.width.saturating_sub(4));
|
||||
let h = 26u16.min(ca.height.saturating_sub(4));
|
||||
let x = ca.x as i32 + 2 + offset;
|
||||
let y = ca.y as i32 + 1 + offset;
|
||||
let rows = h.saturating_sub(2).max(1);
|
||||
let cols = w.saturating_sub(2).max(1);
|
||||
|
||||
match PtyTerminal::spawn(shell, rows, cols) {
|
||||
Ok((pty, rx)) => {
|
||||
let parser = vt100::Parser::new(rows, cols, 0);
|
||||
let id = self.next_id;
|
||||
self.next_id += 1;
|
||||
let title = shell.split('/').last().unwrap_or(shell).to_string();
|
||||
self.windows.push(FloatingWindow {
|
||||
id,
|
||||
x,
|
||||
y,
|
||||
width: w,
|
||||
height: h,
|
||||
content: WindowContent::Terminal { pty, rx, parser, alive: true, title },
|
||||
dragging: None,
|
||||
resizing: None,
|
||||
resizable: true,
|
||||
});
|
||||
self.focus_window(id);
|
||||
}
|
||||
Err(e) => eprintln!("Kunde inte starta terminal: {}", e),
|
||||
}
|
||||
}
|
||||
|
||||
fn close_window(&mut self, id: usize) {
|
||||
self.windows.retain(|w| w.id != id);
|
||||
if self.focused_id == Some(id) {
|
||||
self.focused_id = self.windows.last().map(|w| w.id);
|
||||
}
|
||||
}
|
||||
|
||||
fn focus_window(&mut self, id: usize) {
|
||||
if let Some(pos) = self.windows.iter().position(|w| w.id == id) {
|
||||
let w = self.windows.remove(pos);
|
||||
self.windows.push(w);
|
||||
}
|
||||
self.focused_id = Some(id);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Hjälpfunktioner ─────────────────────────────────────────────────────────
|
||||
|
||||
fn in_rect(col: u16, row: u16, rect: Rect) -> bool {
|
||||
col >= rect.x
|
||||
&& col < rect.x + rect.width
|
||||
&& row >= rect.y
|
||||
&& row < rect.y + rect.height
|
||||
}
|
||||
|
||||
fn default_shell() -> String {
|
||||
if cfg!(windows) {
|
||||
// Försök hitta PowerShell i prioritetsordning: pwsh (Core) → powershell (inbyggd)
|
||||
which_windows("pwsh").or_else(|| which_windows("powershell")).unwrap_or_else(|| "powershell.exe".to_string())
|
||||
} else {
|
||||
std::env::var("SHELL").unwrap_or_else(|_| "/bin/bash".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn which_windows(name: &str) -> Option<String> {
|
||||
let exe = format!("{}.exe", name);
|
||||
std::env::var("PATH").ok()?.split(';').find_map(|dir| {
|
||||
let path = std::path::Path::new(dir).join(&exe);
|
||||
if path.exists() { Some(path.to_string_lossy().into_owned()) } else { None }
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn which_windows(_name: &str) -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Kör ett shell-kommando och returnerar dess stdout som trimmat str
|
||||
fn run_command(command: &str) -> String {
|
||||
#[cfg(unix)]
|
||||
let result = std::process::Command::new("sh").arg("-c").arg(command).output();
|
||||
#[cfg(windows)]
|
||||
let result = std::process::Command::new("powershell.exe")
|
||||
.args(["-NoProfile", "-NonInteractive", "-Command", command])
|
||||
.output();
|
||||
|
||||
match result {
|
||||
Ok(out) => String::from_utf8_lossy(&out.stdout).trim().to_string(),
|
||||
Err(_) => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn key_to_bytes(key: KeyEvent) -> Option<Vec<u8>> {
|
||||
use KeyCode::*;
|
||||
let bytes: Vec<u8> = match key.code {
|
||||
Char(c) => {
|
||||
if key.modifiers.contains(KeyModifiers::CONTROL) {
|
||||
match c {
|
||||
'a'..='z' => vec![c as u8 - b'a' + 1],
|
||||
'A'..='Z' => vec![c as u8 - b'A' + 1],
|
||||
'[' => vec![0x1b],
|
||||
'\\' => vec![0x1c],
|
||||
']' => vec![0x1d],
|
||||
_ => return None,
|
||||
}
|
||||
} else {
|
||||
let mut buf = [0u8; 4];
|
||||
c.encode_utf8(&mut buf).as_bytes().to_vec()
|
||||
}
|
||||
}
|
||||
Enter => vec![b'\r'],
|
||||
Backspace => vec![0x7f],
|
||||
Tab => vec![b'\t'],
|
||||
Esc => vec![0x1b],
|
||||
Up => b"\x1b[A".to_vec(),
|
||||
Down => b"\x1b[B".to_vec(),
|
||||
Right => b"\x1b[C".to_vec(),
|
||||
Left => b"\x1b[D".to_vec(),
|
||||
Home => b"\x1b[H".to_vec(),
|
||||
End => b"\x1b[F".to_vec(),
|
||||
PageUp => b"\x1b[5~".to_vec(),
|
||||
PageDown => b"\x1b[6~".to_vec(),
|
||||
Delete => b"\x1b[3~".to_vec(),
|
||||
F(1) => b"\x1bOP".to_vec(),
|
||||
F(2) => b"\x1bOQ".to_vec(),
|
||||
F(3) => b"\x1bOR".to_vec(),
|
||||
F(4) => b"\x1bOS".to_vec(),
|
||||
F(5) => b"\x1b[15~".to_vec(),
|
||||
F(6) => b"\x1b[17~".to_vec(),
|
||||
F(7) => b"\x1b[18~".to_vec(),
|
||||
F(8) => b"\x1b[19~".to_vec(),
|
||||
F(9) => b"\x1b[20~".to_vec(),
|
||||
F(10) => b"\x1b[21~".to_vec(),
|
||||
F(11) => b"\x1b[23~".to_vec(),
|
||||
F(12) => b"\x1b[24~".to_vec(),
|
||||
_ => return None,
|
||||
};
|
||||
Some(bytes)
|
||||
}
|
||||
|
||||
143
src/config.rs
Normal file
143
src/config.rs
Normal file
@@ -0,0 +1,143 @@
|
||||
use serde::Deserialize;
|
||||
use std::fs;
|
||||
|
||||
#[derive(Deserialize, Debug, Clone, Default)]
|
||||
pub struct Config {
|
||||
#[serde(default, rename = "panel")]
|
||||
pub panels: Vec<PanelConfig>,
|
||||
#[serde(default, rename = "keybind")]
|
||||
pub keybinds: Vec<KeybindConfig>,
|
||||
}
|
||||
|
||||
/// En tangentbords-genväg definierad i config
|
||||
#[derive(Deserialize, Debug, Clone)]
|
||||
pub struct KeybindConfig {
|
||||
/// T.ex. "ctrl+space", "alt+f2", "ctrl+t"
|
||||
pub key: String,
|
||||
/// "global" = alltid, "wm" = bara när ingen terminal är fokuserad
|
||||
#[serde(default)]
|
||||
pub scope: KeybindScope,
|
||||
pub action: MenuAction,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug, Clone, Default, PartialEq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum KeybindScope {
|
||||
/// Aktiveras alltid, oavsett fokus
|
||||
Global,
|
||||
/// Aktiveras bara när ingen terminal-ruta är fokuserad
|
||||
#[default]
|
||||
Wm,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug, Clone)]
|
||||
pub struct PanelConfig {
|
||||
pub position: PanelPosition,
|
||||
#[serde(default, rename = "item")]
|
||||
pub items: Vec<MenuItem>,
|
||||
#[serde(default, rename = "status")]
|
||||
pub status_widgets: Vec<StatusWidget>,
|
||||
/// Länk till extern fil med panel-definition
|
||||
pub file: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug, Clone, PartialEq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum PanelPosition {
|
||||
Top,
|
||||
Bottom,
|
||||
Left,
|
||||
Right,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug, Clone)]
|
||||
pub struct MenuItem {
|
||||
pub label: String,
|
||||
pub action: MenuAction,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug, Clone)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum MenuAction {
|
||||
Exit,
|
||||
SpawnTerminal {
|
||||
shell: Option<String>,
|
||||
},
|
||||
RunScript {
|
||||
path: String,
|
||||
},
|
||||
RunProgram {
|
||||
command: String,
|
||||
#[serde(default)]
|
||||
args: Vec<String>,
|
||||
},
|
||||
Submenu {
|
||||
#[serde(default, rename = "item")]
|
||||
items: Vec<MenuItem>,
|
||||
},
|
||||
/// Öppnar Tui-run kommandodialog
|
||||
SpawnRunDialog,
|
||||
}
|
||||
|
||||
/// En status-widget i panelen: kör ett kommando periodiskt och renderar output
|
||||
#[derive(Deserialize, Debug, Clone)]
|
||||
pub struct StatusWidget {
|
||||
/// Shell-kommando som producerar en sträng (t.ex. `date '+%H:%M:%S'`)
|
||||
pub command: String,
|
||||
/// Hur ofta kommandot körs, i sekunder
|
||||
#[serde(default = "default_interval")]
|
||||
pub interval: u64,
|
||||
/// Hur många tecken brett blocket är
|
||||
pub width: u16,
|
||||
/// Dockas mot höger eller vänster i panelen
|
||||
#[serde(default)]
|
||||
pub align: StatusAlign,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug, Clone, Default, PartialEq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum StatusAlign {
|
||||
Left,
|
||||
#[default]
|
||||
Right,
|
||||
}
|
||||
|
||||
fn default_interval() -> u64 {
|
||||
10
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub fn load(path: &str) -> anyhow::Result<Config> {
|
||||
let content = fs::read_to_string(path)?;
|
||||
let mut config: Config = toml::from_str(&content)?;
|
||||
for panel in &mut config.panels {
|
||||
if let Some(file) = panel.file.clone() {
|
||||
let c = fs::read_to_string(&file)
|
||||
.map_err(|e| anyhow::anyhow!("Kunde inte läsa {}: {}", file, e))?;
|
||||
let ext: ExternalPanelConfig = toml::from_str(&c)
|
||||
.map_err(|e| anyhow::anyhow!("Parse-fel i {}: {}", file, e))?;
|
||||
panel.items = ext.items;
|
||||
panel.status_widgets = ext.status_widgets;
|
||||
}
|
||||
}
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
pub fn load_or_default(path: &str) -> Config {
|
||||
match Config::load(path) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
eprintln!("Varning: kunde inte ladda {}: {}", path, e);
|
||||
Config::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ExternalPanelConfig {
|
||||
#[serde(default, rename = "item")]
|
||||
items: Vec<MenuItem>,
|
||||
#[serde(default, rename = "status")]
|
||||
status_widgets: Vec<StatusWidget>,
|
||||
}
|
||||
189
src/main.rs
189
src/main.rs
@@ -1,77 +1,17 @@
|
||||
mod app;
|
||||
mod config;
|
||||
mod pty;
|
||||
mod render;
|
||||
|
||||
use app::App;
|
||||
use config::Config;
|
||||
use crossterm::{
|
||||
event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, MouseButton, MouseEventKind},
|
||||
event::{self, DisableMouseCapture, EnableMouseCapture},
|
||||
execute,
|
||||
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
|
||||
};
|
||||
use ratatui::{
|
||||
backend::CrosstermBackend,
|
||||
layout::Rect,
|
||||
style::{Color, Modifier, Style},
|
||||
text::{Line, Span},
|
||||
widgets::{Block, Borders, Clear, Paragraph},
|
||||
Terminal,
|
||||
};
|
||||
use std::io;
|
||||
|
||||
struct App {
|
||||
should_quit: bool,
|
||||
button_hovered: bool,
|
||||
}
|
||||
|
||||
impl App {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
should_quit: false,
|
||||
button_hovered: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_event(&mut self, event: Event, button_rect: Rect) {
|
||||
match event {
|
||||
Event::Key(key) => {
|
||||
if key.code == KeyCode::Char('q') || key.code == KeyCode::Esc {
|
||||
self.should_quit = true;
|
||||
}
|
||||
}
|
||||
Event::Mouse(mouse) => {
|
||||
let mx = mouse.column;
|
||||
let my = mouse.row;
|
||||
let in_button = mx >= button_rect.x
|
||||
&& mx < button_rect.x + button_rect.width
|
||||
&& my >= button_rect.y
|
||||
&& my < button_rect.y + button_rect.height;
|
||||
|
||||
match mouse.kind {
|
||||
MouseEventKind::Moved | MouseEventKind::Drag(_) => {
|
||||
self.button_hovered = in_button;
|
||||
}
|
||||
MouseEventKind::Down(MouseButton::Left) => {
|
||||
if in_button {
|
||||
self.should_quit = true;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn centered_rect(width: u16, height: u16, area: Rect) -> Rect {
|
||||
let x = area.x + area.width.saturating_sub(width) / 2;
|
||||
let y = area.y + area.height.saturating_sub(height) / 2;
|
||||
Rect::new(x, y, width.min(area.width), height.min(area.height))
|
||||
}
|
||||
|
||||
fn button_rect_inside_dialog(dialog: Rect) -> Rect {
|
||||
// Centered button, 16 wide, 3 tall, 2 rows from bottom of dialog
|
||||
let btn_w: u16 = 16;
|
||||
let btn_h: u16 = 3;
|
||||
let x = dialog.x + dialog.width.saturating_sub(btn_w) / 2;
|
||||
let y = dialog.y + dialog.height.saturating_sub(btn_h + 2);
|
||||
Rect::new(x, y, btn_w, btn_h)
|
||||
}
|
||||
use ratatui::{backend::CrosstermBackend, Terminal};
|
||||
use std::{io, time::Duration};
|
||||
|
||||
fn main() -> io::Result<()> {
|
||||
enable_raw_mode()?;
|
||||
@@ -81,86 +21,10 @@ fn main() -> io::Result<()> {
|
||||
let backend = CrosstermBackend::new(stdout);
|
||||
let mut terminal = Terminal::new(backend)?;
|
||||
|
||||
let mut app = App::new();
|
||||
// Initialize button_rect so the first event has something to compare against.
|
||||
let mut last_button_rect = Rect::default();
|
||||
let config = Config::load_or_default("config.toml");
|
||||
let mut app = App::new(config);
|
||||
|
||||
loop {
|
||||
terminal.draw(|frame| {
|
||||
let area = frame.area();
|
||||
|
||||
// Dialog box: 40 wide, 10 tall, centered
|
||||
let dialog = centered_rect(40, 10, area);
|
||||
let button = button_rect_inside_dialog(dialog);
|
||||
last_button_rect = button;
|
||||
|
||||
// Draw dialog
|
||||
let block = Block::default()
|
||||
.title(" TUI-WM ")
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(Color::Cyan));
|
||||
|
||||
frame.render_widget(Clear, dialog);
|
||||
frame.render_widget(block, dialog);
|
||||
|
||||
// Welcome text
|
||||
let text = Paragraph::new(vec![
|
||||
Line::from(""),
|
||||
Line::from(vec![Span::styled(
|
||||
"Välkommen till TUI-WM",
|
||||
Style::default().fg(Color::White).add_modifier(Modifier::BOLD),
|
||||
)]),
|
||||
Line::from(vec![Span::styled(
|
||||
"Tryck Q eller klicka Exit",
|
||||
Style::default().fg(Color::DarkGray),
|
||||
)]),
|
||||
])
|
||||
.alignment(ratatui::layout::Alignment::Center);
|
||||
|
||||
// Inner area for text (leave room for button)
|
||||
let text_area = Rect::new(
|
||||
dialog.x + 1,
|
||||
dialog.y + 1,
|
||||
dialog.width.saturating_sub(2),
|
||||
dialog.height.saturating_sub(5),
|
||||
);
|
||||
frame.render_widget(text, text_area);
|
||||
|
||||
// Exit button with hover animation
|
||||
let (btn_style, btn_border_style, label) = if app.button_hovered {
|
||||
(
|
||||
Style::default().fg(Color::Black).bg(Color::Cyan).add_modifier(Modifier::BOLD),
|
||||
Style::default().fg(Color::White),
|
||||
" [ EXIT ] ",
|
||||
)
|
||||
} else {
|
||||
(
|
||||
Style::default().fg(Color::Cyan),
|
||||
Style::default().fg(Color::DarkGray),
|
||||
" Exit ",
|
||||
)
|
||||
};
|
||||
|
||||
let btn_widget = Paragraph::new(Line::from(vec![Span::styled(label, btn_style)]))
|
||||
.block(
|
||||
Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_style(btn_border_style),
|
||||
)
|
||||
.alignment(ratatui::layout::Alignment::Center);
|
||||
|
||||
frame.render_widget(btn_widget, button);
|
||||
})?;
|
||||
|
||||
if event::poll(std::time::Duration::from_millis(16))? {
|
||||
let ev = event::read()?;
|
||||
app.handle_event(ev, last_button_rect);
|
||||
}
|
||||
|
||||
if app.should_quit {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let result = run(&mut terminal, &mut app);
|
||||
|
||||
disable_raw_mode()?;
|
||||
execute!(
|
||||
@@ -170,5 +34,32 @@ fn main() -> io::Result<()> {
|
||||
)?;
|
||||
terminal.show_cursor()?;
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
fn run(
|
||||
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
|
||||
app: &mut App,
|
||||
) -> io::Result<()> {
|
||||
loop {
|
||||
// Uppdatera layout från aktuell terminalstorlek
|
||||
let size = terminal.size()?;
|
||||
app.update_layout(ratatui::layout::Rect::new(0, 0, size.width, size.height));
|
||||
|
||||
// Rendera
|
||||
terminal.draw(|frame| render::render(frame, app))?;
|
||||
|
||||
// Töm PTY-output och uppdatera terminalparsers
|
||||
app.tick();
|
||||
|
||||
// Hantera inkommande events (kort timeout → snabb PTY-uppdatering)
|
||||
if event::poll(Duration::from_millis(16))? {
|
||||
app.handle_event(event::read()?);
|
||||
}
|
||||
|
||||
if app.should_quit {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
67
src/pty.rs
Normal file
67
src/pty.rs
Normal file
@@ -0,0 +1,67 @@
|
||||
use anyhow::Result;
|
||||
use portable_pty::{CommandBuilder, NativePtySystem, PtySize, PtySystem};
|
||||
use std::io::Read;
|
||||
use std::sync::mpsc;
|
||||
use std::thread;
|
||||
|
||||
pub struct PtyTerminal {
|
||||
pub master: Box<dyn portable_pty::MasterPty + Send>,
|
||||
pub writer: Box<dyn std::io::Write + Send>,
|
||||
_child: Box<dyn portable_pty::Child + Send + Sync>,
|
||||
}
|
||||
|
||||
impl PtyTerminal {
|
||||
pub fn spawn(shell: &str, rows: u16, cols: u16) -> Result<(Self, mpsc::Receiver<Vec<u8>>)> {
|
||||
let pty_system = NativePtySystem::default();
|
||||
let pair = pty_system.openpty(PtySize {
|
||||
rows,
|
||||
cols,
|
||||
pixel_width: 0,
|
||||
pixel_height: 0,
|
||||
})?;
|
||||
|
||||
let mut cmd = CommandBuilder::new(shell);
|
||||
cmd.env("TERM", "xterm-256color");
|
||||
cmd.env("COLORTERM", "truecolor");
|
||||
|
||||
let child = pair.slave.spawn_command(cmd)?;
|
||||
drop(pair.slave);
|
||||
|
||||
let mut reader = pair.master.try_clone_reader()?;
|
||||
let writer = pair.master.take_writer()?;
|
||||
|
||||
let (tx, rx) = mpsc::channel();
|
||||
thread::spawn(move || {
|
||||
let mut buf = [0u8; 4096];
|
||||
loop {
|
||||
match reader.read(&mut buf) {
|
||||
Ok(0) | Err(_) => break,
|
||||
Ok(n) => {
|
||||
if tx.send(buf[..n].to_vec()).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Ok((PtyTerminal { master: pair.master, writer, _child: child }, rx))
|
||||
}
|
||||
|
||||
pub fn resize(&mut self, rows: u16, cols: u16) -> Result<()> {
|
||||
self.master.resize(PtySize {
|
||||
rows,
|
||||
cols,
|
||||
pixel_width: 0,
|
||||
pixel_height: 0,
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn write_input(&mut self, data: &[u8]) -> Result<()> {
|
||||
use std::io::Write;
|
||||
self.writer.write_all(data)?;
|
||||
self.writer.flush()?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
388
src/render.rs
Normal file
388
src/render.rs
Normal file
@@ -0,0 +1,388 @@
|
||||
use crate::app::{App, DropdownState, FloatingWindow, ResizeEdge, WindowContent};
|
||||
use crate::config::StatusAlign;
|
||||
use ratatui::{
|
||||
layout::Rect,
|
||||
style::{Color, Modifier, Style},
|
||||
text::{Line, Span},
|
||||
widgets::{Block, Borders, Clear, Paragraph},
|
||||
Frame,
|
||||
};
|
||||
|
||||
pub fn render(frame: &mut Frame, app: &App) {
|
||||
let area = frame.area();
|
||||
|
||||
// Bakgrund
|
||||
frame.render_widget(
|
||||
Block::default().style(Style::default().bg(Color::Indexed(235))),
|
||||
area,
|
||||
);
|
||||
|
||||
// Flytande fönster – sista i listan renderas överst
|
||||
for window in &app.windows {
|
||||
let resize_hover = app
|
||||
.hovered_resize
|
||||
.filter(|(id, _)| *id == window.id)
|
||||
.map(|(_, e)| e);
|
||||
render_window(frame, window, app.focused_id, app.hovered_window_close, resize_hover);
|
||||
}
|
||||
|
||||
// Paneler – alltid ovanpå fönster
|
||||
for (pi, panel_cfg) in app.config.panels.iter().enumerate() {
|
||||
if let Some(&pr) = app.panel_rects.get(pi) {
|
||||
let item_rects =
|
||||
app.panel_item_rects.get(pi).map(|v| v.as_slice()).unwrap_or(&[]);
|
||||
let status_states = app.status_states.get(pi).map(|v| v.as_slice()).unwrap_or(&[]);
|
||||
render_panel(
|
||||
frame,
|
||||
pr,
|
||||
&panel_cfg.items,
|
||||
item_rects,
|
||||
app.hovered_panel_item,
|
||||
pi,
|
||||
&panel_cfg.status_widgets,
|
||||
status_states,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Dropdown – allra överst
|
||||
if let Some(dd) = &app.dropdown {
|
||||
render_dropdown(frame, dd);
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn render_panel(
|
||||
frame: &mut Frame,
|
||||
rect: Rect,
|
||||
items: &[crate::config::MenuItem],
|
||||
item_rects: &[Rect],
|
||||
hovered: Option<(usize, usize)>,
|
||||
panel_idx: usize,
|
||||
status_widgets: &[crate::config::StatusWidget],
|
||||
status_states: &[crate::app::StatusWidgetState],
|
||||
) {
|
||||
// Panelens bakgrund
|
||||
frame.render_widget(
|
||||
Block::default().style(Style::default().bg(Color::Indexed(238))),
|
||||
rect,
|
||||
);
|
||||
|
||||
// Logotyp
|
||||
frame.render_widget(
|
||||
Paragraph::new(Span::styled(
|
||||
" TUI-WM ",
|
||||
Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD),
|
||||
)),
|
||||
Rect::new(rect.x + 1, rect.y, 9, 1),
|
||||
);
|
||||
|
||||
// Vänsterjusterade knappar
|
||||
for (ii, item) in items.iter().enumerate() {
|
||||
let Some(&ir) = item_rects.get(ii) else { continue };
|
||||
let is_hovered = hovered == Some((panel_idx, ii));
|
||||
let style = if is_hovered {
|
||||
Style::default().fg(Color::Black).bg(Color::Cyan).add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
Style::default().fg(Color::White).bg(Color::Indexed(238))
|
||||
};
|
||||
frame.render_widget(
|
||||
Paragraph::new(Span::styled(format!(" {} ", item.label), style)),
|
||||
ir,
|
||||
);
|
||||
}
|
||||
|
||||
// Status-widgets
|
||||
// Höger-dockade renderas från höger kant inåt
|
||||
let mut right_x = rect.x + rect.width;
|
||||
for (si, sw_cfg) in status_widgets.iter().enumerate().rev() {
|
||||
if sw_cfg.align != StatusAlign::Right {
|
||||
continue;
|
||||
}
|
||||
right_x = right_x.saturating_sub(sw_cfg.width);
|
||||
let text = status_states
|
||||
.get(si)
|
||||
.map(|s| s.output.as_str())
|
||||
.unwrap_or("");
|
||||
// Trunkera eller padda till exakt width
|
||||
let padded = format!("{:>width$}", text, width = sw_cfg.width as usize);
|
||||
let draw_rect = Rect::new(right_x, rect.y, sw_cfg.width, 1);
|
||||
frame.render_widget(
|
||||
Paragraph::new(Span::styled(
|
||||
padded,
|
||||
Style::default().fg(Color::Indexed(252)).bg(Color::Indexed(238)),
|
||||
)),
|
||||
draw_rect,
|
||||
);
|
||||
}
|
||||
|
||||
// Vänster-dockade status-widgets renderas efter knapparna
|
||||
let left_start = item_rects.last().map(|r| r.x + r.width + 1).unwrap_or(rect.x + 10);
|
||||
let mut left_x = left_start;
|
||||
for (si, sw_cfg) in status_widgets.iter().enumerate() {
|
||||
if sw_cfg.align != StatusAlign::Left {
|
||||
continue;
|
||||
}
|
||||
let text = status_states.get(si).map(|s| s.output.as_str()).unwrap_or("");
|
||||
let padded = format!("{:<width$}", text, width = sw_cfg.width as usize);
|
||||
let draw_rect = Rect::new(left_x, rect.y, sw_cfg.width, 1);
|
||||
frame.render_widget(
|
||||
Paragraph::new(Span::styled(
|
||||
padded,
|
||||
Style::default().fg(Color::Indexed(252)).bg(Color::Indexed(238)),
|
||||
)),
|
||||
draw_rect,
|
||||
);
|
||||
left_x += sw_cfg.width + 1;
|
||||
}
|
||||
}
|
||||
|
||||
fn render_dropdown(frame: &mut Frame, dd: &DropdownState) {
|
||||
let border_rect = Rect::new(dd.rect.x, dd.rect.y, dd.rect.width, dd.rect.height + 2);
|
||||
frame.render_widget(Clear, border_rect);
|
||||
frame.render_widget(
|
||||
Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(Color::Cyan))
|
||||
.style(Style::default().bg(Color::Indexed(236))),
|
||||
border_rect,
|
||||
);
|
||||
for (i, (item, &ir)) in dd.items.iter().zip(dd.item_rects.iter()).enumerate() {
|
||||
let is_hovered = dd.hovered == Some(i);
|
||||
let style = if is_hovered {
|
||||
Style::default().fg(Color::Black).bg(Color::Cyan).add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
Style::default().fg(Color::White).bg(Color::Indexed(236))
|
||||
};
|
||||
let draw_rect = Rect::new(ir.x + 1, ir.y + 1, ir.width.saturating_sub(2), 1);
|
||||
frame.render_widget(
|
||||
Paragraph::new(Span::styled(format!(" {} ", item.label), style)),
|
||||
draw_rect,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn render_window(
|
||||
frame: &mut Frame,
|
||||
window: &FloatingWindow,
|
||||
focused_id: Option<usize>,
|
||||
hovered_close: Option<usize>,
|
||||
resize_hover: Option<ResizeEdge>,
|
||||
) {
|
||||
let full_rect = window.rect().intersection(frame.area());
|
||||
if full_rect.width < 4 || full_rect.height < 2 {
|
||||
return;
|
||||
}
|
||||
|
||||
let is_focused = focused_id == Some(window.id);
|
||||
let is_resizing = window.resizing.is_some();
|
||||
|
||||
let border_style = if is_resizing {
|
||||
Style::default().fg(Color::Yellow)
|
||||
} else if resize_hover.is_some() {
|
||||
Style::default().fg(Color::Indexed(220)) // ljusgul vid hover på kant
|
||||
} else if is_focused {
|
||||
Style::default().fg(Color::Cyan)
|
||||
} else {
|
||||
Style::default().fg(Color::Indexed(242))
|
||||
};
|
||||
|
||||
let title_text = match &window.content {
|
||||
WindowContent::Terminal { title, .. } => format!(" {} ", title),
|
||||
WindowContent::RunDialog { .. } => " Tui-run ".to_string(),
|
||||
};
|
||||
|
||||
frame.render_widget(Clear, full_rect);
|
||||
frame.render_widget(
|
||||
Block::default()
|
||||
.title(Span::styled(
|
||||
&title_text,
|
||||
Style::default().fg(Color::White).add_modifier(Modifier::BOLD),
|
||||
))
|
||||
.borders(Borders::ALL)
|
||||
.border_style(border_style)
|
||||
.style(Style::default().bg(Color::Black)),
|
||||
full_rect,
|
||||
);
|
||||
|
||||
// Stäng-knapp [X]
|
||||
let cb = window.close_btn_rect();
|
||||
if cb.x + 3 <= full_rect.x + full_rect.width {
|
||||
let close_hov = hovered_close == Some(window.id);
|
||||
let close_style = if close_hov {
|
||||
Style::default().fg(Color::White).bg(Color::Red).add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
Style::default().fg(Color::Red)
|
||||
};
|
||||
frame.render_widget(Paragraph::new(Span::styled("[X]", close_style)), cb);
|
||||
}
|
||||
|
||||
// Resize-hints – bara om fönstret är resizable
|
||||
if window.resizable {
|
||||
render_resize_hints(frame, window, full_rect, resize_hover, is_resizing);
|
||||
}
|
||||
|
||||
// Innehåll
|
||||
let cr = window.content_rect().intersection(frame.area());
|
||||
if cr.width == 0 || cr.height == 0 {
|
||||
return;
|
||||
}
|
||||
match &window.content {
|
||||
WindowContent::Terminal { parser, alive, .. } => {
|
||||
if *alive {
|
||||
render_terminal(frame, parser.screen(), cr);
|
||||
} else {
|
||||
frame.render_widget(
|
||||
Paragraph::new(Span::styled(
|
||||
" [Avslutad] ",
|
||||
Style::default().fg(Color::DarkGray),
|
||||
)),
|
||||
cr,
|
||||
);
|
||||
}
|
||||
}
|
||||
WindowContent::RunDialog { input, cursor_pos } => {
|
||||
render_run_dialog_content(frame, input, *cursor_pos, cr);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Visar subtila tecken i hörnen/kanterna för att indikera resize-möjlighet
|
||||
fn render_resize_hints(
|
||||
frame: &mut Frame,
|
||||
window: &FloatingWindow,
|
||||
rect: Rect,
|
||||
hover: Option<ResizeEdge>,
|
||||
resizing: bool,
|
||||
) {
|
||||
let active_style =
|
||||
Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD);
|
||||
let hint_style = Style::default().fg(Color::Indexed(240));
|
||||
|
||||
let corners: &[(ResizeEdge, u16, u16, &str)] = &[
|
||||
(ResizeEdge::TopLeft, rect.x, rect.y, "◤"),
|
||||
(ResizeEdge::BottomLeft, rect.x, rect.y + rect.height - 1, "◣"),
|
||||
(ResizeEdge::BottomRight, rect.x + rect.width - 1, rect.y + rect.height - 1, "◢"),
|
||||
];
|
||||
|
||||
for &(edge, cx, cy, sym) in corners {
|
||||
let style = if resizing && window.resizing.as_ref().map(|r| r.edge) == Some(edge) {
|
||||
active_style
|
||||
} else if hover == Some(edge) {
|
||||
active_style
|
||||
} else {
|
||||
hint_style
|
||||
};
|
||||
let r = Rect::new(cx, cy, 1, 1).intersection(frame.area());
|
||||
if r.width > 0 && r.height > 0 {
|
||||
frame.render_widget(Paragraph::new(Span::styled(sym, style)), r);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn render_run_dialog_content(frame: &mut Frame, input: &str, cursor_pos: usize, area: Rect) {
|
||||
if area.height == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
// Rad 0: inputfält med prompt och markör
|
||||
let prefix = "> ";
|
||||
let before_cursor = &input[..cursor_pos];
|
||||
let at_cursor = input[cursor_pos..].chars().next().map(|c| c.to_string()).unwrap_or_else(|| " ".to_string());
|
||||
let after_cursor = if cursor_pos < input.len() {
|
||||
let skip = at_cursor.len();
|
||||
&input[cursor_pos + skip..]
|
||||
} else {
|
||||
""
|
||||
};
|
||||
|
||||
let input_line = Line::from(vec![
|
||||
Span::styled(prefix, Style::default().fg(Color::Cyan)),
|
||||
Span::styled(before_cursor, Style::default().fg(Color::White)),
|
||||
Span::styled(&at_cursor, Style::default().fg(Color::Black).bg(Color::White).add_modifier(Modifier::BOLD)),
|
||||
Span::styled(after_cursor, Style::default().fg(Color::White)),
|
||||
]);
|
||||
frame.render_widget(
|
||||
Paragraph::new(input_line),
|
||||
Rect::new(area.x, area.y, area.width, 1),
|
||||
);
|
||||
|
||||
// Rad 1: tom
|
||||
// Rad 2: hjälptext
|
||||
if area.height >= 3 {
|
||||
frame.render_widget(
|
||||
Paragraph::new(Span::styled(
|
||||
" Enter=Kör Esc=Avbryt",
|
||||
Style::default().fg(Color::Indexed(242)),
|
||||
)),
|
||||
Rect::new(area.x, area.y + 2, area.width, 1),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn render_terminal(frame: &mut Frame, screen: &vt100::Screen, area: Rect) {
|
||||
let (screen_rows, screen_cols) = screen.size();
|
||||
let rows = (area.height as usize).min(screen_rows as usize);
|
||||
let cols = (area.width as usize).min(screen_cols as usize);
|
||||
let (cur_row, cur_col) = screen.cursor_position();
|
||||
|
||||
for r in 0..rows {
|
||||
let mut spans: Vec<Span> = Vec::new();
|
||||
let mut cur_style = Style::default();
|
||||
let mut cur_text = String::new();
|
||||
|
||||
for c in 0..cols {
|
||||
let is_cursor = r == cur_row as usize && c == cur_col as usize;
|
||||
let (sym, style) = match screen.cell(r as u16, c as u16) {
|
||||
Some(cell) => {
|
||||
let s = cell.contents();
|
||||
let s = if s.is_empty() { " ".to_string() } else { s.to_string() };
|
||||
let mut st = Style::default()
|
||||
.fg(vt_color(cell.fgcolor()))
|
||||
.bg(vt_color(cell.bgcolor()));
|
||||
if cell.bold() {
|
||||
st = st.add_modifier(Modifier::BOLD);
|
||||
}
|
||||
if cell.italic() {
|
||||
st = st.add_modifier(Modifier::ITALIC);
|
||||
}
|
||||
if cell.underline() {
|
||||
st = st.add_modifier(Modifier::UNDERLINED);
|
||||
}
|
||||
if is_cursor {
|
||||
st = Style::default().fg(Color::Black).bg(Color::White);
|
||||
}
|
||||
(s, st)
|
||||
}
|
||||
None => (" ".to_string(), Style::default()),
|
||||
};
|
||||
|
||||
if style == cur_style {
|
||||
cur_text.push_str(&sym);
|
||||
} else {
|
||||
if !cur_text.is_empty() {
|
||||
spans.push(Span::styled(cur_text.clone(), cur_style));
|
||||
cur_text.clear();
|
||||
}
|
||||
cur_style = style;
|
||||
cur_text = sym;
|
||||
}
|
||||
}
|
||||
if !cur_text.is_empty() {
|
||||
spans.push(Span::styled(cur_text, cur_style));
|
||||
}
|
||||
|
||||
frame.render_widget(
|
||||
Paragraph::new(Line::from(spans)),
|
||||
Rect::new(area.x, area.y + r as u16, area.width, 1),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn vt_color(c: vt100::Color) -> Color {
|
||||
match c {
|
||||
vt100::Color::Default => Color::Reset,
|
||||
vt100::Color::Idx(i) => Color::Indexed(i),
|
||||
vt100::Color::Rgb(r, g, b) => Color::Rgb(r, g, b),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user