diff --git a/.gitignore b/.gitignore index ea8c4bf..8985814 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ /target +/builds diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 7437a74..d91d6bf 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -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"] } diff --git a/Cargo.lock b/Cargo.lock index 1c8b30a..4019032 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", +] diff --git a/Cargo.toml b/Cargo.toml index 2fabd2b..5c01e0f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/config.toml b/config.toml new file mode 100644 index 0000000..48f7d64 --- /dev/null +++ b/config.toml @@ -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" } diff --git a/dist/linux/config.toml b/dist/linux/config.toml new file mode 100644 index 0000000..d841acf --- /dev/null +++ b/dist/linux/config.toml @@ -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" } diff --git a/dist/linux/panels/topbar.toml b/dist/linux/panels/topbar.toml new file mode 100644 index 0000000..cd60484 --- /dev/null +++ b/dist/linux/panels/topbar.toml @@ -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" diff --git a/dist/win/config.toml b/dist/win/config.toml new file mode 100644 index 0000000..0c20ba5 --- /dev/null +++ b/dist/win/config.toml @@ -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" } diff --git a/dist/win/panels/topbar.toml b/dist/win/panels/topbar.toml new file mode 100644 index 0000000..57579ee --- /dev/null +++ b/dist/win/panels/topbar.toml @@ -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" diff --git a/panels/topbar.toml b/panels/topbar.toml new file mode 100644 index 0000000..24942c1 --- /dev/null +++ b/panels/topbar.toml @@ -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" diff --git a/src/app.rs b/src/app.rs new file mode 100644 index 0000000..587deb1 --- /dev/null +++ b/src/app.rs @@ -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 = 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 { + 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::().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, // 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, + pub panel_item_rects: Vec>, + pub content_area: Rect, + + // Hover-tillstånd + pub hovered_panel_item: Option<(usize, usize)>, + pub hovered_window_close: Option, + pub hovered_resize: Option<(usize, ResizeEdge)>, + + // Öppen dropdown + pub dropdown: Option, + + // Flytande fönster (sista = överst) + pub windows: Vec, + pub next_id: usize, + pub focused_id: Option, + + // Status-widget körtillstånd, indexerat [panel][widget] + pub status_states: Vec>, + + // Parsade keybinds från config + pub parsed_keybinds: Vec, +} + +pub struct DropdownState { + pub panel_idx: usize, + pub items: Vec, + pub rect: Rect, + pub item_rects: Vec, + pub hovered: Option, +} + +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, + /// Om false: inga resize-kanter, kan inte storleksändras + pub resizable: bool, +} + +pub enum WindowContent { + Terminal { + pty: PtyTerminal, + rx: mpsc::Receiver>, + 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 { + 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> = config + .panels + .iter() + .map(|p| { + p.status_widgets + .iter() + .map(|_| StatusWidgetState { output: String::new(), last_run: None }) + .collect() + }) + .collect(); + + let parsed_keybinds: Vec = 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 { + 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) { + 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 = + (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 { + 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 { + 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> { + use KeyCode::*; + let bytes: Vec = 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) +} + diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..e865dbe --- /dev/null +++ b/src/config.rs @@ -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, + #[serde(default, rename = "keybind")] + pub keybinds: Vec, +} + +/// 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, + #[serde(default, rename = "status")] + pub status_widgets: Vec, + /// Länk till extern fil med panel-definition + pub file: Option, +} + +#[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, + }, + RunScript { + path: String, + }, + RunProgram { + command: String, + #[serde(default)] + args: Vec, + }, + Submenu { + #[serde(default, rename = "item")] + items: Vec, + }, + /// Ö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 { + 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, + #[serde(default, rename = "status")] + status_widgets: Vec, +} diff --git a/src/main.rs b/src/main.rs index d41215e..5410dbb 100644 --- a/src/main.rs +++ b/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>, + 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(()) } diff --git a/src/pty.rs b/src/pty.rs new file mode 100644 index 0000000..1760cdd --- /dev/null +++ b/src/pty.rs @@ -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, + pub writer: Box, + _child: Box, +} + +impl PtyTerminal { + pub fn spawn(shell: &str, rows: u16, cols: u16) -> Result<(Self, mpsc::Receiver>)> { + 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(()) + } +} diff --git a/src/render.rs b/src/render.rs new file mode 100644 index 0000000..4f3e405 --- /dev/null +++ b/src/render.rs @@ -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!("{:, + hovered_close: Option, + resize_hover: Option, +) { + 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, + 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 = 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), + } +}