From 339ad9530ee92d2ba03571f11575c5179e924bf9 Mon Sep 17 00:00:00 2001 From: Bjorn Blomberg Date: Sun, 29 Mar 2026 04:28:31 +0200 Subject: [PATCH] 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. --- Cargo.lock | 194 +++++++++++++ Cargo.toml | 4 + README.md | 43 +++ SOCKET_API.md | 376 +++++++++++++++++++++++++ config.toml | 11 + src/app.rs | 704 +++++++++++++++++++++++++++++++++++++++++++++-- src/client.rs | 199 ++++++++++++++ src/config.rs | 49 +++- src/ipc.rs | 245 +++++++++++++++++ src/kitty_gfx.rs | 184 +++++++++++++ src/main.rs | 440 +++++++++++++++++++++++++++-- src/pty.rs | 5 +- src/render.rs | 160 ++++++++++- src/server.rs | 112 ++++++++ 14 files changed, 2667 insertions(+), 59 deletions(-) create mode 100644 SOCKET_API.md create mode 100644 src/client.rs create mode 100644 src/ipc.rs create mode 100644 src/kitty_gfx.rs create mode 100644 src/server.rs diff --git a/Cargo.lock b/Cargo.lock index 4019032..a62b91d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,12 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + [[package]] name = "allocator-api2" version = "0.2.21" @@ -38,6 +44,18 @@ version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "cassowary" version = "0.3.0" @@ -59,6 +77,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + [[package]] name = "compact_str" version = "0.8.1" @@ -73,6 +97,15 @@ dependencies = [ "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]] name = "crossterm" version = "0.28.1" @@ -161,6 +194,15 @@ dependencies = [ "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]] name = "filedescriptor" version = "0.8.3" @@ -172,6 +214,16 @@ dependencies = [ "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]] name = "foldhash" version = "0.1.5" @@ -184,6 +236,16 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "hashbrown" version = "0.15.5" @@ -213,6 +275,34 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "indexmap" version = "2.13.0" @@ -326,6 +416,16 @@ dependencies = [ "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]] name = "mio" version = "1.2.0" @@ -338,6 +438,16 @@ dependencies = [ "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]] name = "nix" version = "0.25.1" @@ -352,6 +462,15 @@ dependencies = [ "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]] name = "parking_lot" version = "0.12.5" @@ -387,6 +506,19 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "portable-pty" version = "0.8.1" @@ -417,6 +549,18 @@ dependencies = [ "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]] name = "quote" version = "1.0.45" @@ -517,6 +661,19 @@ dependencies = [ "syn", ] +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + [[package]] name = "serde_spanned" version = "0.6.9" @@ -615,6 +772,12 @@ dependencies = [ "libc", ] +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + [[package]] name = "smallvec" version = "1.15.1" @@ -742,10 +905,14 @@ version = "0.1.0" dependencies = [ "anyhow", "crossterm", + "image", + "libc", "portable-pty", "ratatui", "serde", + "serde_json", "toml", + "toml_edit", "vt100", ] @@ -829,6 +996,12 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + [[package]] name = "winapi" version = "0.3.9" @@ -956,3 +1129,24 @@ checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d" dependencies = [ "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", +] diff --git a/Cargo.toml b/Cargo.toml index 5c01e0f..7aa4d71 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,7 +11,11 @@ path = "src/main.rs" crossterm = { version = "0.28", features = ["event-stream"] } ratatui = "0.29" serde = { version = "1", features = ["derive"] } +serde_json = "1" toml = "0.8" +toml_edit = "0.22" portable-pty = "0.8" vt100 = "0.15" anyhow = "1" +image = { version = "0.25", default-features = false, features = ["jpeg", "png", "gif", "bmp", "webp"] } +libc = "0.2" diff --git a/README.md b/README.md index c9df1ab..17ff63a 100644 --- a/README.md +++ b/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 | Tangent | Funktion | diff --git a/SOCKET_API.md b/SOCKET_API.md new file mode 100644 index 0000000..9ed3e7d --- /dev/null +++ b/SOCKET_API.md @@ -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**. diff --git a/config.toml b/config.toml index 11de09c..00b6f88 100644 --- a/config.toml +++ b/config.toml @@ -1,5 +1,16 @@ # 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]] position = "top" file = "panels/topbar.toml" diff --git a/src/app.rs b/src/app.rs index 309fbf1..92badc2 100644 --- a/src/app.rs +++ b/src/app.rs @@ -79,6 +79,22 @@ pub struct StatusWidgetState { pub last_run: Option, // None = aldrig kört, kör direkt } +// ─── App IPC output ─────────────────────────────────────────────────────────── + +pub enum AppIpcOut { + PopupResult { + client_id: usize, + request_id: Option, + button: String, + button_index: usize, + }, + WindowOpened { + client_id: usize, + request_id: Option, + window_id: usize, + }, +} + // ─── App ────────────────────────────────────────────────────────────────────── pub struct App { @@ -112,6 +128,10 @@ pub struct App { // TUI-WM logo-knapp rects (en per panel), för klick och hover pub tui_wm_btn_rects: Vec, pub hovered_tui_btn: bool, + + // IPC + pub socket_path: Option, + pub ipc_out: Vec, } pub struct DropdownState { @@ -142,6 +162,93 @@ pub struct FloatingWindow { pub mouse_encoding: vt100::MouseProtocolEncoding, /// Delvis mottagen escape-sekvens från PTY (för sekvenser som klippts tvärs genom en chunk-gräns). mouse_seq_carry: Vec, + /// Aktiv text-markering (selection) i terminalen + pub selection: Option, + /// Carry-buffer för ofullständiga APC (Kitty graphics) sekvenser + pub kitty_gfx_carry: Vec, + /// Köade Kitty graphics-kommandon att vidarebefordra till värdterminalen + pub pending_graphics: Vec, +} + +/// En Kitty graphics-sekvens extraherad från PTY-data, redo att vidarebefordras. +pub struct PendingGraphic { + /// Rå APC-sekvens (ESC _ G ... ESC \) + pub raw: Vec, + /// 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 { @@ -156,6 +263,13 @@ pub enum WindowContent { input: String, cursor_pos: usize, }, + PopupDialog { + message: String, + buttons: Vec, + selected: usize, + client_id: usize, + request_id: Option, + }, } enum RunDialogResult { @@ -339,6 +453,8 @@ impl App { parsed_keybinds, tui_wm_btn_rects: Vec::new(), hovered_tui_btn: false, + socket_path: None, + ipc_out: Vec::new(), } } @@ -413,7 +529,7 @@ impl App { pub fn tick(&mut self) { // ── PTY-data → vt100-parser + mus-tracking-skanning ────────────────────────────────────── 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 { continue; } @@ -421,14 +537,27 @@ impl App { match rx.try_recv() { Ok(data) => { // 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( &data, &mut window.mouse_mode, &mut window.mouse_encoding, &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::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 ───────────────────────────── @@ -461,6 +595,7 @@ impl App { self.windows.retain(|w| match &w.content { WindowContent::Terminal { alive, .. } => *alive, WindowContent::RunDialog { .. } => true, + WindowContent::PopupDialog { .. } => true, }); if let Some(fid) = prev_focused { if !self.windows.iter().any(|w| w.id == fid) { @@ -492,13 +627,52 @@ impl App { pub fn handle_event(&mut self, event: Event) { match event { Event::Key(key) => self.handle_key(key), - Event::Mouse(m) => self.handle_mouse(m.column, m.row, m.kind), + Event::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(_, _) => {} - _ => {} + } + } + + 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) { + // 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 if let Some(action) = self.match_keybind(key, KeybindScope::Global) { self.execute_action(action); @@ -516,7 +690,16 @@ impl App { RunDialogResult::Execute(cmd) => { self.close_window(id); 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 => {} @@ -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 if let Some(id) = self.focused_id { - if let Some(bytes) = key_to_bytes(key) { - if let Some(w) = self.windows.iter_mut().find(|w| w.id == id) { - if let WindowContent::Terminal { pty, .. } = &mut w.content { + if let Some(w) = self.windows.iter_mut().find(|w| w.id == id) { + if let WindowContent::Terminal { pty, parser, .. } = &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); } 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)> { + 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) 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 { MouseEventKind::Moved => { self.update_hover(col, row); @@ -945,11 +1252,13 @@ impl App { match action { MenuAction::Exit => self.should_quit = true, 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); } - MenuAction::RunScript { path } => self.spawn_terminal(&path), - MenuAction::RunProgram { command, .. } => self.spawn_terminal(&command), + MenuAction::RunScript { path } => { self.spawn_terminal(&path); } + MenuAction::RunProgram { command, .. } => { self.spawn_terminal(&command); } MenuAction::SpawnRunDialog => self.spawn_run_dialog(), MenuAction::Submenu { .. } => {} MenuAction::NoOp => {} @@ -978,10 +1287,57 @@ impl App { 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 spawn_popup_dialog( + &mut self, + message: String, + buttons: Vec, + client_id: usize, + request_id: Option, + ) { + 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) { let pr = match self.panel_rects.get(panel_idx) { Some(r) => *r, @@ -1051,7 +1407,32 @@ impl App { }).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 offset = (self.windows.len() as i32) * 2; let w = 82u16.min(ca.width.saturating_sub(4)); @@ -1061,10 +1442,16 @@ impl App { let rows = h.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)) => { let parser = vt100::Parser::new(rows, cols, 0); - let id = self.next_id; self.next_id += 1; let title = shell.split('/').last().unwrap_or(shell).to_string(); self.windows.push(FloatingWindow { @@ -1080,11 +1467,15 @@ impl App { 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); } Err(e) => eprintln!("Kunde inte starta terminal: {}", e), } + id } 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> { + 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; → 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 /// styr mus-tracking (\x1b[?h / \x1b[?l) och uppdaterar `mode` och `encoding`. /// 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> { +fn key_to_bytes(key: KeyEvent, app_cursor: bool, _app_keypad: bool) -> Option> { use KeyCode::*; let bytes: Vec = match key.code { Char(c) => { @@ -1364,14 +1854,21 @@ fn key_to_bytes(key: KeyEvent) -> Option> { } Enter => vec![b'\r'], 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], - 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(), + Insert => b"\x1b[2~".to_vec(), + Up => if app_cursor { b"\x1bOA".to_vec() } else { b"\x1b[A".to_vec() }, + Down => if app_cursor { b"\x1bOB".to_vec() } else { b"\x1b[B".to_vec() }, + Right => if app_cursor { b"\x1bOC".to_vec() } else { b"\x1b[C".to_vec() }, + Left => if app_cursor { b"\x1bOD".to_vec() } else { b"\x1b[D".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(), PageDown => b"\x1b[6~".to_vec(), Delete => b"\x1b[3~".to_vec(), @@ -1387,8 +1884,165 @@ fn key_to_bytes(key: KeyEvent) -> Option> { 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) } +/// 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, +) -> Vec { + 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 { + 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 { + 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> { + 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()) +} + diff --git a/src/client.rs b/src/client.rs new file mode 100644 index 0000000..0dd117b --- /dev/null +++ b/src/client.rs @@ -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::(); + + 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, + 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> { + use crossterm::event::KeyCode::*; + let bytes: Vec = match key.code { + Char(c) => { + if key.modifiers.contains(KeyModifiers::CONTROL) { + match c { + 'a'..='z' => vec![c as u8 - b'a' + 1], + 'A'..='Z' => vec![c as u8 - b'A' + 1], + '[' => vec![0x1b], + '\\' => vec![0x1c], + ']' => vec![0x1d], + _ => return None, + } + } else { + let mut buf = [0u8; 4]; + c.encode_utf8(&mut buf).as_bytes().to_vec() + } + } + Enter => vec![b'\r'], + Backspace => vec![0x7f], + Tab => { + 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) +} diff --git a/src/config.rs b/src/config.rs index 9f64d36..3b85c6a 100644 --- a/src/config.rs +++ b/src/config.rs @@ -3,6 +3,16 @@ use std::fs; #[derive(Deserialize, Debug, Clone, Default)] pub struct Config { + /// Sökväg till bakgrundsbild (Kitty graphics protocol) + #[serde(default)] + pub background_image: Option, + /// 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, + /// Standard-shell för nya terminaler. Om None → $SHELL eller /bin/bash. + #[serde(default)] + pub default_shell: Option, #[serde(default, rename = "panel")] pub panels: Vec, #[serde(default, rename = "keybind")] @@ -134,6 +144,24 @@ fn default_keybinds() -> Vec { ] } +fn default_panels() -> Vec { + 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 { pub fn load(path: &str) -> anyhow::Result { let content = fs::read_to_string(path)?; @@ -157,12 +185,31 @@ impl Config { Err(e) => { eprintln!("Varning: kunde inte ladda {}: {}", path, e); Config { + background_image: None, + terminal_bg_color: None, + default_shell: None, + panels: default_panels(), 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)] diff --git a/src/ipc.rs b/src/ipc.rs new file mode 100644 index 0000000..f395dd2 --- /dev/null +++ b/src/ipc.rs @@ -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, + }, + 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, + }, + SpawnPopup { + message: String, + #[serde(default = "default_buttons")] + buttons: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + request_id: Option, + }, + ListWindows { + #[serde(skip_serializing_if = "Option::is_none")] + request_id: Option, + }, + CloseWindow { + window_id: usize, + #[serde(skip_serializing_if = "Option::is_none")] + request_id: Option, + }, + SetBackground { + /// Sökväg till bildfil, eller tom/null för att ta bort bakgrund + #[serde(default)] + path: Option, + /// Om true, spara ändringen i config.toml + #[serde(default)] + save: bool, + #[serde(skip_serializing_if = "Option::is_none")] + request_id: Option, + }, +} + +fn default_buttons() -> Vec { + 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, + }, + WindowOpened { + id: usize, + #[serde(skip_serializing_if = "Option::is_none")] + request_id: Option, + }, + WindowList { + windows: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + request_id: Option, + }, + Error { + message: String, + #[serde(skip_serializing_if = "Option::is_none")] + request_id: Option, + }, +} + +#[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(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 Deserialize<'de>>(reader: &mut R) -> std::io::Result { + 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 { + (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 { + 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), + } +} diff --git a/src/kitty_gfx.rs b/src/kitty_gfx.rs new file mode 100644 index 0000000..65e39a7 --- /dev/null +++ b/src/kitty_gfx.rs @@ -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 { + 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 = 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;;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;;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 ; ; 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 +} diff --git a/src/main.rs b/src/main.rs index 2686b8c..dc23511 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,67 +1,469 @@ mod app; +mod client; mod config; +mod ipc; +mod kitty_gfx; mod log; mod pty; mod render; +mod server; -use app::App; +use app::{App, AppIpcOut, WindowContent}; use config::Config; use crossterm::{ - event::{self, DisableMouseCapture, EnableMouseCapture}, + event::{self, DisableMouseCapture, EnableMouseCapture, EnableBracketedPaste, DisableBracketedPaste, EnableFocusChange, DisableFocusChange}, execute, terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, }; +use ipc::{ClientMessage, ClientRole, ServerMessage, WindowInfo}; 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 = 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<()> { + 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(); + let socket_path = ipc::default_socket_path(); + let (ipc_tx, ipc_rx) = mpsc::channel::(); + server::start(&socket_path, ipc_tx); + enable_raw_mode()?; let mut stdout = io::stdout(); - execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?; - + execute!(stdout, EnterAlternateScreen, EnableMouseCapture, EnableBracketedPaste, EnableFocusChange)?; let backend = CrosstermBackend::new(stdout); let mut terminal = Terminal::new(backend)?; let config = Config::load_or_default("config.toml"); 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()?; - execute!( - terminal.backend_mut(), - LeaveAlternateScreen, - DisableMouseCapture - )?; + execute!(terminal.backend_mut(), LeaveAlternateScreen, DisableMouseCapture, DisableBracketedPaste, DisableFocusChange)?; terminal.show_cursor()?; - result } -fn run( +fn run_standalone_loop( terminal: &mut Terminal>, app: &mut App, + ipc_rx: mpsc::Receiver, + _socket_path: &str, ) -> io::Result<()> { + // all_clients: client_id → (role, width, height, sender) + let mut all_clients: HashMap)> = + HashMap::new(); + + // Separate TestBackend for rendering to display clients + let mut test_terminal: Option> = None; + + // Kitty bakgrundsbild + let mut bg_state: Option = None; + + let mut last_frame_area = ratatui::layout::Rect::default(); + loop { - // Uppdatera layout från aktuell terminalstorlek let size = terminal.size()?; - app.update_layout(ratatui::layout::Rect::new(0, 0, size.width, size.height)); - // Rendera - terminal.draw(|frame| render::render(frame, app))?; + // ── Kitty bakgrundsbild ────────────────────────────────────────── + 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(); - // 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 = 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))? { app.handle_event(event::read()?); } 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; } } 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 = 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>, + app: &App, + bg_state: &mut Option, + 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::(); + 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)> = + HashMap::new(); + let mut test_terminal: Option> = 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 = 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)>, +) { + 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 = 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)>, +) { + 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 }); + } + } + } +} diff --git a/src/pty.rs b/src/pty.rs index 1760cdd..91f7b3f 100644 --- a/src/pty.rs +++ b/src/pty.rs @@ -11,7 +11,7 @@ pub struct PtyTerminal { } impl PtyTerminal { - pub fn spawn(shell: &str, rows: u16, cols: u16) -> Result<(Self, mpsc::Receiver>)> { + pub fn spawn(shell: &str, rows: u16, cols: u16, extra_env: &[(String, String)]) -> Result<(Self, mpsc::Receiver>)> { let pty_system = NativePtySystem::default(); let pair = pty_system.openpty(PtySize { rows, @@ -23,6 +23,9 @@ impl PtyTerminal { let mut cmd = CommandBuilder::new(shell); cmd.env("TERM", "xterm-256color"); cmd.env("COLORTERM", "truecolor"); + for (key, val) in extra_env { + cmd.env(key, val); + } let child = pair.slave.spawn_command(cmd)?; drop(pair.slave); diff --git a/src/render.rs b/src/render.rs index 07ae75a..77267ca 100644 --- a/src/render.rs +++ b/src/render.rs @@ -4,26 +4,34 @@ use ratatui::{ layout::Rect, style::{Color, Modifier, Style}, text::{Line, Span}, - widgets::{Block, Borders, Clear, Paragraph}, + widgets::{Block, Borders, Clear, Paragraph, Wrap}, Frame, }; pub fn render(frame: &mut Frame, app: &App) { 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( - Block::default().style(Style::default().bg(Color::Indexed(235))), + Block::default().style(Style::default().bg(bg_color)), area, ); // 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 { let resize_hover = app .hovered_resize .filter(|(id, _)| *id == window.id) .map(|(_, e)| e); - render_window(frame, window, app.focused_id, app.hovered_window_close, resize_hover); + render_window(frame, window, app.focused_id, app.hovered_window_close, resize_hover, terminal_bg); } // Paneler – alltid ovanpå fönster @@ -182,6 +190,7 @@ fn render_window( focused_id: Option, hovered_close: Option, resize_hover: Option, + terminal_bg: Color, ) { let full_rect = window.rect().intersection(frame.area()); if full_rect.width < 4 || full_rect.height < 2 { @@ -204,6 +213,7 @@ fn render_window( let title_text = match &window.content { WindowContent::Terminal { title, .. } => format!(" {} ", title), WindowContent::RunDialog { .. } => " Tui-run ".to_string(), + WindowContent::PopupDialog { .. } => " TUI-WM ".to_string(), }; frame.render_widget(Clear, full_rect); @@ -215,7 +225,10 @@ fn render_window( )) .borders(Borders::ALL) .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, ); @@ -244,7 +257,7 @@ fn render_window( match &window.content { WindowContent::Terminal { parser, alive, .. } => { if *alive { - render_terminal(frame, parser.screen(), cr); + render_terminal(frame, parser.screen(), cr, window.selection.as_ref(), cr, terminal_bg); } else { frame.render_widget( Paragraph::new(Span::styled( @@ -258,6 +271,72 @@ fn render_window( WindowContent::RunDialog { input, cursor_pos } => { 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, +) { + 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 = buttons.iter().map(|b| format!("[ {} ]", b)).collect(); + let total_w: u16 = labels.iter().map(|l| l.len() as u16).sum::() + + 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 rows = (area.height as usize).min(screen_rows as usize); let cols = (area.width as usize).min(screen_cols as usize); let (cur_row, cur_col) = screen.cursor_position(); + let show_cursor = !screen.hide_cursor(); for r in 0..rows { let mut spans: Vec = Vec::new(); @@ -346,14 +433,23 @@ fn render_terminal(frame: &mut Frame, screen: &vt100::Screen, area: Rect) { let mut cur_text = String::new(); for c in 0..cols { - let is_cursor = r == cur_row as usize && c == cur_col as usize; + let 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) { Some(cell) => { let s = cell.contents(); let s = if s.is_empty() { " ".to_string() } else { s.to_string() }; - let mut st = Style::default() - .fg(vt_color(cell.fgcolor())) - .bg(vt_color(cell.bgcolor())); + let fg = vt_color(cell.fgcolor()); + let raw_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() { st = st.add_modifier(Modifier::BOLD); } @@ -363,12 +459,23 @@ fn render_terminal(frame: &mut Frame, screen: &vt100::Screen, area: Rect) { if cell.underline() { 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); } (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 { @@ -400,3 +507,30 @@ fn vt_color(c: vt100::Color) -> Color { vt100::Color::Rgb(r, g, b) => Color::Rgb(r, g, b), } } + +fn parse_color(s: &str) -> Option { + 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::() { + 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, + } +} diff --git a/src/server.rs b/src/server.rs new file mode 100644 index 0000000..88b9907 --- /dev/null +++ b/src/server.rs @@ -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, + }, + ClientDisconnected { + client_id: usize, + }, + Message { + client_id: usize, + msg: ClientMessage, + }, +} + +pub fn start(socket_path: &str, event_tx: mpsc::Sender) { + 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, + 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::(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 }); +}