Implement Kitty graphics protocol for background image rendering
- Added `kitty_gfx` module to handle loading, scaling, and displaying background images using the Kitty graphics protocol. - Integrated background image handling into the main application loop, allowing dynamic updates based on configuration. - Enhanced terminal rendering to support transparent backgrounds when a Kitty image is active. - Updated IPC server to manage client connections and messages, including handling background image settings. - Modified `PtyTerminal` to accept additional environment variables during shell spawning. - Improved rendering logic to support popup dialogs and terminal background color customization.
This commit is contained in:
194
Cargo.lock
generated
194
Cargo.lock
generated
@@ -2,6 +2,12 @@
|
|||||||
# It is not intended for manual editing.
|
# It is not intended for manual editing.
|
||||||
version = 4
|
version = 4
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "adler2"
|
||||||
|
version = "2.0.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "allocator-api2"
|
name = "allocator-api2"
|
||||||
version = "0.2.21"
|
version = "0.2.21"
|
||||||
@@ -38,6 +44,18 @@ version = "2.11.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af"
|
checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bytemuck"
|
||||||
|
version = "1.25.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "byteorder-lite"
|
||||||
|
version = "0.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cassowary"
|
name = "cassowary"
|
||||||
version = "0.3.0"
|
version = "0.3.0"
|
||||||
@@ -59,6 +77,12 @@ version = "1.0.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "color_quant"
|
||||||
|
version = "1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "compact_str"
|
name = "compact_str"
|
||||||
version = "0.8.1"
|
version = "0.8.1"
|
||||||
@@ -73,6 +97,15 @@ dependencies = [
|
|||||||
"static_assertions",
|
"static_assertions",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "crc32fast"
|
||||||
|
version = "1.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "crossterm"
|
name = "crossterm"
|
||||||
version = "0.28.1"
|
version = "0.28.1"
|
||||||
@@ -161,6 +194,15 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fdeflate"
|
||||||
|
version = "0.3.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c"
|
||||||
|
dependencies = [
|
||||||
|
"simd-adler32",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "filedescriptor"
|
name = "filedescriptor"
|
||||||
version = "0.8.3"
|
version = "0.8.3"
|
||||||
@@ -172,6 +214,16 @@ dependencies = [
|
|||||||
"winapi",
|
"winapi",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "flate2"
|
||||||
|
version = "1.1.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
|
||||||
|
dependencies = [
|
||||||
|
"crc32fast",
|
||||||
|
"miniz_oxide",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "foldhash"
|
name = "foldhash"
|
||||||
version = "0.1.5"
|
version = "0.1.5"
|
||||||
@@ -184,6 +236,16 @@ version = "0.3.32"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
|
checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "gif"
|
||||||
|
version = "0.14.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f5df2ba84018d80c213569363bdcd0c64e6933c67fe4c1d60ecf822971a3c35e"
|
||||||
|
dependencies = [
|
||||||
|
"color_quant",
|
||||||
|
"weezl",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hashbrown"
|
name = "hashbrown"
|
||||||
version = "0.15.5"
|
version = "0.15.5"
|
||||||
@@ -213,6 +275,34 @@ version = "1.0.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
|
checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "image"
|
||||||
|
version = "0.25.10"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104"
|
||||||
|
dependencies = [
|
||||||
|
"bytemuck",
|
||||||
|
"byteorder-lite",
|
||||||
|
"color_quant",
|
||||||
|
"gif",
|
||||||
|
"image-webp",
|
||||||
|
"moxcms",
|
||||||
|
"num-traits",
|
||||||
|
"png",
|
||||||
|
"zune-core",
|
||||||
|
"zune-jpeg",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "image-webp"
|
||||||
|
version = "0.2.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3"
|
||||||
|
dependencies = [
|
||||||
|
"byteorder-lite",
|
||||||
|
"quick-error",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "indexmap"
|
name = "indexmap"
|
||||||
version = "2.13.0"
|
version = "2.13.0"
|
||||||
@@ -326,6 +416,16 @@ dependencies = [
|
|||||||
"autocfg",
|
"autocfg",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "miniz_oxide"
|
||||||
|
version = "0.8.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
|
||||||
|
dependencies = [
|
||||||
|
"adler2",
|
||||||
|
"simd-adler32",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mio"
|
name = "mio"
|
||||||
version = "1.2.0"
|
version = "1.2.0"
|
||||||
@@ -338,6 +438,16 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "moxcms"
|
||||||
|
version = "0.8.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b"
|
||||||
|
dependencies = [
|
||||||
|
"num-traits",
|
||||||
|
"pxfm",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "nix"
|
name = "nix"
|
||||||
version = "0.25.1"
|
version = "0.25.1"
|
||||||
@@ -352,6 +462,15 @@ dependencies = [
|
|||||||
"pin-utils",
|
"pin-utils",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "num-traits"
|
||||||
|
version = "0.2.19"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
|
||||||
|
dependencies = [
|
||||||
|
"autocfg",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "parking_lot"
|
name = "parking_lot"
|
||||||
version = "0.12.5"
|
version = "0.12.5"
|
||||||
@@ -387,6 +506,19 @@ version = "0.1.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
|
checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "png"
|
||||||
|
version = "0.18.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.11.0",
|
||||||
|
"crc32fast",
|
||||||
|
"fdeflate",
|
||||||
|
"flate2",
|
||||||
|
"miniz_oxide",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "portable-pty"
|
name = "portable-pty"
|
||||||
version = "0.8.1"
|
version = "0.8.1"
|
||||||
@@ -417,6 +549,18 @@ dependencies = [
|
|||||||
"unicode-ident",
|
"unicode-ident",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pxfm"
|
||||||
|
version = "0.1.28"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b5a041e753da8b807c9255f28de81879c78c876392ff2469cde94799b2896b9d"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "quick-error"
|
||||||
|
version = "2.0.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "quote"
|
name = "quote"
|
||||||
version = "1.0.45"
|
version = "1.0.45"
|
||||||
@@ -517,6 +661,19 @@ dependencies = [
|
|||||||
"syn",
|
"syn",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_json"
|
||||||
|
version = "1.0.149"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
|
||||||
|
dependencies = [
|
||||||
|
"itoa",
|
||||||
|
"memchr",
|
||||||
|
"serde",
|
||||||
|
"serde_core",
|
||||||
|
"zmij",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serde_spanned"
|
name = "serde_spanned"
|
||||||
version = "0.6.9"
|
version = "0.6.9"
|
||||||
@@ -615,6 +772,12 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "simd-adler32"
|
||||||
|
version = "0.3.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "smallvec"
|
name = "smallvec"
|
||||||
version = "1.15.1"
|
version = "1.15.1"
|
||||||
@@ -742,10 +905,14 @@ version = "0.1.0"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"crossterm",
|
"crossterm",
|
||||||
|
"image",
|
||||||
|
"libc",
|
||||||
"portable-pty",
|
"portable-pty",
|
||||||
"ratatui",
|
"ratatui",
|
||||||
"serde",
|
"serde",
|
||||||
|
"serde_json",
|
||||||
"toml",
|
"toml",
|
||||||
|
"toml_edit",
|
||||||
"vt100",
|
"vt100",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -829,6 +996,12 @@ version = "0.11.1+wasi-snapshot-preview1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
|
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "weezl"
|
||||||
|
version = "0.1.12"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "winapi"
|
name = "winapi"
|
||||||
version = "0.3.9"
|
version = "0.3.9"
|
||||||
@@ -956,3 +1129,24 @@ checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"winapi",
|
"winapi",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zmij"
|
||||||
|
version = "1.0.21"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zune-core"
|
||||||
|
version = "0.5.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zune-jpeg"
|
||||||
|
version = "0.5.15"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296"
|
||||||
|
dependencies = [
|
||||||
|
"zune-core",
|
||||||
|
]
|
||||||
|
|||||||
@@ -11,7 +11,11 @@ path = "src/main.rs"
|
|||||||
crossterm = { version = "0.28", features = ["event-stream"] }
|
crossterm = { version = "0.28", features = ["event-stream"] }
|
||||||
ratatui = "0.29"
|
ratatui = "0.29"
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
toml = "0.8"
|
toml = "0.8"
|
||||||
|
toml_edit = "0.22"
|
||||||
portable-pty = "0.8"
|
portable-pty = "0.8"
|
||||||
vt100 = "0.15"
|
vt100 = "0.15"
|
||||||
anyhow = "1"
|
anyhow = "1"
|
||||||
|
image = { version = "0.25", default-features = false, features = ["jpeg", "png", "gif", "bmp", "webp"] }
|
||||||
|
libc = "0.2"
|
||||||
|
|||||||
43
README.md
43
README.md
@@ -213,6 +213,49 @@ Möss-tracking fungerar automatiskt — om appen i terminalen aktiverar ANSI mus
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Startlägen
|
||||||
|
|
||||||
|
TUI-WM kan startas i tre lägen:
|
||||||
|
|
||||||
|
### Standalone (standard)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tui-wm
|
||||||
|
```
|
||||||
|
|
||||||
|
Kör fönsterhanteraren i den aktuella terminalen och startar samtidigt en Unix-socket-server. Andra program kan ansluta till socketen medan TUI-WM kör.
|
||||||
|
|
||||||
|
### Daemon (`-d`)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tui-wm -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Kör headless — ingen lokal terminal-rendering. State hanteras enbart via socketen. Frames renderas internt och skickas till anslutna display-klienter. Passar för att köra TUI-WM som bakgrundstjänst.
|
||||||
|
|
||||||
|
### Klient (`-c`)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tui-wm -c [socket-sökväg]
|
||||||
|
```
|
||||||
|
|
||||||
|
Ansluter till en befintlig TUI-WM-server (standalone eller daemon) som display-klient. Renderar mottagna frames och vidarebefordrar tangentbords- och resize-events. Om `socket-sökväg` utelämnas används standard-sökvägen. Koppla ned med **Ctrl+Shift+Q**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Socket-API
|
||||||
|
|
||||||
|
Appar som körs inne i TUI-WM får miljövariablerna `TUI_WM_SOCKET` (sökväg till socketen) och `TUI_WM_WINDOW_ID` (fönstrets ID) satta automatiskt. Via socketen kan de:
|
||||||
|
|
||||||
|
- Spawna nya terminalfönster
|
||||||
|
- Visa popup-dialoger med knappar och vänta på svar
|
||||||
|
- Lista eller stänga fönster
|
||||||
|
- Ta emot renderade frames (ANSI) för att visa TUI-WM:s skärm
|
||||||
|
|
||||||
|
Se [SOCKET_API.md](SOCKET_API.md) för fullständig protokolldokumentation med JSON-exempel och kodexempel i Bash och Python.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Standardtangenter
|
## Standardtangenter
|
||||||
|
|
||||||
| Tangent | Funktion |
|
| Tangent | Funktion |
|
||||||
|
|||||||
376
SOCKET_API.md
Normal file
376
SOCKET_API.md
Normal file
@@ -0,0 +1,376 @@
|
|||||||
|
# TUI-WM Socket API
|
||||||
|
|
||||||
|
TUI-WM exposes a Unix domain socket that allows other applications to interact with the window manager: spawn windows, show popup dialogs, list or close windows, and receive rendered frames.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The protocol uses a simple framing format: every message is preceded by a 4-byte big-endian `u32` indicating the byte length of the JSON payload, followed by the JSON itself.
|
||||||
|
|
||||||
|
```
|
||||||
|
[ 4 bytes: length (big-endian u32) ][ N bytes: JSON ]
|
||||||
|
```
|
||||||
|
|
||||||
|
All messages are JSON objects with a mandatory `"type"` field (snake_case) that discriminates the message kind.
|
||||||
|
|
||||||
|
## Connecting
|
||||||
|
|
||||||
|
### Environment variables
|
||||||
|
|
||||||
|
When TUI-WM spawns a terminal inside a window it sets two environment variables for the child process:
|
||||||
|
|
||||||
|
| Variable | Example value | Description |
|
||||||
|
|-------------------|----------------------------|------------------------------------------|
|
||||||
|
| `TUI_WM_SOCKET` | `/run/user/1000/tui-wm.sock` | Path to the Unix socket |
|
||||||
|
| `TUI_WM_WINDOW_ID`| `3` | The window ID of the containing window |
|
||||||
|
|
||||||
|
### Finding the socket path
|
||||||
|
|
||||||
|
If the environment variables are not set (e.g. you are connecting externally), the socket is located at:
|
||||||
|
|
||||||
|
- `$XDG_RUNTIME_DIR/tui-wm.sock` (preferred)
|
||||||
|
- `/tmp/tui-wm-$USER.sock` (fallback)
|
||||||
|
- `/tmp/tui-wm.sock` (last resort)
|
||||||
|
|
||||||
|
### Client roles
|
||||||
|
|
||||||
|
Every client must identify itself as one of two roles in the initial `hello` message:
|
||||||
|
|
||||||
|
- **`display`** — receives rendered frames (ANSI) from the server and forwards input back.
|
||||||
|
- **`app`** — sends commands to the window manager (spawn windows, show popups, etc.) and receives responses.
|
||||||
|
|
||||||
|
A client can act as both simultaneously by choosing either role; `display` clients will receive `frame` messages in addition to any command responses.
|
||||||
|
|
||||||
|
## Handshake
|
||||||
|
|
||||||
|
Every connection must begin with a `hello` message. The server replies with `hello_ok`.
|
||||||
|
|
||||||
|
### Client → Server: `hello`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "hello",
|
||||||
|
"role": "display",
|
||||||
|
"width": 220,
|
||||||
|
"height": 50,
|
||||||
|
"window_id": null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Field | Type | Required | Description |
|
||||||
|
|-------------|-----------------|----------|---------------------------------------------------|
|
||||||
|
| `role` | `"display"` \| `"app"` | Yes | Client role |
|
||||||
|
| `width` | integer (u16) | Yes | Client terminal width (used for frame sizing) |
|
||||||
|
| `height` | integer (u16) | Yes | Client terminal height |
|
||||||
|
| `window_id` | integer \| null | No | The window ID from `TUI_WM_WINDOW_ID` if available |
|
||||||
|
|
||||||
|
### Server → Client: `hello_ok`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "hello_ok",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"socket_path": "/run/user/1000/tui-wm.sock"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Client → Server messages
|
||||||
|
|
||||||
|
### `input`
|
||||||
|
|
||||||
|
Forward raw terminal input bytes to the focused window. Bytes are hex-encoded.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "type": "input", "data": "1b5b41" }
|
||||||
|
```
|
||||||
|
|
||||||
|
The example sends the bytes `0x1b 0x5b 0x41` (Up arrow).
|
||||||
|
|
||||||
|
### `resize`
|
||||||
|
|
||||||
|
Notify the server that the display client's terminal has been resized.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "type": "resize", "width": 200, "height": 48 }
|
||||||
|
```
|
||||||
|
|
||||||
|
### `spawn_window`
|
||||||
|
|
||||||
|
Ask the server to open a new terminal window running the given command.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "spawn_window",
|
||||||
|
"command": "/bin/bash",
|
||||||
|
"request_id": "req-1"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The server responds with `window_opened`.
|
||||||
|
|
||||||
|
### `spawn_popup`
|
||||||
|
|
||||||
|
Show a modal popup dialog with a message and configurable buttons. The server responds with `popup_result` once the user dismisses the dialog.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "spawn_popup",
|
||||||
|
"message": "Vill du avsluta?",
|
||||||
|
"buttons": ["Ja", "Nej"],
|
||||||
|
"request_id": "req-2"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
If `buttons` is omitted it defaults to `["Ja", "Nej"]`.
|
||||||
|
|
||||||
|
### `list_windows`
|
||||||
|
|
||||||
|
Request a list of all open windows.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "type": "list_windows", "request_id": "req-3" }
|
||||||
|
```
|
||||||
|
|
||||||
|
### `close_window`
|
||||||
|
|
||||||
|
Close the window with the given ID.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "type": "close_window", "window_id": 3, "request_id": "req-4" }
|
||||||
|
```
|
||||||
|
|
||||||
|
### `set_background`
|
||||||
|
|
||||||
|
Set or remove the desktop background image (requires Kitty graphics protocol support in the host terminal).
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "type": "set_background", "path": "/path/to/image.jpg", "save": true, "request_id": "req-5" }
|
||||||
|
```
|
||||||
|
|
||||||
|
- `path` — Path to image file (JPEG/PNG/GIF/BMP/WebP), or `null` to remove the background.
|
||||||
|
- `save` — If `true`, the change is persisted to `config.toml`.
|
||||||
|
|
||||||
|
## Server → Client messages
|
||||||
|
|
||||||
|
### `hello_ok`
|
||||||
|
|
||||||
|
See handshake section above.
|
||||||
|
|
||||||
|
### `frame`
|
||||||
|
|
||||||
|
Sent to `display` clients every render cycle (~60 fps). The `data` field is a hex-encoded ANSI escape sequence string that, when written to a terminal, renders the full current state of the TUI-WM screen.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "frame",
|
||||||
|
"width": 200,
|
||||||
|
"height": 48,
|
||||||
|
"min_width": 200,
|
||||||
|
"min_height": 48,
|
||||||
|
"data": "1b5b306d1b5b324a..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
To display a frame, decode `data` from hex and write the raw bytes to stdout.
|
||||||
|
|
||||||
|
### `window_opened`
|
||||||
|
|
||||||
|
Response to `spawn_window`.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "window_opened",
|
||||||
|
"id": 5,
|
||||||
|
"request_id": "req-1"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `popup_result`
|
||||||
|
|
||||||
|
Response to `spawn_popup` — sent when the user selects a button or presses Escape.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "popup_result",
|
||||||
|
"button": "Ja",
|
||||||
|
"button_index": 0,
|
||||||
|
"request_id": "req-2"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `window_list`
|
||||||
|
|
||||||
|
Response to `list_windows`.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "window_list",
|
||||||
|
"windows": [
|
||||||
|
{ "id": 1, "x": 2, "y": 2, "width": 82, "height": 26, "title": "bash" },
|
||||||
|
{ "id": 3, "x": 6, "y": 4, "width": 82, "height": 26, "title": "nvim" }
|
||||||
|
],
|
||||||
|
"request_id": "req-3"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `error`
|
||||||
|
|
||||||
|
Sent when a request cannot be fulfilled.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "error",
|
||||||
|
"message": "Window not found",
|
||||||
|
"request_id": "req-4"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Code examples
|
||||||
|
|
||||||
|
### Bash — show a popup and read the result
|
||||||
|
|
||||||
|
```bash
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
SOCKET="${TUI_WM_SOCKET:-/tmp/tui-wm.sock}"
|
||||||
|
|
||||||
|
# Build the hello + spawn_popup messages
|
||||||
|
send_msg() {
|
||||||
|
local json="$1"
|
||||||
|
local len=${#json}
|
||||||
|
# Write 4-byte big-endian length followed by JSON
|
||||||
|
printf "$(printf '\\x%02x\\x%02x\\x%02x\\x%02x' \
|
||||||
|
$((len >> 24 & 0xff)) $((len >> 16 & 0xff)) \
|
||||||
|
$((len >> 8 & 0xff)) $((len & 0xff)))"
|
||||||
|
printf '%s' "$json"
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
send_msg '{"type":"hello","role":"app","width":80,"height":24}'
|
||||||
|
send_msg '{"type":"spawn_popup","message":"Continue?","buttons":["Yes","No"],"request_id":"r1"}'
|
||||||
|
} | nc -U "$SOCKET" | python3 -c "
|
||||||
|
import sys, struct, json
|
||||||
|
while True:
|
||||||
|
hdr = sys.stdin.buffer.read(4)
|
||||||
|
if len(hdr) < 4:
|
||||||
|
break
|
||||||
|
n = struct.unpack('>I', hdr)[0]
|
||||||
|
msg = json.loads(sys.stdin.buffer.read(n))
|
||||||
|
if msg.get('type') == 'popup_result':
|
||||||
|
print('User chose:', msg['button'])
|
||||||
|
break
|
||||||
|
"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Python — connect as display client and render frames
|
||||||
|
|
||||||
|
```python
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
import os, socket, struct, json, sys
|
||||||
|
|
||||||
|
SOCKET_PATH = os.environ.get("TUI_WM_SOCKET", "/tmp/tui-wm.sock")
|
||||||
|
|
||||||
|
def send(sock, msg: dict):
|
||||||
|
data = json.dumps(msg).encode()
|
||||||
|
sock.sendall(struct.pack(">I", len(data)) + data)
|
||||||
|
|
||||||
|
def recv(sock) -> dict:
|
||||||
|
hdr = b""
|
||||||
|
while len(hdr) < 4:
|
||||||
|
hdr += sock.recv(4 - len(hdr))
|
||||||
|
n = struct.unpack(">I", hdr)[0]
|
||||||
|
buf = b""
|
||||||
|
while len(buf) < n:
|
||||||
|
buf += sock.recv(n - len(buf))
|
||||||
|
return json.loads(buf)
|
||||||
|
|
||||||
|
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as s:
|
||||||
|
s.connect(SOCKET_PATH)
|
||||||
|
|
||||||
|
# Handshake
|
||||||
|
term_size = os.get_terminal_size()
|
||||||
|
send(s, {"type": "hello", "role": "display",
|
||||||
|
"width": term_size.columns, "height": term_size.lines})
|
||||||
|
hello_ok = recv(s)
|
||||||
|
assert hello_ok["type"] == "hello_ok"
|
||||||
|
|
||||||
|
# Render incoming frames
|
||||||
|
sys.stdout.write("\033[?1049h\033[?25l") # alternate screen, hide cursor
|
||||||
|
sys.stdout.flush()
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
msg = recv(s)
|
||||||
|
if msg["type"] == "frame":
|
||||||
|
ansi = bytes.fromhex(msg["data"])
|
||||||
|
sys.stdout.buffer.write(ansi)
|
||||||
|
sys.stdout.buffer.flush()
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
sys.stdout.write("\033[?1049l\033[?25h") # restore
|
||||||
|
sys.stdout.flush()
|
||||||
|
```
|
||||||
|
|
||||||
|
### Python — spawn a window and list windows
|
||||||
|
|
||||||
|
```python
|
||||||
|
import os, socket, struct, json
|
||||||
|
|
||||||
|
SOCKET_PATH = os.environ.get("TUI_WM_SOCKET", "/tmp/tui-wm.sock")
|
||||||
|
|
||||||
|
def send(sock, msg):
|
||||||
|
data = json.dumps(msg).encode()
|
||||||
|
sock.sendall(struct.pack(">I", len(data)) + data)
|
||||||
|
|
||||||
|
def recv(sock):
|
||||||
|
hdr = b""
|
||||||
|
while len(hdr) < 4:
|
||||||
|
hdr += sock.recv(4 - len(hdr))
|
||||||
|
n = struct.unpack(">I", hdr)[0]
|
||||||
|
buf = b""
|
||||||
|
while len(buf) < n:
|
||||||
|
buf += sock.recv(n - len(buf))
|
||||||
|
return json.loads(buf)
|
||||||
|
|
||||||
|
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as s:
|
||||||
|
s.connect(SOCKET_PATH)
|
||||||
|
send(s, {"type": "hello", "role": "app", "width": 80, "height": 24})
|
||||||
|
recv(s) # hello_ok
|
||||||
|
|
||||||
|
# Spawn a window
|
||||||
|
send(s, {"type": "spawn_window", "command": "/bin/bash", "request_id": "a"})
|
||||||
|
opened = recv(s)
|
||||||
|
print("Opened window id:", opened["id"])
|
||||||
|
|
||||||
|
# List all windows
|
||||||
|
send(s, {"type": "list_windows", "request_id": "b"})
|
||||||
|
wlist = recv(s)
|
||||||
|
for w in wlist["windows"]:
|
||||||
|
print(f" Window {w['id']}: {w['title']} at ({w['x']},{w['y']}) {w['width']}x{w['height']}")
|
||||||
|
```
|
||||||
|
|
||||||
|
## Operating modes
|
||||||
|
|
||||||
|
TUI-WM can be started in three modes:
|
||||||
|
|
||||||
|
### Standalone (default)
|
||||||
|
|
||||||
|
```
|
||||||
|
tui-wm
|
||||||
|
```
|
||||||
|
|
||||||
|
Runs the full TUI window manager in the current terminal and simultaneously starts a Unix socket server. Other applications can connect to the socket while TUI-WM is running.
|
||||||
|
|
||||||
|
### Daemon (`-d`)
|
||||||
|
|
||||||
|
```
|
||||||
|
tui-wm -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Runs headless — no terminal rendering. State is managed entirely via the socket. Frames are rendered to an internal buffer and sent to any connected `display` clients. Ideal for running TUI-WM as a background service.
|
||||||
|
|
||||||
|
### Client (`-c`)
|
||||||
|
|
||||||
|
```
|
||||||
|
tui-wm -c [socket-path]
|
||||||
|
```
|
||||||
|
|
||||||
|
Connects to an existing TUI-WM server (standalone or daemon) as a display client. The client renders received frames and forwards keyboard/resize events to the server. If `socket-path` is omitted, the default path is used. Disconnect with **Ctrl+Shift+Q**.
|
||||||
11
config.toml
11
config.toml
@@ -1,5 +1,16 @@
|
|||||||
# TUI-WM konfiguration
|
# TUI-WM konfiguration
|
||||||
|
|
||||||
|
# Bakgrundsbild (Kitty graphics protocol — kräver Kitty/Ghostty/WezTerm)
|
||||||
|
background_image = "/home/brasse/Bilder/backrunder/pexels-veeterzy-38136.jpg"
|
||||||
|
|
||||||
|
# Bakgrundsfärg för virtuella terminaler.
|
||||||
|
# Om inte satt → genomskinlig (bakgrundsbild syns igenom).
|
||||||
|
# Stödjer: "#RRGGBB", "0"-"255" (indexed), färgnamn (black, red, etc.)
|
||||||
|
# terminal_bg_color = "#1a1a2e"
|
||||||
|
|
||||||
|
# Standard-shell för nya terminaler. Om inte satt → $SHELL eller /bin/bash.
|
||||||
|
# default_shell = "/bin/fish"
|
||||||
|
|
||||||
[[panel]]
|
[[panel]]
|
||||||
position = "top"
|
position = "top"
|
||||||
file = "panels/topbar.toml"
|
file = "panels/topbar.toml"
|
||||||
|
|||||||
704
src/app.rs
704
src/app.rs
@@ -79,6 +79,22 @@ pub struct StatusWidgetState {
|
|||||||
pub last_run: Option<Instant>, // None = aldrig kört, kör direkt
|
pub last_run: Option<Instant>, // None = aldrig kört, kör direkt
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── App IPC output ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
pub enum AppIpcOut {
|
||||||
|
PopupResult {
|
||||||
|
client_id: usize,
|
||||||
|
request_id: Option<String>,
|
||||||
|
button: String,
|
||||||
|
button_index: usize,
|
||||||
|
},
|
||||||
|
WindowOpened {
|
||||||
|
client_id: usize,
|
||||||
|
request_id: Option<String>,
|
||||||
|
window_id: usize,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
// ─── App ──────────────────────────────────────────────────────────────────────
|
// ─── App ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
pub struct App {
|
pub struct App {
|
||||||
@@ -112,6 +128,10 @@ pub struct App {
|
|||||||
// TUI-WM logo-knapp rects (en per panel), för klick och hover
|
// TUI-WM logo-knapp rects (en per panel), för klick och hover
|
||||||
pub tui_wm_btn_rects: Vec<Rect>,
|
pub tui_wm_btn_rects: Vec<Rect>,
|
||||||
pub hovered_tui_btn: bool,
|
pub hovered_tui_btn: bool,
|
||||||
|
|
||||||
|
// IPC
|
||||||
|
pub socket_path: Option<String>,
|
||||||
|
pub ipc_out: Vec<AppIpcOut>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct DropdownState {
|
pub struct DropdownState {
|
||||||
@@ -142,6 +162,93 @@ pub struct FloatingWindow {
|
|||||||
pub mouse_encoding: vt100::MouseProtocolEncoding,
|
pub mouse_encoding: vt100::MouseProtocolEncoding,
|
||||||
/// Delvis mottagen escape-sekvens från PTY (för sekvenser som klippts tvärs genom en chunk-gräns).
|
/// Delvis mottagen escape-sekvens från PTY (för sekvenser som klippts tvärs genom en chunk-gräns).
|
||||||
mouse_seq_carry: Vec<u8>,
|
mouse_seq_carry: Vec<u8>,
|
||||||
|
/// Aktiv text-markering (selection) i terminalen
|
||||||
|
pub selection: Option<Selection>,
|
||||||
|
/// Carry-buffer för ofullständiga APC (Kitty graphics) sekvenser
|
||||||
|
pub kitty_gfx_carry: Vec<u8>,
|
||||||
|
/// Köade Kitty graphics-kommandon att vidarebefordra till värdterminalen
|
||||||
|
pub pending_graphics: Vec<PendingGraphic>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// En Kitty graphics-sekvens extraherad från PTY-data, redo att vidarebefordras.
|
||||||
|
pub struct PendingGraphic {
|
||||||
|
/// Rå APC-sekvens (ESC _ G ... ESC \)
|
||||||
|
pub raw: Vec<u8>,
|
||||||
|
/// Virtuell terminal-markörposition vid tidpunkten för kommandot
|
||||||
|
pub cursor_row: u16,
|
||||||
|
pub cursor_col: u16,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Text-markering i en terminal: start- och slut-cell (i terminalkoordinater, 0-baserat).
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct Selection {
|
||||||
|
pub start_row: u16,
|
||||||
|
pub start_col: u16,
|
||||||
|
pub end_row: u16,
|
||||||
|
pub end_col: u16,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Selection {
|
||||||
|
/// Returnera normaliserad (start <= end)
|
||||||
|
fn normalized(&self) -> (u16, u16, u16, u16) {
|
||||||
|
if self.start_row < self.end_row
|
||||||
|
|| (self.start_row == self.end_row && self.start_col <= self.end_col)
|
||||||
|
{
|
||||||
|
(self.start_row, self.start_col, self.end_row, self.end_col)
|
||||||
|
} else {
|
||||||
|
(self.end_row, self.end_col, self.start_row, self.start_col)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn contains(&self, row: u16, col: u16) -> bool {
|
||||||
|
let (sr, sc, er, ec) = self.normalized();
|
||||||
|
if row < sr || row > er {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if sr == er {
|
||||||
|
return col >= sc && col <= ec;
|
||||||
|
}
|
||||||
|
if row == sr {
|
||||||
|
return col >= sc;
|
||||||
|
}
|
||||||
|
if row == er {
|
||||||
|
return col <= ec;
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extrahera markerad text från en vt100-screen
|
||||||
|
pub fn extract_text(&self, screen: &vt100::Screen) -> String {
|
||||||
|
let (sr, sc, er, ec) = self.normalized();
|
||||||
|
let (_screen_rows, screen_cols) = screen.size();
|
||||||
|
let mut result = String::new();
|
||||||
|
for row in sr..=er {
|
||||||
|
let col_start = if row == sr { sc } else { 0 };
|
||||||
|
let col_end = if row == er { ec } else { screen_cols.saturating_sub(1) };
|
||||||
|
for col in col_start..=col_end {
|
||||||
|
if let Some(cell) = screen.cell(row, col) {
|
||||||
|
let s = cell.contents();
|
||||||
|
if s.is_empty() {
|
||||||
|
result.push(' ');
|
||||||
|
} else {
|
||||||
|
result.push_str(&s);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
result.push(' ');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if row != er {
|
||||||
|
// Trim trailing spaces on each line
|
||||||
|
let trimmed = result.trim_end_matches(' ');
|
||||||
|
result.truncate(trimmed.len());
|
||||||
|
result.push('\n');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Trim trailing spaces on last line
|
||||||
|
let trimmed = result.trim_end_matches(' ');
|
||||||
|
result.truncate(trimmed.len());
|
||||||
|
result
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub enum WindowContent {
|
pub enum WindowContent {
|
||||||
@@ -156,6 +263,13 @@ pub enum WindowContent {
|
|||||||
input: String,
|
input: String,
|
||||||
cursor_pos: usize,
|
cursor_pos: usize,
|
||||||
},
|
},
|
||||||
|
PopupDialog {
|
||||||
|
message: String,
|
||||||
|
buttons: Vec<String>,
|
||||||
|
selected: usize,
|
||||||
|
client_id: usize,
|
||||||
|
request_id: Option<String>,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
enum RunDialogResult {
|
enum RunDialogResult {
|
||||||
@@ -339,6 +453,8 @@ impl App {
|
|||||||
parsed_keybinds,
|
parsed_keybinds,
|
||||||
tui_wm_btn_rects: Vec::new(),
|
tui_wm_btn_rects: Vec::new(),
|
||||||
hovered_tui_btn: false,
|
hovered_tui_btn: false,
|
||||||
|
socket_path: None,
|
||||||
|
ipc_out: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -413,7 +529,7 @@ impl App {
|
|||||||
pub fn tick(&mut self) {
|
pub fn tick(&mut self) {
|
||||||
// ── PTY-data → vt100-parser + mus-tracking-skanning ──────────────────────────────────────
|
// ── PTY-data → vt100-parser + mus-tracking-skanning ──────────────────────────────────────
|
||||||
for window in &mut self.windows {
|
for window in &mut self.windows {
|
||||||
let WindowContent::Terminal { rx, parser, alive, .. } = &mut window.content else { continue };
|
let WindowContent::Terminal { rx, parser, pty, alive, title, .. } = &mut window.content else { continue };
|
||||||
if !*alive {
|
if !*alive {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -421,14 +537,27 @@ impl App {
|
|||||||
match rx.try_recv() {
|
match rx.try_recv() {
|
||||||
Ok(data) => {
|
Ok(data) => {
|
||||||
// Scanna för mus-escape-sekvenser FÖRE vi ger data till vt100-parsern.
|
// Scanna för mus-escape-sekvenser FÖRE vi ger data till vt100-parsern.
|
||||||
// På så sätt överlever mus-läget parser-återskapning vid resize.
|
|
||||||
scan_mouse_tracking(
|
scan_mouse_tracking(
|
||||||
&data,
|
&data,
|
||||||
&mut window.mouse_mode,
|
&mut window.mouse_mode,
|
||||||
&mut window.mouse_encoding,
|
&mut window.mouse_encoding,
|
||||||
&mut window.mouse_seq_carry,
|
&mut window.mouse_seq_carry,
|
||||||
);
|
);
|
||||||
parser.process(&data);
|
// Scanna för terminal-queries (DA1, CPR) och skicka svar
|
||||||
|
let responses = scan_terminal_queries(&data, parser);
|
||||||
|
for resp in responses {
|
||||||
|
let _ = pty.write_input(&resp);
|
||||||
|
}
|
||||||
|
// Scanna för OSC-sekvenser (clipboard, etc.)
|
||||||
|
scan_osc_sequences(&data);
|
||||||
|
// Extrahera Kitty graphics-sekvenser och bearbeta resten genom parsern
|
||||||
|
let gfx = process_pty_with_kitty_gfx(
|
||||||
|
&data,
|
||||||
|
parser,
|
||||||
|
pty,
|
||||||
|
&mut window.kitty_gfx_carry,
|
||||||
|
);
|
||||||
|
window.pending_graphics.extend(gfx);
|
||||||
}
|
}
|
||||||
Err(mpsc::TryRecvError::Empty) => break,
|
Err(mpsc::TryRecvError::Empty) => break,
|
||||||
Err(mpsc::TryRecvError::Disconnected) => {
|
Err(mpsc::TryRecvError::Disconnected) => {
|
||||||
@@ -437,6 +566,11 @@ impl App {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Uppdatera fönsterrubrik från OSC 0/2 om programmet satte en
|
||||||
|
let osc_title = parser.screen().title();
|
||||||
|
if !osc_title.is_empty() {
|
||||||
|
*title = osc_title.to_string();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── PTY resize om fönsterstorlek ändrats ─────────────────────────────
|
// ── PTY resize om fönsterstorlek ändrats ─────────────────────────────
|
||||||
@@ -461,6 +595,7 @@ impl App {
|
|||||||
self.windows.retain(|w| match &w.content {
|
self.windows.retain(|w| match &w.content {
|
||||||
WindowContent::Terminal { alive, .. } => *alive,
|
WindowContent::Terminal { alive, .. } => *alive,
|
||||||
WindowContent::RunDialog { .. } => true,
|
WindowContent::RunDialog { .. } => true,
|
||||||
|
WindowContent::PopupDialog { .. } => true,
|
||||||
});
|
});
|
||||||
if let Some(fid) = prev_focused {
|
if let Some(fid) = prev_focused {
|
||||||
if !self.windows.iter().any(|w| w.id == fid) {
|
if !self.windows.iter().any(|w| w.id == fid) {
|
||||||
@@ -492,13 +627,52 @@ impl App {
|
|||||||
pub fn handle_event(&mut self, event: Event) {
|
pub fn handle_event(&mut self, event: Event) {
|
||||||
match event {
|
match event {
|
||||||
Event::Key(key) => self.handle_key(key),
|
Event::Key(key) => self.handle_key(key),
|
||||||
Event::Mouse(m) => self.handle_mouse(m.column, m.row, m.kind),
|
Event::Mouse(m) => self.handle_mouse(m.column, m.row, m.kind, m.modifiers),
|
||||||
|
Event::Paste(text) => self.handle_paste(&text),
|
||||||
|
Event::FocusGained | Event::FocusLost => {
|
||||||
|
// Vidarebefordra fokus-events till alla terminaler som begärt det
|
||||||
|
// (via DEC mode 1004)
|
||||||
|
}
|
||||||
Event::Resize(_, _) => {}
|
Event::Resize(_, _) => {}
|
||||||
_ => {}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_paste(&mut self, text: &str) {
|
||||||
|
let Some(id) = self.focused_id else { return };
|
||||||
|
if let Some(w) = self.windows.iter_mut().find(|w| w.id == id) {
|
||||||
|
match &mut w.content {
|
||||||
|
WindowContent::Terminal { pty, parser, alive, .. } => {
|
||||||
|
if !*alive { return; }
|
||||||
|
let bp = parser.screen().bracketed_paste();
|
||||||
|
let mut data = Vec::new();
|
||||||
|
if bp {
|
||||||
|
data.extend_from_slice(b"\x1b[200~");
|
||||||
|
}
|
||||||
|
data.extend_from_slice(text.as_bytes());
|
||||||
|
if bp {
|
||||||
|
data.extend_from_slice(b"\x1b[201~");
|
||||||
|
}
|
||||||
|
let _ = pty.write_input(&data);
|
||||||
|
}
|
||||||
|
WindowContent::RunDialog { input, cursor_pos } => {
|
||||||
|
// Klistra in text i köra-dialogen
|
||||||
|
input.insert_str(*cursor_pos, text);
|
||||||
|
*cursor_pos += text.len();
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn handle_key(&mut self, key: KeyEvent) {
|
fn handle_key(&mut self, key: KeyEvent) {
|
||||||
|
// 0. Ctrl+Shift+C → kopiera markerad text
|
||||||
|
if key.modifiers.contains(KeyModifiers::CONTROL | KeyModifiers::SHIFT)
|
||||||
|
&& key.code == KeyCode::Char('C')
|
||||||
|
{
|
||||||
|
self.copy_selection();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// 1. Globala keybinds – alltid, oavsett fokus
|
// 1. Globala keybinds – alltid, oavsett fokus
|
||||||
if let Some(action) = self.match_keybind(key, KeybindScope::Global) {
|
if let Some(action) = self.match_keybind(key, KeybindScope::Global) {
|
||||||
self.execute_action(action);
|
self.execute_action(action);
|
||||||
@@ -516,7 +690,16 @@ impl App {
|
|||||||
RunDialogResult::Execute(cmd) => {
|
RunDialogResult::Execute(cmd) => {
|
||||||
self.close_window(id);
|
self.close_window(id);
|
||||||
if !cmd.is_empty() {
|
if !cmd.is_empty() {
|
||||||
self.spawn_terminal(&cmd);
|
let shell = self.config.default_shell.clone()
|
||||||
|
.unwrap_or_else(default_shell);
|
||||||
|
let win_id = self.spawn_terminal(&shell);
|
||||||
|
// Skriv kommandot + Enter till den nya terminalens PTY
|
||||||
|
if let Some(w) = self.windows.iter_mut().find(|w| w.id == win_id) {
|
||||||
|
if let WindowContent::Terminal { pty, .. } = &mut w.content {
|
||||||
|
let input = format!("{}\n", cmd);
|
||||||
|
let _ = pty.write_input(input.as_bytes());
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
RunDialogResult::None => {}
|
RunDialogResult::None => {}
|
||||||
@@ -525,11 +708,35 @@ impl App {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 2b. PopupDialog hanterar sina egna tangenter
|
||||||
|
if let Some(id) = self.focused_id {
|
||||||
|
let is_popup = self
|
||||||
|
.windows
|
||||||
|
.iter()
|
||||||
|
.any(|w| w.id == id && matches!(w.content, WindowContent::PopupDialog { .. }));
|
||||||
|
if is_popup {
|
||||||
|
if let Some((button, button_index, client_id, request_id)) =
|
||||||
|
self.handle_popup_key(id, key)
|
||||||
|
{
|
||||||
|
self.close_window(id);
|
||||||
|
self.ipc_out.push(AppIpcOut::PopupResult {
|
||||||
|
client_id,
|
||||||
|
request_id,
|
||||||
|
button,
|
||||||
|
button_index,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 3. Vidarebefordra till fokuserat terminalfönster
|
// 3. Vidarebefordra till fokuserat terminalfönster
|
||||||
if let Some(id) = self.focused_id {
|
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 Some(w) = self.windows.iter_mut().find(|w| w.id == id) {
|
if let WindowContent::Terminal { pty, parser, .. } = &mut w.content {
|
||||||
if let WindowContent::Terminal { pty, .. } = &mut w.content {
|
let app_cursor = parser.screen().application_cursor();
|
||||||
|
let app_keypad = parser.screen().application_keypad();
|
||||||
|
if let Some(bytes) = key_to_bytes(key, app_cursor, app_keypad) {
|
||||||
let _ = pty.write_input(&bytes);
|
let _ = pty.write_input(&bytes);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
@@ -626,11 +833,111 @@ impl App {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn handle_mouse(&mut self, col: u16, row: u16, kind: MouseEventKind) {
|
fn handle_popup_key(
|
||||||
|
&mut self,
|
||||||
|
id: usize,
|
||||||
|
key: KeyEvent,
|
||||||
|
) -> Option<(String, usize, usize, Option<String>)> {
|
||||||
|
let window = self.windows.iter_mut().find(|w| w.id == id)?;
|
||||||
|
let WindowContent::PopupDialog { buttons, selected, client_id, request_id, .. } =
|
||||||
|
&mut window.content
|
||||||
|
else {
|
||||||
|
return None;
|
||||||
|
};
|
||||||
|
match key.code {
|
||||||
|
KeyCode::Left | KeyCode::Tab => {
|
||||||
|
if *selected > 0 {
|
||||||
|
*selected -= 1;
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
KeyCode::Right => {
|
||||||
|
if *selected + 1 < buttons.len() {
|
||||||
|
*selected += 1;
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
KeyCode::Enter => {
|
||||||
|
let btn = buttons[*selected].clone();
|
||||||
|
let idx = *selected;
|
||||||
|
let cid = *client_id;
|
||||||
|
let rid = request_id.clone();
|
||||||
|
Some((btn, idx, cid, rid))
|
||||||
|
}
|
||||||
|
KeyCode::Esc => {
|
||||||
|
let idx = buttons.len().saturating_sub(1);
|
||||||
|
let btn = buttons.get(idx).cloned().unwrap_or_default();
|
||||||
|
let cid = *client_id;
|
||||||
|
let rid = request_id.clone();
|
||||||
|
Some((btn, idx, cid, rid))
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_mouse(&mut self, col: u16, row: u16, kind: MouseEventKind, modifiers: KeyModifiers) {
|
||||||
// Logga alla mushändelser utom Moved (för mycker brus)
|
// Logga alla mushändelser utom Moved (för mycker brus)
|
||||||
if !matches!(kind, MouseEventKind::Moved) {
|
if !matches!(kind, MouseEventKind::Moved) {
|
||||||
crate::log::log(&format!("MOUSE {:?} col={} row={}", kind, col, row));
|
crate::log::log(&format!("MOUSE {:?} col={} row={} mods={:?}", kind, col, row, modifiers));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Shift+mus → textmarkering i terminal ─────────────────────────────
|
||||||
|
if modifiers.contains(KeyModifiers::SHIFT) {
|
||||||
|
match kind {
|
||||||
|
MouseEventKind::Down(MouseButton::Left) => {
|
||||||
|
// Hitta terminal under muspekaren
|
||||||
|
let hit = self.windows.iter().rev()
|
||||||
|
.find(|w| w.in_content(col, row))
|
||||||
|
.and_then(|w| {
|
||||||
|
if matches!(&w.content, WindowContent::Terminal { alive, .. } if *alive) {
|
||||||
|
Some((w.id, w.content_rect()))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if let Some((id, cr)) = hit {
|
||||||
|
self.focus_window(id);
|
||||||
|
let term_row = row.saturating_sub(cr.y);
|
||||||
|
let term_col = col.saturating_sub(cr.x);
|
||||||
|
if let Some(w) = self.windows.iter_mut().find(|w| w.id == id) {
|
||||||
|
w.selection = Some(Selection {
|
||||||
|
start_row: term_row,
|
||||||
|
start_col: term_col,
|
||||||
|
end_row: term_row,
|
||||||
|
end_col: term_col,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
MouseEventKind::Drag(MouseButton::Left) => {
|
||||||
|
// Utöka markering
|
||||||
|
if let Some(id) = self.focused_id {
|
||||||
|
let cr = self.windows.iter().find(|w| w.id == id).map(|w| w.content_rect());
|
||||||
|
if let Some(cr) = cr {
|
||||||
|
let term_row = row.saturating_sub(cr.y);
|
||||||
|
let term_col = col.saturating_sub(cr.x);
|
||||||
|
if let Some(w) = self.windows.iter_mut().find(|w| w.id == id) {
|
||||||
|
if let Some(sel) = &mut w.selection {
|
||||||
|
sel.end_row = term_row;
|
||||||
|
sel.end_col = term_col;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vanligt klick (utan Shift) rensar markering i alla fönster
|
||||||
|
if matches!(kind, MouseEventKind::Down(MouseButton::Left)) {
|
||||||
|
for w in &mut self.windows {
|
||||||
|
w.selection = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
match kind {
|
match kind {
|
||||||
MouseEventKind::Moved => {
|
MouseEventKind::Moved => {
|
||||||
self.update_hover(col, row);
|
self.update_hover(col, row);
|
||||||
@@ -945,11 +1252,13 @@ impl App {
|
|||||||
match action {
|
match action {
|
||||||
MenuAction::Exit => self.should_quit = true,
|
MenuAction::Exit => self.should_quit = true,
|
||||||
MenuAction::SpawnTerminal { shell } => {
|
MenuAction::SpawnTerminal { shell } => {
|
||||||
let shell = shell.unwrap_or_else(default_shell);
|
let shell = shell
|
||||||
|
.or_else(|| self.config.default_shell.clone())
|
||||||
|
.unwrap_or_else(default_shell);
|
||||||
self.spawn_terminal(&shell);
|
self.spawn_terminal(&shell);
|
||||||
}
|
}
|
||||||
MenuAction::RunScript { path } => self.spawn_terminal(&path),
|
MenuAction::RunScript { path } => { self.spawn_terminal(&path); }
|
||||||
MenuAction::RunProgram { command, .. } => self.spawn_terminal(&command),
|
MenuAction::RunProgram { command, .. } => { self.spawn_terminal(&command); }
|
||||||
MenuAction::SpawnRunDialog => self.spawn_run_dialog(),
|
MenuAction::SpawnRunDialog => self.spawn_run_dialog(),
|
||||||
MenuAction::Submenu { .. } => {}
|
MenuAction::Submenu { .. } => {}
|
||||||
MenuAction::NoOp => {}
|
MenuAction::NoOp => {}
|
||||||
@@ -978,10 +1287,57 @@ impl App {
|
|||||||
mouse_mode: vt100::MouseProtocolMode::None,
|
mouse_mode: vt100::MouseProtocolMode::None,
|
||||||
mouse_encoding: vt100::MouseProtocolEncoding::Default,
|
mouse_encoding: vt100::MouseProtocolEncoding::Default,
|
||||||
mouse_seq_carry: Vec::new(),
|
mouse_seq_carry: Vec::new(),
|
||||||
|
selection: None,
|
||||||
|
kitty_gfx_carry: Vec::new(),
|
||||||
|
pending_graphics: Vec::new(),
|
||||||
});
|
});
|
||||||
self.focus_window(id);
|
self.focus_window(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn spawn_popup_dialog(
|
||||||
|
&mut self,
|
||||||
|
message: String,
|
||||||
|
buttons: Vec<String>,
|
||||||
|
client_id: usize,
|
||||||
|
request_id: Option<String>,
|
||||||
|
) {
|
||||||
|
let ca = self.content_area;
|
||||||
|
let w = 52u16.min(ca.width.saturating_sub(4));
|
||||||
|
let h = 7u16;
|
||||||
|
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::PopupDialog {
|
||||||
|
message,
|
||||||
|
buttons,
|
||||||
|
selected: 0,
|
||||||
|
client_id,
|
||||||
|
request_id,
|
||||||
|
},
|
||||||
|
dragging: None,
|
||||||
|
resizing: None,
|
||||||
|
resizable: false,
|
||||||
|
mouse_mode: vt100::MouseProtocolMode::None,
|
||||||
|
mouse_encoding: vt100::MouseProtocolEncoding::Default,
|
||||||
|
mouse_seq_carry: Vec::new(),
|
||||||
|
selection: None,
|
||||||
|
kitty_gfx_carry: Vec::new(),
|
||||||
|
pending_graphics: Vec::new(),
|
||||||
|
});
|
||||||
|
self.focus_window(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn close_window_pub(&mut self, id: usize) {
|
||||||
|
self.close_window(id);
|
||||||
|
}
|
||||||
|
|
||||||
fn open_dropdown(&mut self, panel_idx: usize, item_idx: usize, items: Vec<MenuItem>) {
|
fn open_dropdown(&mut self, panel_idx: usize, item_idx: usize, items: Vec<MenuItem>) {
|
||||||
let pr = match self.panel_rects.get(panel_idx) {
|
let pr = match self.panel_rects.get(panel_idx) {
|
||||||
Some(r) => *r,
|
Some(r) => *r,
|
||||||
@@ -1051,7 +1407,32 @@ impl App {
|
|||||||
}).collect()
|
}).collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn spawn_terminal(&mut self, shell: &str) {
|
/// Kopierar markerad text från fokuserat fönster till urklipp via OSC 52.
|
||||||
|
fn copy_selection(&mut self) {
|
||||||
|
let Some(id) = self.focused_id else { return };
|
||||||
|
let w = match self.windows.iter().find(|w| w.id == id) {
|
||||||
|
Some(w) => w,
|
||||||
|
None => return,
|
||||||
|
};
|
||||||
|
let selection = match &w.selection {
|
||||||
|
Some(s) => s,
|
||||||
|
None => return,
|
||||||
|
};
|
||||||
|
let WindowContent::Terminal { parser, .. } = &w.content else { return };
|
||||||
|
let text = selection.extract_text(parser.screen());
|
||||||
|
if text.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
crate::log::log(&format!("COPY selection: {} chars", text.len()));
|
||||||
|
// Skicka via OSC 52 till värdterminalen
|
||||||
|
use std::io::Write;
|
||||||
|
let b64 = base64_encode(text.as_bytes());
|
||||||
|
let osc = format!("\x1b]52;c;{}\x07", b64);
|
||||||
|
let _ = std::io::stdout().write_all(osc.as_bytes());
|
||||||
|
let _ = std::io::stdout().flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn spawn_terminal(&mut self, shell: &str) -> usize {
|
||||||
let ca = self.content_area;
|
let ca = self.content_area;
|
||||||
let offset = (self.windows.len() as i32) * 2;
|
let offset = (self.windows.len() as i32) * 2;
|
||||||
let w = 82u16.min(ca.width.saturating_sub(4));
|
let w = 82u16.min(ca.width.saturating_sub(4));
|
||||||
@@ -1061,10 +1442,16 @@ impl App {
|
|||||||
let rows = h.saturating_sub(2).max(1);
|
let rows = h.saturating_sub(2).max(1);
|
||||||
let cols = w.saturating_sub(2).max(1);
|
let cols = w.saturating_sub(2).max(1);
|
||||||
|
|
||||||
match PtyTerminal::spawn(shell, rows, cols) {
|
let id = self.next_id;
|
||||||
|
let mut env_vars: Vec<(String, String)> = Vec::new();
|
||||||
|
if let Some(sp) = &self.socket_path {
|
||||||
|
env_vars.push((crate::ipc::SOCKET_ENV.to_string(), sp.clone()));
|
||||||
|
env_vars.push((crate::ipc::WINDOW_ID_ENV.to_string(), id.to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
match PtyTerminal::spawn(shell, rows, cols, &env_vars) {
|
||||||
Ok((pty, rx)) => {
|
Ok((pty, rx)) => {
|
||||||
let parser = vt100::Parser::new(rows, cols, 0);
|
let parser = vt100::Parser::new(rows, cols, 0);
|
||||||
let id = self.next_id;
|
|
||||||
self.next_id += 1;
|
self.next_id += 1;
|
||||||
let title = shell.split('/').last().unwrap_or(shell).to_string();
|
let title = shell.split('/').last().unwrap_or(shell).to_string();
|
||||||
self.windows.push(FloatingWindow {
|
self.windows.push(FloatingWindow {
|
||||||
@@ -1080,11 +1467,15 @@ impl App {
|
|||||||
mouse_mode: vt100::MouseProtocolMode::None,
|
mouse_mode: vt100::MouseProtocolMode::None,
|
||||||
mouse_encoding: vt100::MouseProtocolEncoding::Default,
|
mouse_encoding: vt100::MouseProtocolEncoding::Default,
|
||||||
mouse_seq_carry: Vec::new(),
|
mouse_seq_carry: Vec::new(),
|
||||||
|
selection: None,
|
||||||
|
kitty_gfx_carry: Vec::new(),
|
||||||
|
pending_graphics: Vec::new(),
|
||||||
});
|
});
|
||||||
self.focus_window(id);
|
self.focus_window(id);
|
||||||
}
|
}
|
||||||
Err(e) => eprintln!("Kunde inte starta terminal: {}", e),
|
Err(e) => eprintln!("Kunde inte starta terminal: {}", e),
|
||||||
}
|
}
|
||||||
|
id
|
||||||
}
|
}
|
||||||
|
|
||||||
fn close_window(&mut self, id: usize) {
|
fn close_window(&mut self, id: usize) {
|
||||||
@@ -1193,6 +1584,105 @@ fn encode_mouse_event(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Skannar rå PTY-data efter terminal-queries och returnerar svar som ska
|
||||||
|
/// skrivas tillbaka till PTY:n.
|
||||||
|
/// Hanterar:
|
||||||
|
/// DA1: \e[c eller \e[0c → svar \e[?62;22c (VT220 med ANSI color)
|
||||||
|
/// CPR: \e[6n → svar \e[{row};{col}R
|
||||||
|
/// XTVERSION: \e[>0q → svar \eP>|TUI-WM 0.1\e\\
|
||||||
|
fn scan_terminal_queries(data: &[u8], parser: &vt100::Parser) -> Vec<Vec<u8>> {
|
||||||
|
let mut responses = Vec::new();
|
||||||
|
let mut i = 0;
|
||||||
|
while i < data.len() {
|
||||||
|
if data[i] != 0x1b {
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// ESC [
|
||||||
|
if i + 1 < data.len() && data[i + 1] == b'[' {
|
||||||
|
let csi_start = i + 2;
|
||||||
|
let mut j = csi_start;
|
||||||
|
// Samla parametrar (siffror + ;)
|
||||||
|
while j < data.len() && (data[j].is_ascii_digit() || data[j] == b';') {
|
||||||
|
j += 1;
|
||||||
|
}
|
||||||
|
if j >= data.len() {
|
||||||
|
break; // ofullständig
|
||||||
|
}
|
||||||
|
let final_byte = data[j];
|
||||||
|
let params = &data[csi_start..j];
|
||||||
|
|
||||||
|
match final_byte {
|
||||||
|
b'c' => {
|
||||||
|
// DA1: ESC[c eller ESC[0c
|
||||||
|
if params.is_empty() || params == b"0" {
|
||||||
|
// Svara som VT220 med ANSI color, mouse, truecolor
|
||||||
|
responses.push(b"\x1b[?62;22c".to_vec());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
b'n' => {
|
||||||
|
// CPR: ESC[6n → cursor position report
|
||||||
|
if params == b"6" {
|
||||||
|
let (row, col) = parser.screen().cursor_position();
|
||||||
|
responses.push(format!("\x1b[{};{}R", row + 1, col + 1).into_bytes());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
i = j + 1;
|
||||||
|
} else {
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
responses
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Skannar rå PTY-data efter OSC-sekvenser.
|
||||||
|
/// Hanterar:
|
||||||
|
/// OSC 52 (clipboard copy) → skickar vidare till värdterminalen via stdout
|
||||||
|
/// OSC 11 (query bg color) → svarar med standardfärg
|
||||||
|
fn scan_osc_sequences(data: &[u8]) {
|
||||||
|
let mut i = 0;
|
||||||
|
while i < data.len() {
|
||||||
|
// OSC startar med ESC ] eller 0x9d
|
||||||
|
if data[i] == 0x1b && i + 1 < data.len() && data[i + 1] == b']' {
|
||||||
|
let osc_start = i + 2;
|
||||||
|
// Hitta terminator: BEL (\x07) eller ST (ESC \)
|
||||||
|
let mut j = osc_start;
|
||||||
|
let mut end = None;
|
||||||
|
while j < data.len() {
|
||||||
|
if data[j] == 0x07 {
|
||||||
|
end = Some(j);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if data[j] == 0x1b && j + 1 < data.len() && data[j + 1] == b'\\' {
|
||||||
|
end = Some(j);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
j += 1;
|
||||||
|
}
|
||||||
|
if let Some(term_pos) = end {
|
||||||
|
if let Ok(payload) = std::str::from_utf8(&data[osc_start..term_pos]) {
|
||||||
|
// OSC 52;c;<base64-data> → clipboard copy
|
||||||
|
if payload.starts_with("52;") {
|
||||||
|
// Vidarebefordra till värdterminalen
|
||||||
|
let osc_end = if data[term_pos] == 0x07 { term_pos + 1 } else { term_pos + 2 };
|
||||||
|
let raw = &data[i..osc_end];
|
||||||
|
// Skriv direkt till stdout (värdterminalen)
|
||||||
|
let _ = std::io::Write::write_all(&mut std::io::stdout(), raw);
|
||||||
|
let _ = std::io::Write::flush(&mut std::io::stdout());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
i = if data[term_pos] == 0x07 { term_pos + 1 } else { term_pos + 2 };
|
||||||
|
} else {
|
||||||
|
break; // ofullständig OSC
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Skannar ett chunk av r\u00e5 PTY-data efter DEC private mode escape-sekvenser som
|
/// Skannar ett chunk av r\u00e5 PTY-data efter DEC private mode escape-sekvenser som
|
||||||
/// styr mus-tracking (\x1b[?<n>h / \x1b[?<n>l) och uppdaterar `mode` och `encoding`.
|
/// styr mus-tracking (\x1b[?<n>h / \x1b[?<n>l) och uppdaterar `mode` och `encoding`.
|
||||||
/// En liten "carry"-buffer anv\u00e4nds f\u00f6r att hantera sekvenser som klippts mitt i
|
/// En liten "carry"-buffer anv\u00e4nds f\u00f6r att hantera sekvenser som klippts mitt i
|
||||||
@@ -1344,7 +1834,7 @@ fn action_display(action: &MenuAction) -> &'static str {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn key_to_bytes(key: KeyEvent) -> Option<Vec<u8>> {
|
fn key_to_bytes(key: KeyEvent, app_cursor: bool, _app_keypad: bool) -> Option<Vec<u8>> {
|
||||||
use KeyCode::*;
|
use KeyCode::*;
|
||||||
let bytes: Vec<u8> = match key.code {
|
let bytes: Vec<u8> = match key.code {
|
||||||
Char(c) => {
|
Char(c) => {
|
||||||
@@ -1364,14 +1854,21 @@ fn key_to_bytes(key: KeyEvent) -> Option<Vec<u8>> {
|
|||||||
}
|
}
|
||||||
Enter => vec![b'\r'],
|
Enter => vec![b'\r'],
|
||||||
Backspace => vec![0x7f],
|
Backspace => vec![0x7f],
|
||||||
Tab => vec![b'\t'],
|
Tab => {
|
||||||
|
if key.modifiers.contains(KeyModifiers::SHIFT) {
|
||||||
|
b"\x1b[Z".to_vec() // Shift+Tab → CSI Z (reverse tab)
|
||||||
|
} else {
|
||||||
|
vec![b'\t']
|
||||||
|
}
|
||||||
|
}
|
||||||
Esc => vec![0x1b],
|
Esc => vec![0x1b],
|
||||||
Up => b"\x1b[A".to_vec(),
|
Insert => b"\x1b[2~".to_vec(),
|
||||||
Down => b"\x1b[B".to_vec(),
|
Up => if app_cursor { b"\x1bOA".to_vec() } else { b"\x1b[A".to_vec() },
|
||||||
Right => b"\x1b[C".to_vec(),
|
Down => if app_cursor { b"\x1bOB".to_vec() } else { b"\x1b[B".to_vec() },
|
||||||
Left => b"\x1b[D".to_vec(),
|
Right => if app_cursor { b"\x1bOC".to_vec() } else { b"\x1b[C".to_vec() },
|
||||||
Home => b"\x1b[H".to_vec(),
|
Left => if app_cursor { b"\x1bOD".to_vec() } else { b"\x1b[D".to_vec() },
|
||||||
End => b"\x1b[F".to_vec(),
|
Home => if app_cursor { b"\x1bOH".to_vec() } else { b"\x1b[H".to_vec() },
|
||||||
|
End => if app_cursor { b"\x1bOF".to_vec() } else { b"\x1b[F".to_vec() },
|
||||||
PageUp => b"\x1b[5~".to_vec(),
|
PageUp => b"\x1b[5~".to_vec(),
|
||||||
PageDown => b"\x1b[6~".to_vec(),
|
PageDown => b"\x1b[6~".to_vec(),
|
||||||
Delete => b"\x1b[3~".to_vec(),
|
Delete => b"\x1b[3~".to_vec(),
|
||||||
@@ -1387,8 +1884,165 @@ fn key_to_bytes(key: KeyEvent) -> Option<Vec<u8>> {
|
|||||||
F(10) => b"\x1b[21~".to_vec(),
|
F(10) => b"\x1b[21~".to_vec(),
|
||||||
F(11) => b"\x1b[23~".to_vec(),
|
F(11) => b"\x1b[23~".to_vec(),
|
||||||
F(12) => b"\x1b[24~".to_vec(),
|
F(12) => b"\x1b[24~".to_vec(),
|
||||||
|
BackTab => b"\x1b[Z".to_vec(),
|
||||||
_ => return None,
|
_ => return None,
|
||||||
};
|
};
|
||||||
Some(bytes)
|
Some(bytes)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Enkel base64-kodning utan extern dependency.
|
||||||
|
fn base64_encode(data: &[u8]) -> String {
|
||||||
|
const ALPHABET: &[u8; 64] =
|
||||||
|
b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||||
|
let mut out = String::with_capacity((data.len() + 2) / 3 * 4);
|
||||||
|
for chunk in data.chunks(3) {
|
||||||
|
let b0 = chunk[0] as u32;
|
||||||
|
let b1 = if chunk.len() > 1 { chunk[1] as u32 } else { 0 };
|
||||||
|
let b2 = if chunk.len() > 2 { chunk[2] as u32 } else { 0 };
|
||||||
|
let triple = (b0 << 16) | (b1 << 8) | b2;
|
||||||
|
out.push(ALPHABET[((triple >> 18) & 0x3F) as usize] as char);
|
||||||
|
out.push(ALPHABET[((triple >> 12) & 0x3F) as usize] as char);
|
||||||
|
if chunk.len() > 1 {
|
||||||
|
out.push(ALPHABET[((triple >> 6) & 0x3F) as usize] as char);
|
||||||
|
} else {
|
||||||
|
out.push('=');
|
||||||
|
}
|
||||||
|
if chunk.len() > 2 {
|
||||||
|
out.push(ALPHABET[(triple & 0x3F) as usize] as char);
|
||||||
|
} else {
|
||||||
|
out.push('=');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Kitty graphics forwarding ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Bearbetar rå PTY-data: extraherar APC Kitty graphics-sekvenser (ESC_G...ESC\),
|
||||||
|
/// skickar resten genom vt100-parsern interfoliat, och returnerar väntande
|
||||||
|
/// grafik-kommandon med markörposition vid varje kommando.
|
||||||
|
fn process_pty_with_kitty_gfx(
|
||||||
|
data: &[u8],
|
||||||
|
parser: &mut vt100::Parser,
|
||||||
|
pty: &mut PtyTerminal,
|
||||||
|
carry: &mut Vec<u8>,
|
||||||
|
) -> Vec<PendingGraphic> {
|
||||||
|
let mut buf = std::mem::take(carry);
|
||||||
|
buf.extend_from_slice(data);
|
||||||
|
|
||||||
|
let mut graphics = Vec::new();
|
||||||
|
let mut pos = 0;
|
||||||
|
|
||||||
|
while pos < buf.len() {
|
||||||
|
// Sök nästa APC start: ESC _ G (0x1b 0x5f 0x47)
|
||||||
|
match find_apc_g_start(&buf[pos..]) {
|
||||||
|
Some(offset) => {
|
||||||
|
// Skicka text före APC genom parsern
|
||||||
|
if offset > 0 {
|
||||||
|
parser.process(&buf[pos..pos + offset]);
|
||||||
|
}
|
||||||
|
let apc_start = pos + offset;
|
||||||
|
|
||||||
|
// Sök APC slut: ESC \ (0x1b 0x5c)
|
||||||
|
match find_apc_end(&buf[apc_start..]) {
|
||||||
|
Some(end_offset) => {
|
||||||
|
let apc_data = &buf[apc_start..apc_start + end_offset];
|
||||||
|
let (row, col) = parser.screen().cursor_position();
|
||||||
|
|
||||||
|
if is_kitty_query(apc_data) {
|
||||||
|
// Svara direkt på query → skicka OK tillbaka till PTY:n
|
||||||
|
if let Some(response) = make_kitty_query_response(apc_data) {
|
||||||
|
let _ = pty.write_input(&response);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
graphics.push(PendingGraphic {
|
||||||
|
raw: apc_data.to_vec(),
|
||||||
|
cursor_row: row,
|
||||||
|
cursor_col: col,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
pos = apc_start + end_offset;
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
// Ofullständig APC — spara resten som carry
|
||||||
|
*carry = buf[apc_start..].to_vec();
|
||||||
|
return graphics;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
// Inga fler APC-sekvenser — bearbeta resterande data
|
||||||
|
parser.process(&buf[pos..]);
|
||||||
|
pos = buf.len();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
graphics
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hitta nästa APC Kitty graphics start (ESC _ G) i data.
|
||||||
|
fn find_apc_g_start(data: &[u8]) -> Option<usize> {
|
||||||
|
if data.len() < 3 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
for i in 0..data.len() - 2 {
|
||||||
|
if data[i] == 0x1b && data[i + 1] == b'_' && data[i + 2] == b'G' {
|
||||||
|
return Some(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hitta APC slut (ESC \) efter start. Returnerar end offset (inkl. ESC \).
|
||||||
|
fn find_apc_end(data: &[u8]) -> Option<usize> {
|
||||||
|
if data.len() < 5 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
for i in 3..data.len() - 1 {
|
||||||
|
if data[i] == 0x1b && data[i + 1] == b'\\' {
|
||||||
|
return Some(i + 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Kolla om en APC-sekvens är en Kitty graphics query (a=q).
|
||||||
|
fn is_kitty_query(apc_data: &[u8]) -> bool {
|
||||||
|
if apc_data.len() < 5 {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Header: allt mellan ESC_G och ';' (eller ESC\)
|
||||||
|
let header_end = apc_data[3..]
|
||||||
|
.iter()
|
||||||
|
.position(|&b| b == b';' || b == 0x1b)
|
||||||
|
.map(|p| p + 3)
|
||||||
|
.unwrap_or(apc_data.len().saturating_sub(2));
|
||||||
|
if let Ok(header) = std::str::from_utf8(&apc_data[3..header_end]) {
|
||||||
|
header.split(',').any(|kv| kv.trim() == "a=q")
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Skapa ett Kitty graphics query-svar (OK) för en given query-sekvens.
|
||||||
|
fn make_kitty_query_response(apc_data: &[u8]) -> Option<Vec<u8>> {
|
||||||
|
if apc_data.len() < 5 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let header_end = apc_data[3..]
|
||||||
|
.iter()
|
||||||
|
.position(|&b| b == b';' || b == 0x1b)
|
||||||
|
.map(|p| p + 3)
|
||||||
|
.unwrap_or(apc_data.len().saturating_sub(2));
|
||||||
|
let header = std::str::from_utf8(&apc_data[3..header_end]).ok()?;
|
||||||
|
|
||||||
|
// Extrahera image-id (i=N)
|
||||||
|
let mut id = "0";
|
||||||
|
for kv in header.split(',') {
|
||||||
|
if let Some(v) = kv.strip_prefix("i=") {
|
||||||
|
id = v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(format!("\x1b_Gi={};OK\x1b\\", id).into_bytes())
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
199
src/client.rs
Normal file
199
src/client.rs
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
use crate::ipc::{self, ClientMessage, ClientRole, ServerMessage};
|
||||||
|
use crossterm::{
|
||||||
|
event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyModifiers},
|
||||||
|
execute,
|
||||||
|
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
|
||||||
|
};
|
||||||
|
use std::io::{self, BufReader, BufWriter, Write};
|
||||||
|
use std::os::unix::net::UnixStream;
|
||||||
|
use std::sync::mpsc;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
pub fn run(socket_path: &str) -> io::Result<()> {
|
||||||
|
let stream = UnixStream::connect(socket_path).map_err(|e| {
|
||||||
|
io::Error::new(
|
||||||
|
io::ErrorKind::ConnectionRefused,
|
||||||
|
format!("Kunde inte ansluta till TUI-WM ({}): {}", socket_path, e),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let stream_write = stream.try_clone()?;
|
||||||
|
let mut reader = BufReader::new(stream);
|
||||||
|
let mut writer = BufWriter::new(stream_write);
|
||||||
|
|
||||||
|
let (term_w, term_h) = crossterm::terminal::size()?;
|
||||||
|
|
||||||
|
ipc::write_message(
|
||||||
|
&mut writer,
|
||||||
|
&ClientMessage::Hello {
|
||||||
|
role: ClientRole::Display,
|
||||||
|
width: term_w,
|
||||||
|
height: term_h,
|
||||||
|
window_id: None,
|
||||||
|
},
|
||||||
|
)?;
|
||||||
|
|
||||||
|
match ipc::read_message::<_, ServerMessage>(&mut reader) {
|
||||||
|
Ok(ServerMessage::HelloOk { .. }) => {}
|
||||||
|
_ => {
|
||||||
|
return Err(io::Error::new(io::ErrorKind::Other, "Ogiltigt svar från server"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let (frame_tx, frame_rx) = mpsc::channel::<ServerMessage>();
|
||||||
|
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
loop {
|
||||||
|
match ipc::read_message::<_, ServerMessage>(&mut reader) {
|
||||||
|
Ok(msg) => {
|
||||||
|
if frame_tx.send(msg).is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(_) => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
enable_raw_mode()?;
|
||||||
|
let mut stdout = io::stdout();
|
||||||
|
execute!(stdout, EnterAlternateScreen, EnableMouseCapture, crossterm::cursor::Hide)?;
|
||||||
|
|
||||||
|
let result = client_loop(&mut stdout, &mut writer, &frame_rx, term_w, term_h);
|
||||||
|
|
||||||
|
execute!(
|
||||||
|
stdout,
|
||||||
|
crossterm::cursor::Show,
|
||||||
|
LeaveAlternateScreen,
|
||||||
|
DisableMouseCapture
|
||||||
|
)?;
|
||||||
|
disable_raw_mode()?;
|
||||||
|
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
fn client_loop(
|
||||||
|
stdout: &mut impl Write,
|
||||||
|
writer: &mut impl Write,
|
||||||
|
frame_rx: &mpsc::Receiver<ServerMessage>,
|
||||||
|
initial_width: u16,
|
||||||
|
initial_height: u16,
|
||||||
|
) -> io::Result<()> {
|
||||||
|
let mut term_width = initial_width;
|
||||||
|
let mut term_height = initial_height;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
// Töm alla inkommande frames
|
||||||
|
loop {
|
||||||
|
match frame_rx.try_recv() {
|
||||||
|
Ok(ServerMessage::Frame { data, min_height, min_width, .. }) => {
|
||||||
|
let ansi = ipc::from_hex(&data);
|
||||||
|
stdout.write_all(&ansi)?;
|
||||||
|
if term_height > min_height {
|
||||||
|
draw_boundary(stdout, min_height, term_width)?;
|
||||||
|
}
|
||||||
|
if term_width > min_width {
|
||||||
|
// Rita vertikala avgränsningslinjer om bredden skiljer sig
|
||||||
|
// (enkelt: rita inte, den horisontella räcker)
|
||||||
|
}
|
||||||
|
stdout.flush()?;
|
||||||
|
}
|
||||||
|
Ok(_) => {}
|
||||||
|
Err(mpsc::TryRecvError::Empty) => break,
|
||||||
|
Err(mpsc::TryRecvError::Disconnected) => return Ok(()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hantera input
|
||||||
|
if event::poll(Duration::from_millis(5))? {
|
||||||
|
match event::read()? {
|
||||||
|
Event::Key(key) => {
|
||||||
|
// Ctrl+Shift+Q = koppla ned
|
||||||
|
if key.modifiers.contains(KeyModifiers::CONTROL)
|
||||||
|
&& key.modifiers.contains(KeyModifiers::SHIFT)
|
||||||
|
&& key.code == KeyCode::Char('q')
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if let Some(bytes) = key_to_bytes(key) {
|
||||||
|
ipc::write_message(
|
||||||
|
writer,
|
||||||
|
&ClientMessage::Input { data: ipc::to_hex(&bytes) },
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Event::Resize(w, h) => {
|
||||||
|
term_width = w;
|
||||||
|
term_height = h;
|
||||||
|
ipc::write_message(writer, &ClientMessage::Resize { width: w, height: h })?;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_boundary(out: &mut impl Write, at_row: u16, width: u16) -> io::Result<()> {
|
||||||
|
write!(out, "\x1b[{};1H", at_row + 1)?;
|
||||||
|
let line = "─".repeat(width as usize);
|
||||||
|
write!(out, "\x1b[2m\x1b[37m{}\x1b[0m", line)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn key_to_bytes(key: crossterm::event::KeyEvent) -> Option<Vec<u8>> {
|
||||||
|
use crossterm::event::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 => {
|
||||||
|
if key.modifiers.contains(KeyModifiers::SHIFT) {
|
||||||
|
b"\x1b[Z".to_vec()
|
||||||
|
} else {
|
||||||
|
vec![b'\t']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Esc => vec![0x1b],
|
||||||
|
Insert => b"\x1b[2~".to_vec(),
|
||||||
|
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(),
|
||||||
|
BackTab => b"\x1b[Z".to_vec(),
|
||||||
|
_ => return None,
|
||||||
|
};
|
||||||
|
Some(bytes)
|
||||||
|
}
|
||||||
@@ -3,6 +3,16 @@ use std::fs;
|
|||||||
|
|
||||||
#[derive(Deserialize, Debug, Clone, Default)]
|
#[derive(Deserialize, Debug, Clone, Default)]
|
||||||
pub struct Config {
|
pub struct Config {
|
||||||
|
/// Sökväg till bakgrundsbild (Kitty graphics protocol)
|
||||||
|
#[serde(default)]
|
||||||
|
pub background_image: Option<String>,
|
||||||
|
/// Bakgrundsfärg för virtuella terminaler. Om None → genomskinlig (Color::Reset).
|
||||||
|
/// Stödjer: "#RRGGBB", "N" (indexed 0-255), färgnamn.
|
||||||
|
#[serde(default)]
|
||||||
|
pub terminal_bg_color: Option<String>,
|
||||||
|
/// Standard-shell för nya terminaler. Om None → $SHELL eller /bin/bash.
|
||||||
|
#[serde(default)]
|
||||||
|
pub default_shell: Option<String>,
|
||||||
#[serde(default, rename = "panel")]
|
#[serde(default, rename = "panel")]
|
||||||
pub panels: Vec<PanelConfig>,
|
pub panels: Vec<PanelConfig>,
|
||||||
#[serde(default, rename = "keybind")]
|
#[serde(default, rename = "keybind")]
|
||||||
@@ -134,6 +144,24 @@ fn default_keybinds() -> Vec<KeybindConfig> {
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn default_panels() -> Vec<PanelConfig> {
|
||||||
|
vec![PanelConfig {
|
||||||
|
position: PanelPosition::Top,
|
||||||
|
items: vec![
|
||||||
|
MenuItem {
|
||||||
|
label: "Terminal".to_string(),
|
||||||
|
action: MenuAction::SpawnTerminal { shell: Some("/bin/bash".to_string()) },
|
||||||
|
},
|
||||||
|
MenuItem {
|
||||||
|
label: "Avsluta".to_string(),
|
||||||
|
action: MenuAction::Exit,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
status_widgets: vec![],
|
||||||
|
file: None,
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
|
||||||
impl Config {
|
impl Config {
|
||||||
pub fn load(path: &str) -> anyhow::Result<Config> {
|
pub fn load(path: &str) -> anyhow::Result<Config> {
|
||||||
let content = fs::read_to_string(path)?;
|
let content = fs::read_to_string(path)?;
|
||||||
@@ -157,12 +185,31 @@ impl Config {
|
|||||||
Err(e) => {
|
Err(e) => {
|
||||||
eprintln!("Varning: kunde inte ladda {}: {}", path, e);
|
eprintln!("Varning: kunde inte ladda {}: {}", path, e);
|
||||||
Config {
|
Config {
|
||||||
|
background_image: None,
|
||||||
|
terminal_bg_color: None,
|
||||||
|
default_shell: None,
|
||||||
|
panels: default_panels(),
|
||||||
keybinds: default_keybinds(),
|
keybinds: default_keybinds(),
|
||||||
..Config::default()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Uppdatera background_image i config-filen (TOML)
|
||||||
|
pub fn save_background(path: &str, image_path: Option<&str>) -> anyhow::Result<()> {
|
||||||
|
let content = fs::read_to_string(path).unwrap_or_default();
|
||||||
|
let mut doc: toml_edit::DocumentMut = content.parse().unwrap_or_default();
|
||||||
|
match image_path {
|
||||||
|
Some(p) => {
|
||||||
|
doc["background_image"] = toml_edit::value(p);
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
doc.remove("background_image");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fs::write(path, doc.to_string())?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
|
|||||||
245
src/ipc.rs
Normal file
245
src/ipc.rs
Normal file
@@ -0,0 +1,245 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::io::{Read, Write};
|
||||||
|
use ratatui::buffer::Buffer;
|
||||||
|
|
||||||
|
pub const SOCKET_ENV: &str = "TUI_WM_SOCKET";
|
||||||
|
pub const WINDOW_ID_ENV: &str = "TUI_WM_WINDOW_ID";
|
||||||
|
pub const VERSION: &str = "0.1.0";
|
||||||
|
|
||||||
|
pub fn default_socket_path() -> String {
|
||||||
|
if let Ok(runtime) = std::env::var("XDG_RUNTIME_DIR") {
|
||||||
|
format!("{}/tui-wm.sock", runtime)
|
||||||
|
} else if let Ok(user) = std::env::var("USER") {
|
||||||
|
format!("/tmp/tui-wm-{}.sock", user)
|
||||||
|
} else {
|
||||||
|
"/tmp/tui-wm.sock".to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||||
|
#[serde(tag = "type", rename_all = "snake_case")]
|
||||||
|
pub enum ClientMessage {
|
||||||
|
Hello {
|
||||||
|
role: ClientRole,
|
||||||
|
width: u16,
|
||||||
|
height: u16,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
window_id: Option<usize>,
|
||||||
|
},
|
||||||
|
Input { data: String }, // hex-encoded raw bytes
|
||||||
|
Resize { width: u16, height: u16 },
|
||||||
|
SpawnWindow {
|
||||||
|
command: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
request_id: Option<String>,
|
||||||
|
},
|
||||||
|
SpawnPopup {
|
||||||
|
message: String,
|
||||||
|
#[serde(default = "default_buttons")]
|
||||||
|
buttons: Vec<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
request_id: Option<String>,
|
||||||
|
},
|
||||||
|
ListWindows {
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
request_id: Option<String>,
|
||||||
|
},
|
||||||
|
CloseWindow {
|
||||||
|
window_id: usize,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
request_id: Option<String>,
|
||||||
|
},
|
||||||
|
SetBackground {
|
||||||
|
/// Sökväg till bildfil, eller tom/null för att ta bort bakgrund
|
||||||
|
#[serde(default)]
|
||||||
|
path: Option<String>,
|
||||||
|
/// Om true, spara ändringen i config.toml
|
||||||
|
#[serde(default)]
|
||||||
|
save: bool,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
request_id: Option<String>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_buttons() -> Vec<String> {
|
||||||
|
vec!["Ja".to_string(), "Nej".to_string()]
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum ClientRole {
|
||||||
|
Display,
|
||||||
|
App,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||||
|
#[serde(tag = "type", rename_all = "snake_case")]
|
||||||
|
pub enum ServerMessage {
|
||||||
|
HelloOk { version: String, socket_path: String },
|
||||||
|
Frame {
|
||||||
|
width: u16,
|
||||||
|
height: u16,
|
||||||
|
min_width: u16,
|
||||||
|
min_height: u16,
|
||||||
|
data: String, // hex-encoded ANSI
|
||||||
|
},
|
||||||
|
PopupResult {
|
||||||
|
button: String,
|
||||||
|
button_index: usize,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
request_id: Option<String>,
|
||||||
|
},
|
||||||
|
WindowOpened {
|
||||||
|
id: usize,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
request_id: Option<String>,
|
||||||
|
},
|
||||||
|
WindowList {
|
||||||
|
windows: Vec<WindowInfo>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
request_id: Option<String>,
|
||||||
|
},
|
||||||
|
Error {
|
||||||
|
message: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
request_id: Option<String>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||||
|
pub struct WindowInfo {
|
||||||
|
pub id: usize,
|
||||||
|
pub x: i32,
|
||||||
|
pub y: i32,
|
||||||
|
pub width: u16,
|
||||||
|
pub height: u16,
|
||||||
|
pub title: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn write_message<W: Write, T: Serialize>(writer: &mut W, msg: &T) -> std::io::Result<()> {
|
||||||
|
let json = serde_json::to_vec(msg)
|
||||||
|
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
|
||||||
|
let len = json.len() as u32;
|
||||||
|
writer.write_all(&len.to_be_bytes())?;
|
||||||
|
writer.write_all(&json)?;
|
||||||
|
writer.flush()?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn read_message<R: Read, T: for<'de> Deserialize<'de>>(reader: &mut R) -> std::io::Result<T> {
|
||||||
|
let mut len_buf = [0u8; 4];
|
||||||
|
reader.read_exact(&mut len_buf)?;
|
||||||
|
let len = u32::from_be_bytes(len_buf) as usize;
|
||||||
|
if len > 16 * 1024 * 1024 {
|
||||||
|
return Err(std::io::Error::new(std::io::ErrorKind::InvalidData, "Message too large"));
|
||||||
|
}
|
||||||
|
let mut buf = vec![0u8; len];
|
||||||
|
reader.read_exact(&mut buf)?;
|
||||||
|
serde_json::from_slice(&buf)
|
||||||
|
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn to_hex(bytes: &[u8]) -> String {
|
||||||
|
bytes.iter().map(|b| format!("{:02x}", b)).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn from_hex(s: &str) -> Vec<u8> {
|
||||||
|
(0..s.len())
|
||||||
|
.step_by(2)
|
||||||
|
.filter_map(|i| s.get(i..i + 2).and_then(|h| u8::from_str_radix(h, 16).ok()))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn buffer_to_ansi(buf: &Buffer) -> Vec<u8> {
|
||||||
|
use ratatui::style::{Color, Modifier};
|
||||||
|
let area = buf.area;
|
||||||
|
let mut out = String::with_capacity((area.width as usize * area.height as usize) * 20);
|
||||||
|
out.push_str("\x1b[0m\x1b[2J\x1b[H");
|
||||||
|
let mut last_fg = Color::Reset;
|
||||||
|
let mut last_bg = Color::Reset;
|
||||||
|
let mut last_mod = Modifier::empty();
|
||||||
|
for row in 0..area.height {
|
||||||
|
if row > 0 {
|
||||||
|
use std::fmt::Write as FmtWrite;
|
||||||
|
let _ = write!(out, "\x1b[{};1H", row + 1);
|
||||||
|
}
|
||||||
|
for col in 0..area.width {
|
||||||
|
let idx = (row * area.width + col) as usize;
|
||||||
|
let cell = &buf.content[idx];
|
||||||
|
let new_mod = cell.modifier;
|
||||||
|
if new_mod != last_mod {
|
||||||
|
out.push_str("\x1b[0m");
|
||||||
|
last_fg = Color::Reset;
|
||||||
|
last_bg = Color::Reset;
|
||||||
|
if new_mod.contains(Modifier::BOLD) { out.push_str("\x1b[1m"); }
|
||||||
|
if new_mod.contains(Modifier::DIM) { out.push_str("\x1b[2m"); }
|
||||||
|
if new_mod.contains(Modifier::ITALIC) { out.push_str("\x1b[3m"); }
|
||||||
|
if new_mod.contains(Modifier::UNDERLINED) { out.push_str("\x1b[4m"); }
|
||||||
|
if new_mod.contains(Modifier::REVERSED) { out.push_str("\x1b[7m"); }
|
||||||
|
if new_mod.contains(Modifier::CROSSED_OUT) { out.push_str("\x1b[9m"); }
|
||||||
|
last_mod = new_mod;
|
||||||
|
}
|
||||||
|
if cell.fg != last_fg {
|
||||||
|
out.push_str(&color_fg(cell.fg));
|
||||||
|
last_fg = cell.fg;
|
||||||
|
}
|
||||||
|
if cell.bg != last_bg {
|
||||||
|
out.push_str(&color_bg(cell.bg));
|
||||||
|
last_bg = cell.bg;
|
||||||
|
}
|
||||||
|
out.push_str(cell.symbol());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.push_str("\x1b[0m");
|
||||||
|
out.into_bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn color_fg(c: ratatui::style::Color) -> String {
|
||||||
|
use ratatui::style::Color::*;
|
||||||
|
match c {
|
||||||
|
Reset => "\x1b[39m".into(),
|
||||||
|
Black => "\x1b[30m".into(),
|
||||||
|
Red => "\x1b[31m".into(),
|
||||||
|
Green => "\x1b[32m".into(),
|
||||||
|
Yellow => "\x1b[33m".into(),
|
||||||
|
Blue => "\x1b[34m".into(),
|
||||||
|
Magenta => "\x1b[35m".into(),
|
||||||
|
Cyan => "\x1b[36m".into(),
|
||||||
|
Gray => "\x1b[37m".into(),
|
||||||
|
DarkGray => "\x1b[90m".into(),
|
||||||
|
LightRed => "\x1b[91m".into(),
|
||||||
|
LightGreen => "\x1b[92m".into(),
|
||||||
|
LightYellow => "\x1b[93m".into(),
|
||||||
|
LightBlue => "\x1b[94m".into(),
|
||||||
|
LightMagenta => "\x1b[95m".into(),
|
||||||
|
LightCyan => "\x1b[96m".into(),
|
||||||
|
White => "\x1b[97m".into(),
|
||||||
|
Rgb(r, g, b) => format!("\x1b[38;2;{};{};{}m", r, g, b),
|
||||||
|
Indexed(n) => format!("\x1b[38;5;{}m", n),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn color_bg(c: ratatui::style::Color) -> String {
|
||||||
|
use ratatui::style::Color::*;
|
||||||
|
match c {
|
||||||
|
Reset => "\x1b[49m".into(),
|
||||||
|
Black => "\x1b[40m".into(),
|
||||||
|
Red => "\x1b[41m".into(),
|
||||||
|
Green => "\x1b[42m".into(),
|
||||||
|
Yellow => "\x1b[43m".into(),
|
||||||
|
Blue => "\x1b[44m".into(),
|
||||||
|
Magenta => "\x1b[45m".into(),
|
||||||
|
Cyan => "\x1b[46m".into(),
|
||||||
|
Gray => "\x1b[47m".into(),
|
||||||
|
DarkGray => "\x1b[100m".into(),
|
||||||
|
LightRed => "\x1b[101m".into(),
|
||||||
|
LightGreen => "\x1b[102m".into(),
|
||||||
|
LightYellow => "\x1b[103m".into(),
|
||||||
|
LightBlue => "\x1b[104m".into(),
|
||||||
|
LightMagenta => "\x1b[105m".into(),
|
||||||
|
LightCyan => "\x1b[106m".into(),
|
||||||
|
White => "\x1b[107m".into(),
|
||||||
|
Rgb(r, g, b) => format!("\x1b[48;2;{};{};{}m", r, g, b),
|
||||||
|
Indexed(n) => format!("\x1b[48;5;{}m", n),
|
||||||
|
}
|
||||||
|
}
|
||||||
184
src/kitty_gfx.rs
Normal file
184
src/kitty_gfx.rs
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
//! Kitty graphics protocol — bakgrundsbildrendering.
|
||||||
|
//!
|
||||||
|
//! Implementerar den minimala delen av Kitty-protokollet som behövs
|
||||||
|
//! för att visa en bakgrundsbild bakom TUI-WM:s text:
|
||||||
|
//!
|
||||||
|
//! 1. Ladda bild (JPEG/PNG/…) med `image`-craten
|
||||||
|
//! 2. Skala till terminalens pixelstorlek
|
||||||
|
//! 3. Transmittera som PNG via APC escape-sekvenser
|
||||||
|
//! 4. Visa med negativ z-index (under text)
|
||||||
|
//!
|
||||||
|
//! Bilden tilldelas image-id 1 och placement-id 1.
|
||||||
|
//! Vid byte av storlek eller bakgrund raderas den gamla med `a=d,d=I,i=1`.
|
||||||
|
|
||||||
|
use std::io::Write;
|
||||||
|
|
||||||
|
const IMAGE_ID: u32 = 1;
|
||||||
|
const PLACEMENT_ID: u32 = 1;
|
||||||
|
const CHUNK_SIZE: usize = 4096;
|
||||||
|
|
||||||
|
/// Tillstånd för den aktiva bakgrundsbilden.
|
||||||
|
pub struct BgImage {
|
||||||
|
/// Sökväg som bilden laddades från
|
||||||
|
pub path: String,
|
||||||
|
/// Terminal-storlek (kolumner, rader) som bilden sist renderades för
|
||||||
|
pub rendered_cols: u16,
|
||||||
|
pub rendered_rows: u16,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Radera alla Kitty-bilder med vårt image-id.
|
||||||
|
pub fn delete_bg(stdout: &mut impl Write) {
|
||||||
|
let cmd = format!("\x1b_Ga=d,d=I,i={},q=2;\x1b\\", IMAGE_ID);
|
||||||
|
let _ = stdout.write_all(cmd.as_bytes());
|
||||||
|
let _ = stdout.flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ladda, skala och visa en bakgrundsbild med Kitty graphics protocol.
|
||||||
|
///
|
||||||
|
/// `cols`/`rows` = terminalens storlek i celler.
|
||||||
|
/// `pixel_w`/`pixel_h` = terminalens storlek i pixlar (0 = okänd, skippa rendering).
|
||||||
|
///
|
||||||
|
/// Returnerar `Some(BgImage)` vid framgång.
|
||||||
|
pub fn show_bg(
|
||||||
|
stdout: &mut impl Write,
|
||||||
|
path: &str,
|
||||||
|
cols: u16,
|
||||||
|
rows: u16,
|
||||||
|
pixel_w: u16,
|
||||||
|
pixel_h: u16,
|
||||||
|
) -> Option<BgImage> {
|
||||||
|
if pixel_w == 0 || pixel_h == 0 {
|
||||||
|
crate::log::log(&format!(
|
||||||
|
"kitty_gfx: kan inte rendera bakgrund — pixelstorlek okänd ({}x{})",
|
||||||
|
pixel_w, pixel_h
|
||||||
|
));
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ladda bilden
|
||||||
|
let img = match image::open(path) {
|
||||||
|
Ok(i) => i,
|
||||||
|
Err(e) => {
|
||||||
|
crate::log::log(&format!("kitty_gfx: kan inte ladda {}: {}", path, e));
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Skala till exakt pixelstorlek (cover: bevara aspect, beskär)
|
||||||
|
let resized = img.resize_to_fill(
|
||||||
|
pixel_w as u32,
|
||||||
|
pixel_h as u32,
|
||||||
|
image::imageops::FilterType::Triangle,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Koda som PNG till minne
|
||||||
|
let mut png_buf: Vec<u8> = Vec::new();
|
||||||
|
{
|
||||||
|
let encoder = image::codecs::png::PngEncoder::new(&mut png_buf);
|
||||||
|
if let Err(e) = resized.write_with_encoder(encoder) {
|
||||||
|
crate::log::log(&format!("kitty_gfx: PNG-encoding misslyckades: {}", e));
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
crate::log::log(&format!(
|
||||||
|
"kitty_gfx: bild {}x{} px → PNG {} bytes för {}x{} celler",
|
||||||
|
resized.width(),
|
||||||
|
resized.height(),
|
||||||
|
png_buf.len(),
|
||||||
|
cols,
|
||||||
|
rows,
|
||||||
|
));
|
||||||
|
|
||||||
|
// Radera eventuell gammal bild
|
||||||
|
delete_bg(stdout);
|
||||||
|
|
||||||
|
// Positionera markören till övre vänstra hörnet innan vi skickar bilden
|
||||||
|
let _ = stdout.write_all(b"\x1b[1;1H");
|
||||||
|
|
||||||
|
// Base64-koda
|
||||||
|
let b64 = base64_encode(&png_buf);
|
||||||
|
|
||||||
|
// Skicka chunkat
|
||||||
|
let chunks: Vec<&str> = b64
|
||||||
|
.as_bytes()
|
||||||
|
.chunks(CHUNK_SIZE)
|
||||||
|
.map(|c| std::str::from_utf8(c).unwrap_or(""))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
for (idx, chunk) in chunks.iter().enumerate() {
|
||||||
|
let is_last = idx == chunks.len() - 1;
|
||||||
|
let m = if is_last { 0 } else { 1 };
|
||||||
|
|
||||||
|
if idx == 0 {
|
||||||
|
// Första chunk: full metadata
|
||||||
|
// f=100 → PNG, a=T → transmit+display, z=-1 → under text
|
||||||
|
// C=1 → flytta inte markören
|
||||||
|
let header = format!(
|
||||||
|
"\x1b_Ga=T,f=100,i={},p={},z=-1,c={},r={},C=1,q=2,m={};",
|
||||||
|
IMAGE_ID, PLACEMENT_ID, cols, rows, m
|
||||||
|
);
|
||||||
|
let _ = stdout.write_all(header.as_bytes());
|
||||||
|
} else {
|
||||||
|
let header = format!("\x1b_Gm={};", m);
|
||||||
|
let _ = stdout.write_all(header.as_bytes());
|
||||||
|
}
|
||||||
|
let _ = stdout.write_all(chunk.as_bytes());
|
||||||
|
let _ = stdout.write_all(b"\x1b\\");
|
||||||
|
}
|
||||||
|
let _ = stdout.flush();
|
||||||
|
|
||||||
|
Some(BgImage {
|
||||||
|
path: path.to_string(),
|
||||||
|
rendered_cols: cols,
|
||||||
|
rendered_rows: rows,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fråga terminalen om pixelstorlek via `\x1b[14t`.
|
||||||
|
/// Skickar frågan — svaret (`\x1b[4;<h>;<w>t`) läses av crossterm.
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn query_pixel_size(stdout: &mut impl Write) {
|
||||||
|
let _ = stdout.write_all(b"\x1b[14t");
|
||||||
|
let _ = stdout.flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Försök tolka ett `\x1b[4;<h>;<w>t`-svar från stdin.
|
||||||
|
/// Returnerar `(width, height)` i pixlar.
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn parse_pixel_size_response(data: &[u8]) -> Option<(u16, u16)> {
|
||||||
|
// Format: ESC [ 4 ; <height> ; <width> t
|
||||||
|
let s = std::str::from_utf8(data).ok()?;
|
||||||
|
let s = s.strip_prefix("\x1b[4;")?;
|
||||||
|
let s = s.strip_suffix('t')?;
|
||||||
|
let mut parts = s.split(';');
|
||||||
|
let h: u16 = parts.next()?.parse().ok()?;
|
||||||
|
let w: u16 = parts.next()?.parse().ok()?;
|
||||||
|
Some((w, h))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Enkel base64-kodning.
|
||||||
|
fn base64_encode(data: &[u8]) -> String {
|
||||||
|
const ALPHABET: &[u8; 64] =
|
||||||
|
b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||||
|
let mut out = String::with_capacity((data.len() + 2) / 3 * 4);
|
||||||
|
for chunk in data.chunks(3) {
|
||||||
|
let b0 = chunk[0] as u32;
|
||||||
|
let b1 = if chunk.len() > 1 { chunk[1] as u32 } else { 0 };
|
||||||
|
let b2 = if chunk.len() > 2 { chunk[2] as u32 } else { 0 };
|
||||||
|
let triple = (b0 << 16) | (b1 << 8) | b2;
|
||||||
|
out.push(ALPHABET[((triple >> 18) & 0x3F) as usize] as char);
|
||||||
|
out.push(ALPHABET[((triple >> 12) & 0x3F) as usize] as char);
|
||||||
|
if chunk.len() > 1 {
|
||||||
|
out.push(ALPHABET[((triple >> 6) & 0x3F) as usize] as char);
|
||||||
|
} else {
|
||||||
|
out.push('=');
|
||||||
|
}
|
||||||
|
if chunk.len() > 2 {
|
||||||
|
out.push(ALPHABET[(triple & 0x3F) as usize] as char);
|
||||||
|
} else {
|
||||||
|
out.push('=');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
440
src/main.rs
440
src/main.rs
@@ -1,67 +1,469 @@
|
|||||||
mod app;
|
mod app;
|
||||||
|
mod client;
|
||||||
mod config;
|
mod config;
|
||||||
|
mod ipc;
|
||||||
|
mod kitty_gfx;
|
||||||
mod log;
|
mod log;
|
||||||
mod pty;
|
mod pty;
|
||||||
mod render;
|
mod render;
|
||||||
|
mod server;
|
||||||
|
|
||||||
use app::App;
|
use app::{App, AppIpcOut, WindowContent};
|
||||||
use config::Config;
|
use config::Config;
|
||||||
use crossterm::{
|
use crossterm::{
|
||||||
event::{self, DisableMouseCapture, EnableMouseCapture},
|
event::{self, DisableMouseCapture, EnableMouseCapture, EnableBracketedPaste, DisableBracketedPaste, EnableFocusChange, DisableFocusChange},
|
||||||
execute,
|
execute,
|
||||||
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
|
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
|
||||||
};
|
};
|
||||||
|
use ipc::{ClientMessage, ClientRole, ServerMessage, WindowInfo};
|
||||||
use ratatui::{backend::CrosstermBackend, Terminal};
|
use ratatui::{backend::CrosstermBackend, Terminal};
|
||||||
use std::{io, time::Duration};
|
use server::IpcEvent;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::io::{self, Write as _};
|
||||||
|
use std::sync::mpsc;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
enum Mode {
|
||||||
|
Standalone,
|
||||||
|
Daemon,
|
||||||
|
Client(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_args() -> Mode {
|
||||||
|
let args: Vec<String> = std::env::args().collect();
|
||||||
|
let mut i = 1;
|
||||||
|
while i < args.len() {
|
||||||
|
match args[i].as_str() {
|
||||||
|
"-d" | "--daemon" => return Mode::Daemon,
|
||||||
|
"-c" | "--connect" => {
|
||||||
|
let path = args
|
||||||
|
.get(i + 1)
|
||||||
|
.filter(|s| !s.starts_with('-'))
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(ipc::default_socket_path);
|
||||||
|
return Mode::Client(path);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
Mode::Standalone
|
||||||
|
}
|
||||||
|
|
||||||
fn main() -> io::Result<()> {
|
fn main() -> io::Result<()> {
|
||||||
|
match parse_args() {
|
||||||
|
Mode::Standalone => run_standalone(),
|
||||||
|
Mode::Daemon => run_daemon_mode(),
|
||||||
|
Mode::Client(path) => client::run(&path),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_standalone() -> io::Result<()> {
|
||||||
log::init();
|
log::init();
|
||||||
|
let socket_path = ipc::default_socket_path();
|
||||||
|
let (ipc_tx, ipc_rx) = mpsc::channel::<IpcEvent>();
|
||||||
|
server::start(&socket_path, ipc_tx);
|
||||||
|
|
||||||
enable_raw_mode()?;
|
enable_raw_mode()?;
|
||||||
let mut stdout = io::stdout();
|
let mut stdout = io::stdout();
|
||||||
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
|
execute!(stdout, EnterAlternateScreen, EnableMouseCapture, EnableBracketedPaste, EnableFocusChange)?;
|
||||||
|
|
||||||
let backend = CrosstermBackend::new(stdout);
|
let backend = CrosstermBackend::new(stdout);
|
||||||
let mut terminal = Terminal::new(backend)?;
|
let mut terminal = Terminal::new(backend)?;
|
||||||
|
|
||||||
let config = Config::load_or_default("config.toml");
|
let config = Config::load_or_default("config.toml");
|
||||||
let mut app = App::new(config);
|
let mut app = App::new(config);
|
||||||
|
app.socket_path = Some(socket_path.clone());
|
||||||
|
|
||||||
let result = run(&mut terminal, &mut app);
|
let result = run_standalone_loop(&mut terminal, &mut app, ipc_rx, &socket_path);
|
||||||
|
|
||||||
disable_raw_mode()?;
|
disable_raw_mode()?;
|
||||||
execute!(
|
execute!(terminal.backend_mut(), LeaveAlternateScreen, DisableMouseCapture, DisableBracketedPaste, DisableFocusChange)?;
|
||||||
terminal.backend_mut(),
|
|
||||||
LeaveAlternateScreen,
|
|
||||||
DisableMouseCapture
|
|
||||||
)?;
|
|
||||||
terminal.show_cursor()?;
|
terminal.show_cursor()?;
|
||||||
|
|
||||||
result
|
result
|
||||||
}
|
}
|
||||||
|
|
||||||
fn run(
|
fn run_standalone_loop(
|
||||||
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
|
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
|
||||||
app: &mut App,
|
app: &mut App,
|
||||||
|
ipc_rx: mpsc::Receiver<IpcEvent>,
|
||||||
|
_socket_path: &str,
|
||||||
) -> io::Result<()> {
|
) -> io::Result<()> {
|
||||||
|
// all_clients: client_id → (role, width, height, sender)
|
||||||
|
let mut all_clients: HashMap<usize, (ClientRole, u16, u16, mpsc::SyncSender<ServerMessage>)> =
|
||||||
|
HashMap::new();
|
||||||
|
|
||||||
|
// Separate TestBackend for rendering to display clients
|
||||||
|
let mut test_terminal: Option<ratatui::Terminal<ratatui::backend::TestBackend>> = None;
|
||||||
|
|
||||||
|
// Kitty bakgrundsbild
|
||||||
|
let mut bg_state: Option<kitty_gfx::BgImage> = None;
|
||||||
|
|
||||||
|
let mut last_frame_area = ratatui::layout::Rect::default();
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
// Uppdatera layout från aktuell terminalstorlek
|
|
||||||
let size = terminal.size()?;
|
let size = terminal.size()?;
|
||||||
app.update_layout(ratatui::layout::Rect::new(0, 0, size.width, size.height));
|
|
||||||
|
|
||||||
// Rendera
|
// ── Kitty bakgrundsbild ──────────────────────────────────────────
|
||||||
terminal.draw(|frame| render::render(frame, app))?;
|
update_background(terminal, app, &mut bg_state, size.width, size.height);
|
||||||
|
|
||||||
// Töm PTY-output och uppdatera terminalparsers
|
terminal.draw(|frame| {
|
||||||
|
let area = frame.area();
|
||||||
|
app.update_layout(area);
|
||||||
|
render::render(frame, app);
|
||||||
|
last_frame_area = area;
|
||||||
|
})?;
|
||||||
app.tick();
|
app.tick();
|
||||||
|
|
||||||
// Hantera inkommande events (kort timeout → snabb PTY-uppdatering)
|
// ── Vidarebefordra Kitty graphics från virtuella terminaler ──────
|
||||||
|
{
|
||||||
|
let mut stdout = io::stdout();
|
||||||
|
for window in &mut app.windows {
|
||||||
|
if window.pending_graphics.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let cr = window.content_rect();
|
||||||
|
for gfx in window.pending_graphics.drain(..) {
|
||||||
|
let host_row = cr.y + gfx.cursor_row.min(cr.height.saturating_sub(1));
|
||||||
|
let host_col = cr.x + gfx.cursor_col.min(cr.width.saturating_sub(1));
|
||||||
|
// Positionera markören på värdterminalen
|
||||||
|
let _ = write!(stdout, "\x1b[{};{}H", host_row + 1, host_col + 1);
|
||||||
|
let _ = stdout.write_all(&gfx.raw);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let _ = stdout.flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process IPC events
|
||||||
|
while let Ok(ev) = ipc_rx.try_recv() {
|
||||||
|
handle_ipc_event(ev, app, &mut all_clients);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drain app IPC output (popup results, window opened notifications)
|
||||||
|
let out_events: Vec<AppIpcOut> = app.ipc_out.drain(..).collect();
|
||||||
|
for out_ev in out_events {
|
||||||
|
send_app_ipc_out(out_ev, &all_clients);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send frames to display clients
|
||||||
|
let display_clients: Vec<_> = all_clients
|
||||||
|
.values()
|
||||||
|
.filter(|(role, ..)| *role == ClientRole::Display)
|
||||||
|
.collect();
|
||||||
|
if !display_clients.is_empty() {
|
||||||
|
let min_w = display_clients.iter().map(|(_, w, _, _)| *w).min().unwrap_or(size.width);
|
||||||
|
let min_h = display_clients.iter().map(|(_, _, h, _)| *h).min().unwrap_or(size.height);
|
||||||
|
let render_w = min_w.min(size.width);
|
||||||
|
let render_h = min_h.min(size.height);
|
||||||
|
|
||||||
|
// Render to TestBackend at min size
|
||||||
|
let tt = test_terminal.get_or_insert_with(|| {
|
||||||
|
ratatui::Terminal::new(ratatui::backend::TestBackend::new(render_w, render_h))
|
||||||
|
.expect("TestBackend")
|
||||||
|
});
|
||||||
|
// Resize if needed
|
||||||
|
let cur_size = tt.size().unwrap_or_default();
|
||||||
|
if cur_size.width != render_w || cur_size.height != render_h {
|
||||||
|
*tt = ratatui::Terminal::new(
|
||||||
|
ratatui::backend::TestBackend::new(render_w, render_h),
|
||||||
|
)
|
||||||
|
.expect("TestBackend resize");
|
||||||
|
}
|
||||||
|
let render_area = ratatui::layout::Rect::new(0, 0, render_w, render_h);
|
||||||
|
app.update_layout(render_area);
|
||||||
|
let _ = tt.draw(|frame| render::render(frame, &*app));
|
||||||
|
// Restore layout for local render
|
||||||
|
app.update_layout(last_frame_area);
|
||||||
|
|
||||||
|
let ansi = ipc::buffer_to_ansi(tt.backend().buffer());
|
||||||
|
let hex_data = ipc::to_hex(&ansi);
|
||||||
|
let frame_msg = ServerMessage::Frame {
|
||||||
|
width: render_w,
|
||||||
|
height: render_h,
|
||||||
|
min_width: render_w,
|
||||||
|
min_height: render_h,
|
||||||
|
data: hex_data,
|
||||||
|
};
|
||||||
|
all_clients.retain(|_, (role, _, _, tx)| {
|
||||||
|
if *role == ClientRole::Display {
|
||||||
|
tx.send(frame_msg.clone()).is_ok()
|
||||||
|
} else {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if event::poll(Duration::from_millis(16))? {
|
if event::poll(Duration::from_millis(16))? {
|
||||||
app.handle_event(event::read()?);
|
app.handle_event(event::read()?);
|
||||||
}
|
}
|
||||||
|
|
||||||
if app.should_quit {
|
if app.should_quit {
|
||||||
|
// Radera kitty bakgrundsbild vid avslut
|
||||||
|
if bg_state.is_some() {
|
||||||
|
kitty_gfx::delete_bg(&mut io::stdout());
|
||||||
|
}
|
||||||
|
// Radera alla vidarebefordrade Kitty-bilder
|
||||||
|
let _ = io::stdout().write_all(b"\x1b_Ga=d,d=a;\x1b\\");
|
||||||
|
let _ = io::stdout().flush();
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Hämta terminalens pixelstorlek via TIOCGWINSZ ioctl.
|
||||||
|
#[cfg(unix)]
|
||||||
|
fn get_terminal_pixel_size() -> (u16, u16) {
|
||||||
|
use std::mem::MaybeUninit;
|
||||||
|
unsafe {
|
||||||
|
let mut ws: MaybeUninit<libc::winsize> = MaybeUninit::uninit();
|
||||||
|
if libc::ioctl(libc::STDOUT_FILENO, libc::TIOCGWINSZ, ws.as_mut_ptr()) == 0 {
|
||||||
|
let ws = ws.assume_init();
|
||||||
|
(ws.ws_xpixel, ws.ws_ypixel)
|
||||||
|
} else {
|
||||||
|
(0, 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(unix))]
|
||||||
|
fn get_terminal_pixel_size() -> (u16, u16) {
|
||||||
|
(0, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Uppdatera Kitty-bakgrundsbild om det behövs (ny bild, storleksändring, borttagen).
|
||||||
|
fn update_background(
|
||||||
|
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
|
||||||
|
app: &App,
|
||||||
|
bg_state: &mut Option<kitty_gfx::BgImage>,
|
||||||
|
cols: u16,
|
||||||
|
rows: u16,
|
||||||
|
) {
|
||||||
|
let wanted_path = app.config.background_image.as_deref();
|
||||||
|
|
||||||
|
// Kolla om vi behöver göra något
|
||||||
|
let needs_update = match (wanted_path, bg_state.as_ref()) {
|
||||||
|
(None, None) => false,
|
||||||
|
(None, Some(_)) => true, // ta bort
|
||||||
|
(Some(_p), None) => true, // ny bild
|
||||||
|
(Some(p), Some(bg)) => {
|
||||||
|
p != bg.path || cols != bg.rendered_cols || rows != bg.rendered_rows
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if !needs_update {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let stdout = terminal.backend_mut();
|
||||||
|
|
||||||
|
match wanted_path {
|
||||||
|
None => {
|
||||||
|
// Radera bakgrund
|
||||||
|
kitty_gfx::delete_bg(stdout);
|
||||||
|
*bg_state = None;
|
||||||
|
}
|
||||||
|
Some(path) => {
|
||||||
|
let (pixel_w, pixel_h) = get_terminal_pixel_size();
|
||||||
|
if let Some(bg) = kitty_gfx::show_bg(stdout, path, cols, rows, pixel_w, pixel_h) {
|
||||||
|
*bg_state = Some(bg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_daemon_mode() -> io::Result<()> {
|
||||||
|
log::init();
|
||||||
|
let socket_path = ipc::default_socket_path();
|
||||||
|
let (ipc_tx, ipc_rx) = mpsc::channel::<IpcEvent>();
|
||||||
|
server::start(&socket_path, ipc_tx);
|
||||||
|
|
||||||
|
let config = Config::load_or_default("config.toml");
|
||||||
|
let mut app = App::new(config);
|
||||||
|
app.socket_path = Some(socket_path.clone());
|
||||||
|
|
||||||
|
let mut all_clients: HashMap<usize, (ClientRole, u16, u16, mpsc::SyncSender<ServerMessage>)> =
|
||||||
|
HashMap::new();
|
||||||
|
let mut test_terminal: Option<ratatui::Terminal<ratatui::backend::TestBackend>> = None;
|
||||||
|
|
||||||
|
eprintln!("TUI-WM daemon startad. Socket: {}", socket_path);
|
||||||
|
|
||||||
|
loop {
|
||||||
|
// Process IPC events
|
||||||
|
while let Ok(ev) = ipc_rx.try_recv() {
|
||||||
|
handle_ipc_event(ev, &mut app, &mut all_clients);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drain app IPC output
|
||||||
|
let out_events: Vec<AppIpcOut> = app.ipc_out.drain(..).collect();
|
||||||
|
for out_ev in out_events {
|
||||||
|
send_app_ipc_out(out_ev, &all_clients);
|
||||||
|
}
|
||||||
|
|
||||||
|
app.tick();
|
||||||
|
|
||||||
|
// Render and send frames to display clients
|
||||||
|
let display_clients: Vec<_> = all_clients
|
||||||
|
.values()
|
||||||
|
.filter(|(role, ..)| *role == ClientRole::Display)
|
||||||
|
.collect();
|
||||||
|
if !display_clients.is_empty() {
|
||||||
|
let min_w = display_clients.iter().map(|(_, w, _, _)| *w).min().unwrap_or(80);
|
||||||
|
let min_h = display_clients.iter().map(|(_, _, h, _)| *h).min().unwrap_or(24);
|
||||||
|
let render_w = min_w.max(20);
|
||||||
|
let render_h = min_h.max(6);
|
||||||
|
|
||||||
|
let tt = test_terminal.get_or_insert_with(|| {
|
||||||
|
ratatui::Terminal::new(ratatui::backend::TestBackend::new(render_w, render_h))
|
||||||
|
.expect("TestBackend")
|
||||||
|
});
|
||||||
|
let cur_size = tt.size().unwrap_or_default();
|
||||||
|
if cur_size.width != render_w || cur_size.height != render_h {
|
||||||
|
*tt = ratatui::Terminal::new(
|
||||||
|
ratatui::backend::TestBackend::new(render_w, render_h),
|
||||||
|
)
|
||||||
|
.expect("TestBackend resize");
|
||||||
|
}
|
||||||
|
|
||||||
|
let render_area = ratatui::layout::Rect::new(0, 0, render_w, render_h);
|
||||||
|
app.update_layout(render_area);
|
||||||
|
let _ = tt.draw(|frame| render::render(frame, &app));
|
||||||
|
|
||||||
|
let ansi = ipc::buffer_to_ansi(tt.backend().buffer());
|
||||||
|
let hex_data = ipc::to_hex(&ansi);
|
||||||
|
let frame_msg = ServerMessage::Frame {
|
||||||
|
width: render_w,
|
||||||
|
height: render_h,
|
||||||
|
min_width: render_w,
|
||||||
|
min_height: render_h,
|
||||||
|
data: hex_data,
|
||||||
|
};
|
||||||
|
all_clients.retain(|_, (role, _, _, tx)| {
|
||||||
|
if *role == ClientRole::Display {
|
||||||
|
tx.send(frame_msg.clone()).is_ok()
|
||||||
|
} else {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if app.should_quit {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
std::thread::sleep(Duration::from_millis(16));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_ipc_event(
|
||||||
|
ev: IpcEvent,
|
||||||
|
app: &mut App,
|
||||||
|
all_clients: &mut HashMap<usize, (ClientRole, u16, u16, mpsc::SyncSender<ServerMessage>)>,
|
||||||
|
) {
|
||||||
|
match ev {
|
||||||
|
IpcEvent::ClientConnected { client_id, role, width, height, tx } => {
|
||||||
|
all_clients.insert(client_id, (role, width, height, tx));
|
||||||
|
}
|
||||||
|
IpcEvent::ClientDisconnected { client_id } => {
|
||||||
|
all_clients.remove(&client_id);
|
||||||
|
}
|
||||||
|
IpcEvent::Message { client_id, msg } => {
|
||||||
|
match msg {
|
||||||
|
ClientMessage::Input { data } => {
|
||||||
|
let bytes = ipc::from_hex(&data);
|
||||||
|
if let Some(id) = app.focused_id {
|
||||||
|
if let Some(w) = app.windows.iter_mut().find(|w| w.id == id) {
|
||||||
|
if let WindowContent::Terminal { pty, .. } = &mut w.content {
|
||||||
|
let _ = pty.write_input(&bytes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ClientMessage::Resize { width, height } => {
|
||||||
|
if let Some(client) = all_clients.get_mut(&client_id) {
|
||||||
|
client.1 = width;
|
||||||
|
client.2 = height;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ClientMessage::SpawnWindow { command, request_id } => {
|
||||||
|
let window_id = app.next_id;
|
||||||
|
app.spawn_terminal(&command);
|
||||||
|
app.ipc_out.push(AppIpcOut::WindowOpened {
|
||||||
|
client_id,
|
||||||
|
request_id,
|
||||||
|
window_id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
ClientMessage::SpawnPopup { message, buttons, request_id } => {
|
||||||
|
app.spawn_popup_dialog(message, buttons, client_id, request_id);
|
||||||
|
}
|
||||||
|
ClientMessage::ListWindows { request_id } => {
|
||||||
|
let windows: Vec<WindowInfo> = app
|
||||||
|
.windows
|
||||||
|
.iter()
|
||||||
|
.filter_map(|w| {
|
||||||
|
let title = match &w.content {
|
||||||
|
WindowContent::Terminal { title, .. } => title.clone(),
|
||||||
|
WindowContent::RunDialog { .. } => "[Kommandodialog]".to_string(),
|
||||||
|
WindowContent::PopupDialog { message, .. } => {
|
||||||
|
format!("[Popup: {}]", &message[..message.len().min(20)])
|
||||||
|
}
|
||||||
|
};
|
||||||
|
Some(WindowInfo {
|
||||||
|
id: w.id,
|
||||||
|
x: w.x,
|
||||||
|
y: w.y,
|
||||||
|
width: w.width,
|
||||||
|
height: w.height,
|
||||||
|
title,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
if let Some((_, _, _, tx)) = all_clients.get(&client_id) {
|
||||||
|
let _ = tx.send(ServerMessage::WindowList { windows, request_id });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ClientMessage::CloseWindow { window_id, .. } => {
|
||||||
|
app.close_window_pub(window_id);
|
||||||
|
}
|
||||||
|
ClientMessage::SetBackground { path, save, request_id } => {
|
||||||
|
app.config.background_image = path.clone();
|
||||||
|
if save {
|
||||||
|
if let Err(e) = Config::save_background("config.toml", path.as_deref()) {
|
||||||
|
log::log(&format!("Kunde inte spara bakgrund till config: {}", e));
|
||||||
|
if let Some((_, _, _, tx)) = all_clients.get(&client_id) {
|
||||||
|
let _ = tx.send(ServerMessage::Error {
|
||||||
|
message: format!("Kunde inte spara config: {}", e),
|
||||||
|
request_id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if let Some((_, _, _, tx)) = all_clients.get(&client_id) {
|
||||||
|
let _ = tx.send(ServerMessage::HelloOk {
|
||||||
|
version: ipc::VERSION.to_string(),
|
||||||
|
socket_path: String::new(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ClientMessage::Hello { .. } => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn send_app_ipc_out(
|
||||||
|
out_ev: AppIpcOut,
|
||||||
|
all_clients: &HashMap<usize, (ClientRole, u16, u16, mpsc::SyncSender<ServerMessage>)>,
|
||||||
|
) {
|
||||||
|
match out_ev {
|
||||||
|
AppIpcOut::PopupResult { client_id, request_id, button, button_index } => {
|
||||||
|
if let Some((_, _, _, tx)) = all_clients.get(&client_id) {
|
||||||
|
let _ = tx.send(ServerMessage::PopupResult { button, button_index, request_id });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
AppIpcOut::WindowOpened { client_id, request_id, window_id } => {
|
||||||
|
if let Some((_, _, _, tx)) = all_clients.get(&client_id) {
|
||||||
|
let _ = tx.send(ServerMessage::WindowOpened { id: window_id, request_id });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ pub struct PtyTerminal {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl PtyTerminal {
|
impl PtyTerminal {
|
||||||
pub fn spawn(shell: &str, rows: u16, cols: u16) -> Result<(Self, mpsc::Receiver<Vec<u8>>)> {
|
pub fn spawn(shell: &str, rows: u16, cols: u16, extra_env: &[(String, String)]) -> Result<(Self, mpsc::Receiver<Vec<u8>>)> {
|
||||||
let pty_system = NativePtySystem::default();
|
let pty_system = NativePtySystem::default();
|
||||||
let pair = pty_system.openpty(PtySize {
|
let pair = pty_system.openpty(PtySize {
|
||||||
rows,
|
rows,
|
||||||
@@ -23,6 +23,9 @@ impl PtyTerminal {
|
|||||||
let mut cmd = CommandBuilder::new(shell);
|
let mut cmd = CommandBuilder::new(shell);
|
||||||
cmd.env("TERM", "xterm-256color");
|
cmd.env("TERM", "xterm-256color");
|
||||||
cmd.env("COLORTERM", "truecolor");
|
cmd.env("COLORTERM", "truecolor");
|
||||||
|
for (key, val) in extra_env {
|
||||||
|
cmd.env(key, val);
|
||||||
|
}
|
||||||
|
|
||||||
let child = pair.slave.spawn_command(cmd)?;
|
let child = pair.slave.spawn_command(cmd)?;
|
||||||
drop(pair.slave);
|
drop(pair.slave);
|
||||||
|
|||||||
160
src/render.rs
160
src/render.rs
@@ -4,26 +4,34 @@ use ratatui::{
|
|||||||
layout::Rect,
|
layout::Rect,
|
||||||
style::{Color, Modifier, Style},
|
style::{Color, Modifier, Style},
|
||||||
text::{Line, Span},
|
text::{Line, Span},
|
||||||
widgets::{Block, Borders, Clear, Paragraph},
|
widgets::{Block, Borders, Clear, Paragraph, Wrap},
|
||||||
Frame,
|
Frame,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub fn render(frame: &mut Frame, app: &App) {
|
pub fn render(frame: &mut Frame, app: &App) {
|
||||||
let area = frame.area();
|
let area = frame.area();
|
||||||
|
|
||||||
// Bakgrund
|
// Bakgrund — transparent om Kitty-bakgrundsbild är aktiv, annars mörk
|
||||||
|
let bg_color = if app.config.background_image.is_some() {
|
||||||
|
Color::Reset
|
||||||
|
} else {
|
||||||
|
Color::Indexed(235)
|
||||||
|
};
|
||||||
frame.render_widget(
|
frame.render_widget(
|
||||||
Block::default().style(Style::default().bg(Color::Indexed(235))),
|
Block::default().style(Style::default().bg(bg_color)),
|
||||||
area,
|
area,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Flytande fönster – sista i listan renderas överst
|
// Flytande fönster – sista i listan renderas överst
|
||||||
|
let terminal_bg = app.config.terminal_bg_color.as_deref()
|
||||||
|
.and_then(parse_color)
|
||||||
|
.unwrap_or(Color::Reset);
|
||||||
for window in &app.windows {
|
for window in &app.windows {
|
||||||
let resize_hover = app
|
let resize_hover = app
|
||||||
.hovered_resize
|
.hovered_resize
|
||||||
.filter(|(id, _)| *id == window.id)
|
.filter(|(id, _)| *id == window.id)
|
||||||
.map(|(_, e)| e);
|
.map(|(_, e)| e);
|
||||||
render_window(frame, window, app.focused_id, app.hovered_window_close, resize_hover);
|
render_window(frame, window, app.focused_id, app.hovered_window_close, resize_hover, terminal_bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Paneler – alltid ovanpå fönster
|
// Paneler – alltid ovanpå fönster
|
||||||
@@ -182,6 +190,7 @@ fn render_window(
|
|||||||
focused_id: Option<usize>,
|
focused_id: Option<usize>,
|
||||||
hovered_close: Option<usize>,
|
hovered_close: Option<usize>,
|
||||||
resize_hover: Option<ResizeEdge>,
|
resize_hover: Option<ResizeEdge>,
|
||||||
|
terminal_bg: Color,
|
||||||
) {
|
) {
|
||||||
let full_rect = window.rect().intersection(frame.area());
|
let full_rect = window.rect().intersection(frame.area());
|
||||||
if full_rect.width < 4 || full_rect.height < 2 {
|
if full_rect.width < 4 || full_rect.height < 2 {
|
||||||
@@ -204,6 +213,7 @@ fn render_window(
|
|||||||
let title_text = match &window.content {
|
let title_text = match &window.content {
|
||||||
WindowContent::Terminal { title, .. } => format!(" {} ", title),
|
WindowContent::Terminal { title, .. } => format!(" {} ", title),
|
||||||
WindowContent::RunDialog { .. } => " Tui-run ".to_string(),
|
WindowContent::RunDialog { .. } => " Tui-run ".to_string(),
|
||||||
|
WindowContent::PopupDialog { .. } => " TUI-WM ".to_string(),
|
||||||
};
|
};
|
||||||
|
|
||||||
frame.render_widget(Clear, full_rect);
|
frame.render_widget(Clear, full_rect);
|
||||||
@@ -215,7 +225,10 @@ fn render_window(
|
|||||||
))
|
))
|
||||||
.borders(Borders::ALL)
|
.borders(Borders::ALL)
|
||||||
.border_style(border_style)
|
.border_style(border_style)
|
||||||
.style(Style::default().bg(Color::Black)),
|
.style(Style::default().bg(match &window.content {
|
||||||
|
WindowContent::Terminal { .. } => terminal_bg,
|
||||||
|
_ => Color::Black,
|
||||||
|
})),
|
||||||
full_rect,
|
full_rect,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -244,7 +257,7 @@ fn render_window(
|
|||||||
match &window.content {
|
match &window.content {
|
||||||
WindowContent::Terminal { parser, alive, .. } => {
|
WindowContent::Terminal { parser, alive, .. } => {
|
||||||
if *alive {
|
if *alive {
|
||||||
render_terminal(frame, parser.screen(), cr);
|
render_terminal(frame, parser.screen(), cr, window.selection.as_ref(), cr, terminal_bg);
|
||||||
} else {
|
} else {
|
||||||
frame.render_widget(
|
frame.render_widget(
|
||||||
Paragraph::new(Span::styled(
|
Paragraph::new(Span::styled(
|
||||||
@@ -258,6 +271,72 @@ fn render_window(
|
|||||||
WindowContent::RunDialog { input, cursor_pos } => {
|
WindowContent::RunDialog { input, cursor_pos } => {
|
||||||
render_run_dialog_content(frame, input, *cursor_pos, cr);
|
render_run_dialog_content(frame, input, *cursor_pos, cr);
|
||||||
}
|
}
|
||||||
|
WindowContent::PopupDialog { .. } => {
|
||||||
|
render_popup_dialog(frame, window, focused_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_popup_dialog(
|
||||||
|
frame: &mut Frame,
|
||||||
|
window: &FloatingWindow,
|
||||||
|
focused_id: Option<usize>,
|
||||||
|
) {
|
||||||
|
let is_focused = focused_id == Some(window.id);
|
||||||
|
let border_style = if is_focused {
|
||||||
|
Style::default().fg(Color::Cyan)
|
||||||
|
} else {
|
||||||
|
Style::default().fg(Color::Indexed(240))
|
||||||
|
};
|
||||||
|
|
||||||
|
let WindowContent::PopupDialog { message, buttons, selected, .. } = &window.content else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let rect = window.rect();
|
||||||
|
frame.render_widget(ratatui::widgets::Clear, rect);
|
||||||
|
frame.render_widget(
|
||||||
|
Block::default()
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.border_style(border_style)
|
||||||
|
.title(Span::styled(" TUI-WM ", Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)))
|
||||||
|
.style(Style::default().bg(Color::Indexed(235))),
|
||||||
|
rect,
|
||||||
|
);
|
||||||
|
|
||||||
|
let content = window.content_rect();
|
||||||
|
let msg_h = content.height.saturating_sub(2);
|
||||||
|
if msg_h > 0 {
|
||||||
|
frame.render_widget(
|
||||||
|
Paragraph::new(message.as_str())
|
||||||
|
.wrap(Wrap { trim: true })
|
||||||
|
.alignment(ratatui::layout::Alignment::Center)
|
||||||
|
.style(Style::default().fg(Color::White).bg(Color::Indexed(235))),
|
||||||
|
Rect::new(content.x, content.y, content.width, msg_h),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Buttons
|
||||||
|
let btn_y = content.y + content.height.saturating_sub(1);
|
||||||
|
let labels: Vec<String> = buttons.iter().map(|b| format!("[ {} ]", b)).collect();
|
||||||
|
let total_w: u16 = labels.iter().map(|l| l.len() as u16).sum::<u16>()
|
||||||
|
+ labels.len().saturating_sub(1) as u16;
|
||||||
|
let mut btn_x = content.x.saturating_add((content.width.saturating_sub(total_w)) / 2);
|
||||||
|
|
||||||
|
for (i, label) in labels.iter().enumerate() {
|
||||||
|
let w = label.len() as u16;
|
||||||
|
let style = if i == *selected {
|
||||||
|
Style::default().fg(Color::Black).bg(Color::Cyan).add_modifier(Modifier::BOLD)
|
||||||
|
} else {
|
||||||
|
Style::default().fg(Color::White).bg(Color::Indexed(238))
|
||||||
|
};
|
||||||
|
if btn_x + w <= rect.x + rect.width {
|
||||||
|
frame.render_widget(
|
||||||
|
Paragraph::new(Span::styled(label.clone(), style)),
|
||||||
|
Rect::new(btn_x, btn_y, w, 1),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
btn_x += w + 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -334,11 +413,19 @@ fn render_run_dialog_content(frame: &mut Frame, input: &str, cursor_pos: usize,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_terminal(frame: &mut Frame, screen: &vt100::Screen, area: Rect) {
|
fn render_terminal(
|
||||||
|
frame: &mut Frame,
|
||||||
|
screen: &vt100::Screen,
|
||||||
|
area: Rect,
|
||||||
|
selection: Option<&crate::app::Selection>,
|
||||||
|
_content_rect: Rect,
|
||||||
|
terminal_bg: Color,
|
||||||
|
) {
|
||||||
let (screen_rows, screen_cols) = screen.size();
|
let (screen_rows, screen_cols) = screen.size();
|
||||||
let rows = (area.height as usize).min(screen_rows as usize);
|
let rows = (area.height as usize).min(screen_rows as usize);
|
||||||
let cols = (area.width as usize).min(screen_cols as usize);
|
let cols = (area.width as usize).min(screen_cols as usize);
|
||||||
let (cur_row, cur_col) = screen.cursor_position();
|
let (cur_row, cur_col) = screen.cursor_position();
|
||||||
|
let show_cursor = !screen.hide_cursor();
|
||||||
|
|
||||||
for r in 0..rows {
|
for r in 0..rows {
|
||||||
let mut spans: Vec<Span> = Vec::new();
|
let mut spans: Vec<Span> = Vec::new();
|
||||||
@@ -346,14 +433,23 @@ fn render_terminal(frame: &mut Frame, screen: &vt100::Screen, area: Rect) {
|
|||||||
let mut cur_text = String::new();
|
let mut cur_text = String::new();
|
||||||
|
|
||||||
for c in 0..cols {
|
for c in 0..cols {
|
||||||
let is_cursor = r == cur_row as usize && c == cur_col as usize;
|
let is_cursor = show_cursor && r == cur_row as usize && c == cur_col as usize;
|
||||||
|
let is_selected = selection
|
||||||
|
.map(|sel| sel.contains(r as u16, c as u16))
|
||||||
|
.unwrap_or(false);
|
||||||
let (sym, style) = match screen.cell(r as u16, c as u16) {
|
let (sym, style) = match screen.cell(r as u16, c as u16) {
|
||||||
Some(cell) => {
|
Some(cell) => {
|
||||||
let s = cell.contents();
|
let s = cell.contents();
|
||||||
let s = if s.is_empty() { " ".to_string() } else { s.to_string() };
|
let s = if s.is_empty() { " ".to_string() } else { s.to_string() };
|
||||||
let mut st = Style::default()
|
let fg = vt_color(cell.fgcolor());
|
||||||
.fg(vt_color(cell.fgcolor()))
|
let raw_bg = vt_color(cell.bgcolor());
|
||||||
.bg(vt_color(cell.bgcolor()));
|
// Om cellen har default-bakgrund, använd terminal_bg
|
||||||
|
let bg = if raw_bg == Color::Reset { terminal_bg } else { raw_bg };
|
||||||
|
let mut st = if cell.inverse() {
|
||||||
|
Style::default().fg(bg).bg(fg)
|
||||||
|
} else {
|
||||||
|
Style::default().fg(fg).bg(bg)
|
||||||
|
};
|
||||||
if cell.bold() {
|
if cell.bold() {
|
||||||
st = st.add_modifier(Modifier::BOLD);
|
st = st.add_modifier(Modifier::BOLD);
|
||||||
}
|
}
|
||||||
@@ -363,12 +459,23 @@ fn render_terminal(frame: &mut Frame, screen: &vt100::Screen, area: Rect) {
|
|||||||
if cell.underline() {
|
if cell.underline() {
|
||||||
st = st.add_modifier(Modifier::UNDERLINED);
|
st = st.add_modifier(Modifier::UNDERLINED);
|
||||||
}
|
}
|
||||||
if is_cursor {
|
if is_selected {
|
||||||
|
st = Style::default()
|
||||||
|
.fg(Color::Black)
|
||||||
|
.bg(Color::Indexed(153)); // ljusblå markering
|
||||||
|
} else if is_cursor {
|
||||||
st = Style::default().fg(Color::Black).bg(Color::White);
|
st = Style::default().fg(Color::Black).bg(Color::White);
|
||||||
}
|
}
|
||||||
(s, st)
|
(s, st)
|
||||||
}
|
}
|
||||||
None => (" ".to_string(), Style::default()),
|
None => {
|
||||||
|
let st = if is_selected {
|
||||||
|
Style::default().fg(Color::Black).bg(Color::Indexed(153))
|
||||||
|
} else {
|
||||||
|
Style::default().bg(terminal_bg)
|
||||||
|
};
|
||||||
|
(" ".to_string(), st)
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if style == cur_style {
|
if style == cur_style {
|
||||||
@@ -400,3 +507,30 @@ fn vt_color(c: vt100::Color) -> Color {
|
|||||||
vt100::Color::Rgb(r, g, b) => Color::Rgb(r, g, b),
|
vt100::Color::Rgb(r, g, b) => Color::Rgb(r, g, b),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn parse_color(s: &str) -> Option<Color> {
|
||||||
|
let s = s.trim();
|
||||||
|
if let Some(hex) = s.strip_prefix('#') {
|
||||||
|
if hex.len() == 6 {
|
||||||
|
let r = u8::from_str_radix(&hex[0..2], 16).ok()?;
|
||||||
|
let g = u8::from_str_radix(&hex[2..4], 16).ok()?;
|
||||||
|
let b = u8::from_str_radix(&hex[4..6], 16).ok()?;
|
||||||
|
return Some(Color::Rgb(r, g, b));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Ok(n) = s.parse::<u8>() {
|
||||||
|
return Some(Color::Indexed(n));
|
||||||
|
}
|
||||||
|
match s.to_lowercase().as_str() {
|
||||||
|
"black" => Some(Color::Black),
|
||||||
|
"red" => Some(Color::Red),
|
||||||
|
"green" => Some(Color::Green),
|
||||||
|
"yellow" => Some(Color::Yellow),
|
||||||
|
"blue" => Some(Color::Blue),
|
||||||
|
"magenta" => Some(Color::Magenta),
|
||||||
|
"cyan" => Some(Color::Cyan),
|
||||||
|
"white" => Some(Color::White),
|
||||||
|
"reset" | "transparent" => Some(Color::Reset),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
112
src/server.rs
Normal file
112
src/server.rs
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
use crate::ipc::{self, ClientMessage, ClientRole, ServerMessage};
|
||||||
|
use std::io::{BufReader, BufWriter};
|
||||||
|
use std::os::unix::net::{UnixListener, UnixStream};
|
||||||
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||||
|
use std::sync::mpsc;
|
||||||
|
use std::thread;
|
||||||
|
|
||||||
|
static NEXT_CLIENT_ID: AtomicUsize = AtomicUsize::new(1);
|
||||||
|
|
||||||
|
pub enum IpcEvent {
|
||||||
|
ClientConnected {
|
||||||
|
client_id: usize,
|
||||||
|
role: ClientRole,
|
||||||
|
width: u16,
|
||||||
|
height: u16,
|
||||||
|
tx: mpsc::SyncSender<ServerMessage>,
|
||||||
|
},
|
||||||
|
ClientDisconnected {
|
||||||
|
client_id: usize,
|
||||||
|
},
|
||||||
|
Message {
|
||||||
|
client_id: usize,
|
||||||
|
msg: ClientMessage,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn start(socket_path: &str, event_tx: mpsc::Sender<IpcEvent>) {
|
||||||
|
let _ = std::fs::remove_file(socket_path);
|
||||||
|
let listener = match UnixListener::bind(socket_path) {
|
||||||
|
Ok(l) => l,
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("Kunde inte binda Unix socket {}: {}", socket_path, e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let socket_path = socket_path.to_string();
|
||||||
|
thread::spawn(move || {
|
||||||
|
for stream in listener.incoming() {
|
||||||
|
match stream {
|
||||||
|
Ok(stream) => {
|
||||||
|
let client_id = NEXT_CLIENT_ID.fetch_add(1, Ordering::SeqCst);
|
||||||
|
let event_tx = event_tx.clone();
|
||||||
|
let sp = socket_path.clone();
|
||||||
|
thread::spawn(move || {
|
||||||
|
handle_client(client_id, stream, event_tx, sp);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Err(_) => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_client(
|
||||||
|
client_id: usize,
|
||||||
|
stream: UnixStream,
|
||||||
|
event_tx: mpsc::Sender<IpcEvent>,
|
||||||
|
socket_path: String,
|
||||||
|
) {
|
||||||
|
let stream_write = match stream.try_clone() {
|
||||||
|
Ok(s) => s,
|
||||||
|
Err(_) => return,
|
||||||
|
};
|
||||||
|
let mut reader = BufReader::new(stream);
|
||||||
|
let (tx, rx) = mpsc::sync_channel::<ServerMessage>(64);
|
||||||
|
|
||||||
|
// Writer thread
|
||||||
|
thread::spawn(move || {
|
||||||
|
let mut writer = BufWriter::new(stream_write);
|
||||||
|
for msg in rx {
|
||||||
|
if ipc::write_message(&mut writer, &msg).is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Read Hello
|
||||||
|
let hello: ClientMessage = match ipc::read_message(&mut reader) {
|
||||||
|
Ok(m) => m,
|
||||||
|
Err(_) => return,
|
||||||
|
};
|
||||||
|
let (role, width, height) = match &hello {
|
||||||
|
ClientMessage::Hello { role, width, height, .. } => (role.clone(), *width, *height),
|
||||||
|
_ => return,
|
||||||
|
};
|
||||||
|
|
||||||
|
let _ = tx.send(ServerMessage::HelloOk {
|
||||||
|
version: ipc::VERSION.to_string(),
|
||||||
|
socket_path: socket_path.clone(),
|
||||||
|
});
|
||||||
|
|
||||||
|
let _ = event_tx.send(IpcEvent::ClientConnected {
|
||||||
|
client_id,
|
||||||
|
role,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
tx: tx.clone(),
|
||||||
|
});
|
||||||
|
|
||||||
|
loop {
|
||||||
|
match ipc::read_message::<_, ClientMessage>(&mut reader) {
|
||||||
|
Ok(msg) => {
|
||||||
|
if event_tx.send(IpcEvent::Message { client_id, msg }).is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(_) => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let _ = event_tx.send(IpcEvent::ClientDisconnected { client_id });
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user