commit 9c21a863fda112629a745cb75f3570e5354067fe Author: MrJensK <jens.se@icloud.com> AuthorDate: Wed May 13 17:46:25 2026 +0200 Commit: MrJensK <jens.se@icloud.com> CommitDate: Wed May 13 17:46:25 2026 +0200 --- Makefile | 29 ++++++++++++++ README.md | 91 +++++++++++++++++++++++++++++++++++++++++++ claude.md | 82 +++++++++++++++++++++++++++++++++++++++ draw.c | 128 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ draw.h | 10 +++++ draw.o | Bin 0 -> 13968 bytes main.c | 121 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ main.o | Bin 0 -> 13440 bytes monitors.c | 127 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ monitors.h | 18 +++++++++ monitors.o | Bin 0 -> 23208 bytes xrantui | Bin 0 -> 43032 bytes 12 files changed, 606 insertions(+) diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..f8ece03 --- /dev/null +++ b/Makefile @@ -0,0 +1,29 @@ +CC = gcc +CFLAGS = -Wall -Wextra -std=c11 -g \ + $(shell pkg-config --cflags ncurses) +LDLIBS = $(shell pkg-config --libs ncurses) -lX11 -lXrandr + +OBJS = main.o monitors.o draw.o + +xrantui: $(OBJS) + $(CC) $(OBJS) -o $@ $(LDLIBS) + +%.o: %.c + $(CC) $(CFLAGS) -c $< -o $@ + +main.o: main.c monitors.h draw.h +monitors.o: monitors.c monitors.h +draw.o: draw.c draw.h monitors.h + +PREFIX ?= /usr/local + +install: xrantui + install -Dm755 xrantui $(DESTDIR)$(PREFIX)/bin/xrantui + +uninstall: + rm -f $(DESTDIR)$(PREFIX)/bin/xrantui + +clean: + rm -f $(OBJS) xrantui + +.PHONY: clean install uninstall diff --git a/README.md b/README.md new file mode 100644 index 0000000..5e98c47 --- /dev/null +++ b/README.md @@ -0,0 +1,91 @@ +# xrantui + +Interactive terminal UI for arranging monitors, built with ncurses and Xrandr. + +  + +## Features + +- Visual scaled-down canvas of your monitor layout +- Move monitors with arrow keys and apply instantly via xrandr +- Snap-to-edge when dragging monitors near each other +- Set primary monitor +- Save layout to `~/.screenlayout/xrantui.sh` with automatic `~/.xprofile` hook for persistence across reboots +- Undo to revert unsaved changes + +## Requirements + +### Runtime + +- X11 with Xrandr +- `xrandr` CLI tool in `$PATH` + +### Build + +```bash +sudo apt install gcc make pkg-config libncurses-dev libxrandr-dev libx11-dev +``` + +## Installation + +```bash +git clone <repo> +cd xrantui +make +sudo make install # installs to /usr/local/bin +``` + +Custom prefix: + +```bash +sudo make install PREFIX=/usr # → /usr/bin/xrantui +make install PREFIX=~/.local # → ~/.local/bin/xrantui (no sudo needed) +``` + +Uninstall: + +```bash +sudo make uninstall +``` + +## Usage + +Run inside a terminal within an X session: + +```bash +xrantui +``` + +If your `DISPLAY` variable is not set: + +```bash +DISPLAY=:0 xrantui +``` + +## Keybindings + +| Key | Action | +|---|---| +| `Tab` | Select next monitor | +| `← ↑ → ↓` | Move selected monitor (40 px/step, snaps to edges) | +| `p` | Set selected monitor as primary | +| `u` | Undo — revert all monitors to positions at startup | +| `Enter` | Apply layout with xrandr | +| `s` | Save layout to `~/.screenlayout/xrantui.sh` | +| `q` | Quit | + +## Persistent layout + +Press `s` to save the current layout. This writes a shell script to +`~/.screenlayout/xrantui.sh` and adds a one-time entry to `~/.xprofile` so the +layout is restored automatically on every X login. + +## Project structure + +``` +├── main.c — event loop and keybindings +├── monitors.c — read/write monitor state via Xrandr +├── monitors.h +├── draw.c — render monitors with ncurses +└── draw.h +``` diff --git a/claude.md b/claude.md new file mode 100644 index 0000000..dbd8078 --- /dev/null +++ b/claude.md @@ -0,0 +1,82 @@ +# xrantui + +TUI för att placera ut skärmar med notcurses och Xrandr i C. + +## Syfte + +Interaktivt verktyg som kör i terminalen (inom X). Användaren väljer skärm, flyttar den med piltangenter och applicerar konfigurationen via xrandr. + +## Stack + +- **Språk:** C (C11) +- **TUI:** notcurses +- **Skärminformation:** libxrandr / libx11 +- **Build:** gcc + pkg-config + +## Bygg + +```bash +gcc main.c -o xrantui \ + $(pkg-config --libs --cflags notcurses) \ + -lX11 -lXrandr +``` + +## Projektstruktur + +``` +xrantui/ +├── CLAUDE.md +├── main.c # entry point, event loop +├── monitors.c # hämta/sätta skärminfo via Xrandr +├── monitors.h +├── draw.c # rita skärmar med notcurses +└── draw.h +``` + +## Datastruktur + +```c +typedef struct { + char name[64]; // output-namn, t.ex. "HDMI-1" + int x, y; // position i pixlar + int w, h; // upplösning i pixlar + int connected; // RR_Connected +} Monitor; +``` + +## Keybindings + +| Tangent | Funktion | +|---|---| +| Tab | Växla vald skärm | +| Piltangenter | Flytta skärm (ett steg = scale px) | +| Enter | Applicera med xrandr | +| q | Avsluta | + +## Skalfaktor + +Variabeln `scale` styr hur många pixlar en terminalcell representerar. +Standard: `40`. Justera om skärmarna inte får plats eller blir för små. + +## Applicering + +Applicerar via `system()` med ett genererat xrandr-kommando: + +```bash +xrandr --output HDMI-1 --pos 320x1080 --auto +``` + +## Beroenden (Debian) + +```bash +sudo apt install libnotcurses-dev libnotcurses-core-dev \ + libx11-dev libxrandr-dev +``` + +## Känt att bygga vidare på + +- Snapping — magnetism när skärmar är nära varandra +- Batch-apply — ändra alla skärmar innan xrandr anropas +- Spara ut `monitors.sh` istället för att köra direkt +- Visa upplösning och Hz inuti skärmrektangeln +- Stöd för att sätta primärskärm (`--primary`) \ No newline at end of file diff --git a/draw.c b/draw.c new file mode 100644 index 0000000..9c2c07b --- /dev/null +++ b/draw.c @@ -0,0 +1,128 @@ +#include "draw.h" +#include <ncurses.h> +#include <string.h> +#include <stdio.h> + +#define COLOR_NORMAL 1 +#define COLOR_SEL 2 +#define COLOR_STATUS 3 +#define COLOR_PRIMARY 4 /* primärskärm, ej vald */ +#define COLOR_PRISEL 5 /* primärskärm, vald */ + +void draw_init_colors(void) { + start_color(); + use_default_colors(); + init_pair(COLOR_NORMAL, COLOR_WHITE, COLOR_BLUE); + init_pair(COLOR_SEL, COLOR_YELLOW, COLOR_GREEN); + init_pair(COLOR_STATUS, COLOR_WHITE, COLOR_BLACK); + init_pair(COLOR_PRIMARY, COLOR_CYAN, COLOR_BLUE); + init_pair(COLOR_PRISEL, COLOR_CYAN, COLOR_GREEN); +} + +static int pair_for(int selected, int primary) { + if (selected && primary) return COLOR_PRISEL; + if (selected) return COLOR_SEL; + if (primary) return COLOR_PRIMARY; + return COLOR_NORMAL; +} + +static void draw_box(int y, int x, int h, int w, int sel, int primary) { + int rows, cols; + getmaxyx(stdscr, rows, cols); + if (y >= rows || x >= cols || h < 2 || w < 2) return; + if (y + h > rows) h = rows - y; + if (x + w > cols) w = cols - x; + if (h < 2 || w < 2) return; + + int pair = pair_for(sel, primary); + attron(COLOR_PAIR(pair)); + + mvaddch(y, x, ACS_ULCORNER); + for (int c = 1; c < w - 1; c++) addch(ACS_HLINE); + mvaddch(y, x + w - 1, ACS_URCORNER); + + for (int r = 1; r < h - 1; r++) { + mvaddch(y + r, x, ACS_VLINE); + for (int c = 1; c < w - 1; c++) mvaddch(y + r, x + c, ' '); + mvaddch(y + r, x + w - 1, ACS_VLINE); + } + + mvaddch(y + h - 1, x, ACS_LLCORNER); + for (int c = 1; c < w - 1; c++) mvaddch(y + h - 1, x + c, ACS_HLINE); + mvaddch(y + h - 1, x + w - 1, ACS_LRCORNER); + + /* primärmarkör i övre vänstra hörnet av interiören */ + if (primary && w > 2) + mvaddstr(y + 1, x + 1, "P"); + + attroff(COLOR_PAIR(pair)); +} + +static void draw_centered(int y, int x_start, int width, const char *text, int pair) { + int rows, cols; + getmaxyx(stdscr, rows, cols); + (void)cols; + if (y < 0 || y >= rows) return; + + int len = (int)strlen(text); + if (len > width) len = width; + int lx = x_start + (width - len) / 2; + + char buf[66]; + strncpy(buf, text, (size_t)len); + buf[len] = '\0'; + + attron(COLOR_PAIR(pair)); + mvaddstr(y, lx, buf); + attroff(COLOR_PAIR(pair)); +} + +void draw_monitors(Monitor *mons, int count, int selected, int scale, + const char *msg) { + int rows, cols; + getmaxyx(stdscr, rows, cols); + clear(); + + for (int i = 0; i < count; i++) { + Monitor *m = &mons[i]; + + int tx = m->x / scale; + int ty = m->y / (scale * 2); + int tw = (m->w > 0) ? m->w / scale : 10; + int th = (m->h > 0) ? m->h / (scale * 2) : 4; + if (tw < 10) tw = 10; + if (th < 4) th = 4; + + draw_box(ty, tx, th, tw, i == selected, m->primary); + + int inner_w = tw - 2; + int pair = pair_for(i == selected, m->primary); + + /* namn — centrerat på övre halvan av interiören */ + int name_row = ty + 1 + (th - 2) / 3; + draw_centered(name_row, tx + 1, inner_w, m->name, pair); + + /* upplösning — en rad under namnet om plats finns */ + if (th >= 4 && m->w > 0 && m->h > 0) { + char res[32]; + snprintf(res, sizeof(res), "%dx%d", m->w, m->h); + draw_centered(name_row + 1, tx + 1, inner_w, res, pair); + } + } + + /* statusrad */ + attron(COLOR_PAIR(COLOR_STATUS)); + mvhline(rows - 1, 0, ' ', cols); + if (msg) { + mvprintw(rows - 1, 0, " %s", msg); + } else { + mvprintw(rows - 1, 0, + " Tab:välj pil:flytta p:primär u:ångra" + " Enter:applicera s:spara q:avsluta" + " | %s @ %d,%d%s", + mons[selected].name, + mons[selected].x, mons[selected].y, + mons[selected].primary ? " [P]" : ""); + } + attroff(COLOR_PAIR(COLOR_STATUS)); +} diff --git a/draw.h b/draw.h new file mode 100644 index 0000000..c7ee909 --- /dev/null +++ b/draw.h @@ -0,0 +1,10 @@ +#ifndef DRAW_H +#define DRAW_H + +#include "monitors.h" + +void draw_init_colors(void); +void draw_monitors(Monitor *mons, int count, int selected, int scale, + const char *msg); /* msg visas i statusraden; NULL = normal hjälptext */ + +#endif diff --git a/draw.o b/draw.o new file mode 100644 index 0000000..9bda0bb Binary files /dev/null and b/draw.o differ diff --git a/main.c b/main.c new file mode 100644 index 0000000..93eacd2 --- /dev/null +++ b/main.c @@ -0,0 +1,121 @@ +#include <ncurses.h> +#include <stdio.h> +#include <stdlib.h> +#include <string.h> +#include "monitors.h" +#include "draw.h" + +#define SCALE 40 +#define SNAP_DIST SCALE /* pixlar — en knapptryckning */ + +static void snap(Monitor *mons, int count, int idx) { + Monitor *m = &mons[idx]; + for (int i = 0; i < count; i++) { + if (i == idx) continue; + Monitor *o = &mons[i]; + int mr = m->x + m->w, or_ = o->x + o->w; + int mb = m->y + m->h, ob = o->y + o->h; + + /* horisontell snap: vänster mot höger och tvärtom */ + if (abs(m->x - or_) <= SNAP_DIST) m->x = or_; + else if (abs(mr - o->x) <= SNAP_DIST) m->x = o->x - m->w; + + /* vertikal snap: topp mot botten och tvärtom */ + if (abs(m->y - ob) <= SNAP_DIST) m->y = ob; + else if (abs(mb - o->y) <= SNAP_DIST) m->y = o->y - m->h; + } + if (m->x < 0) m->x = 0; + if (m->y < 0) m->y = 0; +} + +int main(void) { + Monitor mons[MAX_MONITORS]; + int nmons = monitors_get(mons, MAX_MONITORS); + if (nmons == 0) { + fprintf(stderr, "xrantui: inga aktiva skärmar hittades\n"); + return 1; + } + + /* spara ursprungspositioner för ångra */ + Monitor orig[MAX_MONITORS]; + memcpy(orig, mons, sizeof(Monitor) * (size_t)nmons); + + initscr(); + cbreak(); + noecho(); + keypad(stdscr, TRUE); + curs_set(0); + draw_init_colors(); + + int selected = 0; + const char *msg = NULL; + + for (;;) { + draw_monitors(mons, nmons, selected, SCALE, msg); + refresh(); + msg = NULL; + + int ch = getch(); + switch (ch) { + case 'q': + case 'Q': + goto done; + + case '\t': + selected = (selected + 1) % nmons; + break; + + case KEY_UP: + mons[selected].y -= SCALE; + if (mons[selected].y < 0) mons[selected].y = 0; + snap(mons, nmons, selected); + break; + case KEY_DOWN: + mons[selected].y += SCALE; + snap(mons, nmons, selected); + break; + case KEY_LEFT: + mons[selected].x -= SCALE; + if (mons[selected].x < 0) mons[selected].x = 0; + snap(mons, nmons, selected); + break; + case KEY_RIGHT: + mons[selected].x += SCALE; + snap(mons, nmons, selected); + break; + + case 'p': + case 'P': + /* sätt vald skärm som primär, rensa övriga */ + for (int i = 0; i < nmons; i++) + mons[i].primary = (i == selected) ? 1 : 0; + msg = "Primärskärm satt — tryck Enter för att applicera"; + break; + + case 'u': + case 'U': + memcpy(mons, orig, sizeof(Monitor) * (size_t)nmons); + msg = "Återställt till ursprungspositioner"; + break; + + case KEY_ENTER: + case '\n': + case '\r': + monitors_apply(mons, nmons); + msg = "Applicerat"; + break; + + case 's': + case 'S': + if (monitors_save(mons, nmons) == 0) + msg = "Sparat till ~/.screenlayout/xrantui.sh och ~/.xprofile"; + else + msg = "Fel: kunde inte spara"; + break; + } + } + +done: + endwin(); + return 0; +} diff --git a/main.o b/main.o new file mode 100644 index 0000000..79de6c7 Binary files /dev/null and b/main.o differ diff --git a/monitors.c b/monitors.c new file mode 100644 index 0000000..c126213 --- /dev/null +++ b/monitors.c @@ -0,0 +1,127 @@ +#include "monitors.h" +#include <X11/Xlib.h> +#include <X11/extensions/Xrandr.h> +#include <stdio.h> +#include <string.h> +#include <stdlib.h> +#include <sys/stat.h> + +int monitors_get(Monitor *mons, int max) { + Display *dpy = XOpenDisplay(NULL); + if (!dpy) { + fprintf(stderr, "Kan inte öppna display\n"); + return 0; + } + + Window root = DefaultRootWindow(dpy); + XRRScreenResources *res = XRRGetScreenResourcesCurrent(dpy, root); + if (!res) { + XCloseDisplay(dpy); + return 0; + } + + RROutput primary_out = XRRGetOutputPrimary(dpy, root); + + int count = 0; + for (int i = 0; i < res->noutput && count < max; i++) { + XRROutputInfo *out = XRRGetOutputInfo(dpy, res, res->outputs[i]); + if (!out) continue; + + if (out->connection == RR_Connected && out->crtc) { + Monitor *m = &mons[count]; + strncpy(m->name, out->name, sizeof(m->name) - 1); + m->name[sizeof(m->name) - 1] = '\0'; + m->connected = 1; + m->primary = (res->outputs[i] == primary_out) ? 1 : 0; + m->x = m->y = m->w = m->h = 0; + + XRRCrtcInfo *crtc = XRRGetCrtcInfo(dpy, res, out->crtc); + if (crtc) { + m->x = (int)crtc->x; + m->y = (int)crtc->y; + m->w = (int)crtc->width; + m->h = (int)crtc->height; + XRRFreeCrtcInfo(crtc); + } + count++; + } + XRRFreeOutputInfo(out); + } + + XRRFreeScreenResources(res); + XCloseDisplay(dpy); + return count; +} + +static void sanitize_name(char *name) { + for (char *p = name; *p; p++) { + if (!(*p >= 'A' && *p <= 'Z') && !(*p >= 'a' && *p <= 'z') && + !(*p >= '0' && *p <= '9') && *p != '-' && *p != '_') + *p = '_'; + } +} + +static void build_xrandr_args(Monitor *mons, int count, char *buf, size_t bufsz) { + int pos = snprintf(buf, bufsz, "xrandr"); + for (int i = 0; i < count && pos < (int)bufsz - 80; i++) { + sanitize_name(mons[i].name); + pos += snprintf(buf + pos, bufsz - (size_t)pos, + " --output %s --pos %dx%d --auto%s", + mons[i].name, mons[i].x, mons[i].y, + mons[i].primary ? " --primary" : ""); + } +} + +void monitors_apply(Monitor *mons, int count) { + char cmd[4096]; + build_xrandr_args(mons, count, cmd, sizeof(cmd)); + system(cmd); +} + +int monitors_save(Monitor *mons, int count) { + const char *home = getenv("HOME"); + if (!home) return -1; + + char dir[512]; + snprintf(dir, sizeof(dir), "%s/.screenlayout", home); + mkdir(dir, 0755); + + char path[560]; + snprintf(path, sizeof(path), "%s/xrantui.sh", dir); + FILE *f = fopen(path, "w"); + if (!f) return -1; + + fprintf(f, "#!/bin/sh\n"); + for (int i = 0; i < count; i++) { + sanitize_name(mons[i].name); + fprintf(f, "xrandr --output %s --pos %dx%d --auto%s\n", + mons[i].name, mons[i].x, mons[i].y, + mons[i].primary ? " --primary" : ""); + } + fclose(f); + chmod(path, 0755); + + /* lägg till anrop i ~/.xprofile om det inte redan finns */ + char xprofile[512]; + snprintf(xprofile, sizeof(xprofile), "%s/.xprofile", home); + + int found = 0; + f = fopen(xprofile, "r"); + if (f) { + char line[256]; + while (fgets(line, sizeof(line), f)) { + if (strstr(line, "xrantui.sh")) { found = 1; break; } + } + fclose(f); + } + if (!found) { + f = fopen(xprofile, "a"); + if (f) { + fprintf(f, "\n[ -f \"$HOME/.screenlayout/xrantui.sh\" ]" + " && sh \"$HOME/.screenlayout/xrantui.sh\"\n"); + fclose(f); + } + } + + return 0; +} diff --git a/monitors.h b/monitors.h new file mode 100644 index 0000000..8fd60e6 --- /dev/null +++ b/monitors.h @@ -0,0 +1,18 @@ +#ifndef MONITORS_H +#define MONITORS_H + +#define MAX_MONITORS 16 + +typedef struct { + char name[64]; + int x, y; + int w, h; + int connected; + int primary; +} Monitor; + +int monitors_get(Monitor *mons, int max); +void monitors_apply(Monitor *mons, int count); +int monitors_save(Monitor *mons, int count); /* returnerar 0 vid lyckad sparning */ + +#endif diff --git a/monitors.o b/monitors.o new file mode 100644 index 0000000..67144b3 Binary files /dev/null and b/monitors.o differ diff --git a/xrantui b/xrantui new file mode 100755 index 0000000..98c1744 Binary files /dev/null and b/xrantui differ