foxygit / screen Log in
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.
+
+![C11](https://img.shields.io/badge/C-11-blue) ![ncurses](https://img.shields.io/badge/TUI-ncurses-green)
+
+## 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