diff --git a/.github/workflows/build-iso.yml b/.github/workflows/build-iso.yml new file mode 100644 index 0000000..d23a53c --- /dev/null +++ b/.github/workflows/build-iso.yml @@ -0,0 +1,108 @@ +name: Build OrionOS ISO + +on: + push: + tags: + - 'v*' + workflow_dispatch: + inputs: + release_name: + description: 'Release label (e.g. v0.1-mintaka)' + required: false + default: 'nightly' + +permissions: + contents: write + +jobs: + build: + runs-on: ubuntu-latest + timeout-minutes: 90 + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Install dependencies on host + run: | + sudo apt-get update -qq + sudo apt-get install -y --no-install-recommends \ + build-essential \ + libx11-dev libxft-dev libfontconfig1-dev \ + libimlib2-dev \ + pkg-config \ + xorg-dev + + - name: Build OrionOS shell components + run: | + make skykit rigel quill lens || true + make wm deskbar launcher installer nebula orionpkg || true + + - name: Build ISO (Docker) + run: | + chmod +x build-iso.sh + ./build-iso.sh + env: + DOCKER_BUILDKIT: 1 + + - name: Verify ISO was created + run: | + ls -lh dist/*.iso + sha256sum dist/*.iso > dist/SHA256SUMS + + - name: Upload ISO as artifact + uses: actions/upload-artifact@v4 + with: + name: orionos-iso-${{ github.run_number }} + path: | + dist/*.iso + dist/SHA256SUMS + retention-days: 30 + + - name: Create GitHub Release + if: startsWith(github.ref, 'refs/tags/') + uses: softprops/action-gh-release@v2 + with: + name: OrionOS ${{ github.ref_name }} + body: | + ## OrionOS ${{ github.ref_name }} + + Bootable live ISO. Burn to USB with: + ``` + sudo dd if=orionos.iso of=/dev/sdX bs=4M status=progress && sync + ``` + Or use [Balena Etcher](https://etcher.balena.io/). + + ### What's included + - OrionWM — tiling/floating window manager + - Rigel — native file manager (right-click menu, search) + - Quill — native text editor + - Lens — native image viewer + - OrionDeskbar — application bar + - Nebula — system settings + - OrionPkg — package manager GUI + - OrionInstall — graphical installer + + ### Verify + ``` + sha256sum -c SHA256SUMS + ``` + files: | + dist/*.iso + dist/SHA256SUMS + draft: false + prerelease: ${{ contains(github.ref_name, 'alpha') || contains(github.ref_name, 'beta') || contains(github.ref_name, 'rc') }} + + # Separate job: build and push Docker image used by the ISO builder + docker: + runs-on: ubuntu-latest + if: github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/') + steps: + - uses: actions/checkout@v4 + - name: Build ISO builder image + run: docker build -t orionos-builder:latest iso/docker/ + - name: Smoke test builder + run: docker run --rm orionos-builder:latest echo "Builder image OK" diff --git a/.gitignore b/.gitignore index bf9b303..db97ed4 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,8 @@ orionpkg/orionpkg skykit/libskykit.a skykit/skykit.o rigel/rigel +quill/quill +lens/lens installer/orioninstall installer/orioninstall-gui launcher/orionlauncher diff --git a/Makefile b/Makefile index 4c56309..bdc4333 100644 --- a/Makefile +++ b/Makefile @@ -5,9 +5,9 @@ # make clean — remove build artifacts # make iso — build the bootable ISO (requires Arch Linux + archiso) -.PHONY: all wm deskbar launcher installer nebula orionpkg skykit rigel clean install iso +.PHONY: all wm deskbar launcher installer nebula orionpkg skykit rigel quill lens clean install iso -all: wm deskbar launcher installer nebula orionpkg skykit rigel +all: wm deskbar launcher installer nebula orionpkg skykit rigel quill lens wm: $(MAKE) -C wm @@ -33,6 +33,12 @@ skykit: rigel: $(MAKE) -C rigel +quill: + $(MAKE) -C quill + +lens: + $(MAKE) -C lens + clean: $(MAKE) -C wm clean $(MAKE) -C deskbar clean @@ -42,6 +48,8 @@ clean: $(MAKE) -C orionpkg clean $(MAKE) -C skykit clean $(MAKE) -C rigel clean + $(MAKE) -C quill clean + $(MAKE) -C lens clean install: all # binaries @@ -52,6 +60,8 @@ install: all install -Dm755 nebula/nebula /usr/local/bin/nebula install -Dm755 orionpkg/orionpkg /usr/local/bin/orionpkg install -Dm755 rigel/rigel /usr/local/bin/rigel + install -Dm755 quill/quill /usr/local/bin/quill + install -Dm755 lens/lens /usr/local/bin/lens # GTK theme mkdir -p /usr/share/themes cp -r themes/OrionOS /usr/share/themes/ diff --git a/iso/docker/entrypoint.sh b/iso/docker/entrypoint.sh index 06f325a..137a47d 100755 --- a/iso/docker/entrypoint.sh +++ b/iso/docker/entrypoint.sh @@ -38,6 +38,7 @@ ttf-liberation noto-fonts libxft fontconfig +imlib2 xf86-video-intel xf86-video-vesa mesa @@ -242,6 +243,14 @@ fi if [[ -f "$SRC/rigel/rigel" ]]; then install -m755 "$SRC/rigel/rigel" "$AIROOTFS/usr/local/bin/rigel" fi +# Quill text editor (compiled binary) +if [[ -f "$SRC/quill/quill" ]]; then + install -m755 "$SRC/quill/quill" "$AIROOTFS/usr/local/bin/quill" +fi +# Lens image viewer (compiled binary) +if [[ -f "$SRC/lens/lens" ]]; then + install -m755 "$SRC/lens/lens" "$AIROOTFS/usr/local/bin/lens" +fi # ── 5. GTK theme ────────────────────────────────────────────────────── echo ">>> Installing GTK theme..." diff --git a/lens/Makefile b/lens/Makefile new file mode 100644 index 0000000..52a8017 --- /dev/null +++ b/lens/Makefile @@ -0,0 +1,12 @@ +CC = gcc +CFLAGS = -O2 -Wall -Wextra \ + $(shell pkg-config --cflags fontconfig xft x11 imlib2) +LIBS = $(shell pkg-config --libs fontconfig xft x11 imlib2) -lm + +lens: lens.c + $(CC) $(CFLAGS) -o lens lens.c $(LIBS) + +clean: + rm -f lens + +.PHONY: clean diff --git a/lens/lens.c b/lens/lens.c new file mode 100644 index 0000000..13f6a31 --- /dev/null +++ b/lens/lens.c @@ -0,0 +1,503 @@ +/* lens.c — OrionOS Image Viewer + * Named for the optical lens. Native C/Xlib + Imlib2. + * No GTK. Uses imlib2 for image decoding (JPEG, PNG, GIF, BMP, TIFF, WEBP). + * + * Keybindings: + * n / Right Next image in directory + * p / Left Previous image + * +/- Zoom in / out + * 0 Actual size (100%) + * f Fit to window (default) + * r Rotate 90° clockwise + * Esc / q Quit + * F11 Fullscreen toggle + * + * Mouse: + * Scroll Zoom in/out + * Drag Pan when zoomed in + */ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#define WIN_W 900 +#define WIN_H 640 +#define STAT_H 28 + +/* palette */ +#define C_BG 0x090C12UL +#define C_STAT 0x0D1117UL +#define C_BORDER 0x1A2035UL +#define C_TEXT 0xCDD6F4UL +#define C_DIM 0x4A5880UL + +/* ── App state ────────────────────────────────────────────── */ +typedef struct { + Display *dpy; + int scr; + Window win; + GC gc; + Visual *vis; + Colormap cmap; + int depth; + int win_w, win_h; + Atom wm_del; + Atom net_wm_state; + Atom net_wm_fullscreen; + + /* image */ + Imlib_Image img; + int img_w, img_h; + int rotation; /* 0,90,180,270 */ + + /* file list */ + char **files; + int nfiles; + int cur_file; + + /* view */ + double zoom; + int fit; /* 1 = fit to window */ + int pan_x, pan_y; + int drag; + int drag_sx, drag_sy; + int drag_px, drag_py; + + /* Xft for status text */ + XftFont *font; + + int fullscreen; + int running; + int dirty; +} Lens; + +static Lens L; + +/* ── Helpers ──────────────────────────────────────────────── */ +static unsigned long px_alloc(unsigned long rgb) { + XColor c; + c.red = ((rgb >> 16) & 0xFF) * 257; + c.green = ((rgb >> 8) & 0xFF) * 257; + c.blue = ( rgb & 0xFF) * 257; + c.flags = DoRed | DoGreen | DoBlue; + XAllocColor(L.dpy, L.cmap, &c); + return c.pixel; +} + +static void fill(int x, int y, int w, int h, unsigned long rgb) { + XSetForeground(L.dpy, L.gc, px_alloc(rgb)); + XFillRectangle(L.dpy, L.win, L.gc, x, y, w, h); +} + +static void hline(int x, int y, int w, unsigned long rgb) { + XSetForeground(L.dpy, L.gc, px_alloc(rgb)); + XDrawLine(L.dpy, L.win, L.gc, x, y, x + w - 1, y); +} + +static int baseline(XftFont *f, int y, int h) { + return y + (h + f->ascent - f->descent) / 2; +} + +static void xft_str(XftFont *f, unsigned long rgb, int x, int y, + const char *s) { + XftDraw *xd = XftDrawCreate(L.dpy, L.win, L.vis, L.cmap); + XRenderColor rc = { + .red = ((rgb >> 16) & 0xFF) * 257, + .green = ((rgb >> 8) & 0xFF) * 257, + .blue = ( rgb & 0xFF) * 257, + .alpha = 0xFFFF + }; + XftColor col; + XftColorAllocValue(L.dpy, L.vis, L.cmap, &rc, &col); + XftDrawStringUtf8(xd, &col, f, x, y, + (const FcChar8 *)s, (int)strlen(s)); + XftColorFree(L.dpy, L.vis, L.cmap, &col); + XftDrawDestroy(xd); +} + +static int img_extw(void) { return (L.rotation % 180) ? L.img_h : L.img_w; } +static int img_exth(void) { return (L.rotation % 180) ? L.img_w : L.img_h; } + +/* ── Image loading ────────────────────────────────────────── */ +static const char *image_exts[] = { + ".jpg",".jpeg",".png",".gif",".bmp",".tiff",".tif", + ".webp",".pnm",".pgm",".ppm",".xpm",".ico",".svg", NULL +}; + +static int is_image(const char *name) { + const char *dot = strrchr(name, '.'); + if (!dot) return 0; + for (int i = 0; image_exts[i]; i++) { + if (strcasecmp(dot, image_exts[i]) == 0) return 1; + } + return 0; +} + +static int cmp_str(const void *a, const void *b) { + return strcmp(*(const char **)a, *(const char **)b); +} + +static void load_dir_list(const char *dir_path) { + DIR *d = opendir(dir_path); + if (!d) return; + + /* free old list */ + for (int i = 0; i < L.nfiles; i++) free(L.files[i]); + free(L.files); + L.files = NULL; + L.nfiles = 0; + + int cap = 256; + L.files = malloc(cap * sizeof(char *)); + + struct dirent *de; + while ((de = readdir(d))) { + if (!is_image(de->d_name)) continue; + if (L.nfiles >= cap) { + cap *= 2; + L.files = realloc(L.files, cap * sizeof(char *)); + } + char full[4096]; + snprintf(full, sizeof(full), "%s/%s", dir_path, de->d_name); + L.files[L.nfiles++] = strdup(full); + } + closedir(d); + qsort(L.files, L.nfiles, sizeof(char *), cmp_str); +} + +static void load_image(const char *path) { + if (L.img) { imlib_context_set_image(L.img); imlib_free_image(); L.img = NULL; } + L.img = imlib_load_image(path); + if (!L.img) return; + imlib_context_set_image(L.img); + L.img_w = imlib_image_get_width(); + L.img_h = imlib_image_get_height(); + L.rotation = 0; + L.fit = 1; + L.pan_x = 0; + L.pan_y = 0; + + /* compute fit zoom */ + int view_h = L.win_h - STAT_H; + double zw = (double)L.win_w / L.img_w; + double zh = (double)view_h / L.img_h; + L.zoom = (zw < zh) ? zw : zh; + if (L.zoom > 1.0) L.zoom = 1.0; /* never upscale for fit */ + + /* set window title */ + const char *name = strrchr(path, '/'); + name = name ? name + 1 : path; + char title[4096]; + snprintf(title, sizeof(title), "Lens — %s", name); + XStoreName(L.dpy, L.win, title); +} + +static void nav_delta(int delta) { + if (L.nfiles == 0) return; + L.cur_file = (L.cur_file + delta + L.nfiles) % L.nfiles; + load_image(L.files[L.cur_file]); + L.dirty = 1; +} + +/* ── Rendering ────────────────────────────────────────────── */ +static void render(void) { + int view_h = L.win_h - STAT_H; + + /* background */ + fill(0, 0, L.win_w, view_h, C_BG); + + if (!L.img) { + fill(0, view_h, L.win_w, STAT_H, C_STAT); + hline(0, view_h, L.win_w, C_BORDER); + xft_str(L.font, C_DIM, + L.win_w / 2 - 60, baseline(L.font, view_h, STAT_H), + "No image loaded"); + return; + } + + imlib_context_set_image(L.img); + imlib_context_set_display(L.dpy); + imlib_context_set_visual(L.vis); + imlib_context_set_colormap(L.cmap); + imlib_context_set_drawable(L.win); + + /* compute displayed size */ + int ew = img_extw(), eh = img_exth(); + int dw, dh; + + if (L.fit) { + double zw = (double)L.win_w / ew; + double zh = (double)view_h / eh; + L.zoom = (zw < zh) ? zw : zh; + if (L.zoom > 8.0) L.zoom = 8.0; + dw = (int)(ew * L.zoom); + dh = (int)(eh * L.zoom); + L.pan_x = (L.win_w - dw) / 2; + L.pan_y = (view_h - dh) / 2; + } else { + dw = (int)(ew * L.zoom); + dh = (int)(eh * L.zoom); + } + + /* clamp pan */ + if (!L.fit) { + int min_px = L.win_w - dw; + int min_py = view_h - dh; + if (L.pan_x > 0) L.pan_x = 0; + if (L.pan_y > 0) L.pan_y = 0; + if (L.pan_x < min_px) L.pan_x = min_px; + if (L.pan_y < min_py) L.pan_y = min_py; + /* center if smaller than viewport */ + if (dw < L.win_w) L.pan_x = (L.win_w - dw) / 2; + if (dh < view_h) L.pan_y = (view_h - dh) / 2; + } + + /* create rotated + scaled image for display */ + Imlib_Image rotated = L.img; + int free_rotated = 0; + + if (L.rotation != 0) { + imlib_context_set_image(L.img); + rotated = imlib_create_rotated_image(L.rotation * M_PI / 180.0); + if (rotated) free_rotated = 1; + else rotated = L.img; + } + + imlib_context_set_image(rotated); + int rw = imlib_image_get_width(); + int rh = imlib_image_get_height(); + Imlib_Image scaled = imlib_create_cropped_scaled_image(0, 0, rw, rh, dw, dh); + if (free_rotated) { imlib_context_set_image(rotated); imlib_free_image(); } + + if (scaled) { + imlib_context_set_image(scaled); + imlib_context_set_drawable(L.win); + imlib_render_image_on_drawable(L.pan_x, L.pan_y); + imlib_free_image(); + } + + /* status bar */ + fill(0, view_h, L.win_w, STAT_H, C_STAT); + hline(0, view_h, L.win_w, C_BORDER); + + char stat[512]; + const char *name = L.nfiles > 0 ? strrchr(L.files[L.cur_file], '/') : NULL; + name = name ? name + 1 : "?"; + snprintf(stat, sizeof(stat), + "%s %d × %d %d%% [%d / %d]", + name, ew, eh, (int)(L.zoom * 100), + L.nfiles > 0 ? L.cur_file + 1 : 0, L.nfiles); + xft_str(L.font, C_TEXT, 12, + baseline(L.font, view_h, STAT_H), stat); + + const char *hint = "n/p next/prev +/- zoom f fit r rotate q quit"; + XGlyphInfo ext; + XftTextExtentsUtf8(L.dpy, L.font, + (const FcChar8 *)hint, (int)strlen(hint), &ext); + xft_str(L.font, C_DIM, L.win_w - ext.xOff - 10, + baseline(L.font, view_h, STAT_H), hint); + + XFlush(L.dpy); +} + +/* ── Key handling ─────────────────────────────────────────── */ +static void handle_key(XKeyEvent *ev) { + char text[16] = {0}; + KeySym ksym = 0; + XLookupString(ev, text, sizeof(text)-1, &ksym, NULL); + + int view_h = L.win_h - STAT_H; + + switch (ksym) { + case XK_q: case XK_Escape: L.running = 0; return; + + case XK_n: case XK_Right: nav_delta(+1); return; + case XK_p: case XK_Left: nav_delta(-1); return; + + case XK_plus: case XK_equal: + L.fit = 0; + L.zoom *= 1.25; + if (L.zoom > 32.0) L.zoom = 32.0; + L.dirty = 1; return; + case XK_minus: + L.fit = 0; + L.zoom /= 1.25; + if (L.zoom < 0.02) L.zoom = 0.02; + L.dirty = 1; return; + case XK_0: + L.fit = 0; + L.zoom = 1.0; + L.pan_x = (L.win_w - img_extw()) / 2; + L.pan_y = (view_h - img_exth()) / 2; + L.dirty = 1; return; + case XK_f: + L.fit = !L.fit; + L.dirty = 1; return; + case XK_r: + L.rotation = (L.rotation + 90) % 360; + L.dirty = 1; return; + case XK_F11: + L.fullscreen = !L.fullscreen; + { + XEvent xev = {0}; + xev.type = ClientMessage; + xev.xclient.window = L.win; + xev.xclient.message_type = L.net_wm_state; + xev.xclient.format = 32; + xev.xclient.data.l[0] = L.fullscreen ? 1 : 0; + xev.xclient.data.l[1] = (long)L.net_wm_fullscreen; + XSendEvent(L.dpy, DefaultRootWindow(L.dpy), False, + SubstructureNotifyMask | SubstructureRedirectMask, &xev); + } + L.dirty = 1; return; + } +} + +/* ── Main ─────────────────────────────────────────────────── */ +int main(int argc, char **argv) { + if (argc < 2) { + fprintf(stderr, "usage: lens \n"); + return 1; + } + + L.dpy = XOpenDisplay(NULL); + if (!L.dpy) { fputs("lens: cannot open display\n", stderr); return 1; } + L.scr = DefaultScreen(L.dpy); + L.vis = DefaultVisual(L.dpy, L.scr); + L.cmap = DefaultColormap(L.dpy, L.scr); + L.depth = DefaultDepth(L.dpy, L.scr); + L.win_w = WIN_W; + L.win_h = WIN_H; + + L.font = XftFontOpenName(L.dpy, L.scr, "Sans:size=9"); + if (!L.font) L.font = XftFontOpenName(L.dpy, L.scr, "fixed"); + + XSetWindowAttributes swa = { + .background_pixel = px_alloc(C_BG), + .event_mask = ExposureMask | KeyPressMask + | ButtonPressMask | ButtonReleaseMask + | PointerMotionMask | StructureNotifyMask + }; + L.win = XCreateWindow(L.dpy, RootWindow(L.dpy, L.scr), + 80, 60, WIN_W, WIN_H, 0, + L.depth, InputOutput, L.vis, + CWBackPixel | CWEventMask, &swa); + + L.gc = XCreateGC(L.dpy, L.win, 0, NULL); + + L.wm_del = XInternAtom(L.dpy, "WM_DELETE_WINDOW", False); + L.net_wm_state = XInternAtom(L.dpy, "_NET_WM_STATE", False); + L.net_wm_fullscreen = XInternAtom(L.dpy, "_NET_WM_STATE_FULLSCREEN", False); + XSetWMProtocols(L.dpy, L.win, &L.wm_del, 1); + + XClassHint *ch = XAllocClassHint(); + ch->res_name = "lens"; + ch->res_class = "Lens"; + XSetClassHint(L.dpy, L.win, ch); + XFree(ch); + + /* imlib2 setup */ + imlib_context_set_display(L.dpy); + imlib_context_set_visual(L.vis); + imlib_context_set_colormap(L.cmap); + imlib_context_set_drawable(L.win); + imlib_set_cache_size(32 * 1024 * 1024); + + /* build sibling file list from directory of argv[1] */ + char abs_path[4096]; + if (!realpath(argv[1], abs_path)) { + snprintf(abs_path, sizeof(abs_path), "%s", argv[1]); + } + char dir_buf[4096]; + snprintf(dir_buf, sizeof(dir_buf), "%s", abs_path); + char *dir = dirname(dir_buf); + load_dir_list(dir); + + /* find current file in list */ + L.cur_file = 0; + for (int i = 0; i < L.nfiles; i++) { + if (strcmp(L.files[i], abs_path) == 0) { L.cur_file = i; break; } + } + + load_image(abs_path); + L.running = 1; + L.dirty = 1; + + XMapWindow(L.dpy, L.win); + XFlush(L.dpy); + + while (L.running) { + if (L.dirty && !XPending(L.dpy)) { + render(); + L.dirty = 0; + } + + XEvent ev; + XNextEvent(L.dpy, &ev); + switch (ev.type) { + case Expose: + if (ev.xexpose.count == 0) L.dirty = 1; + break; + case ConfigureNotify: + L.win_w = ev.xconfigure.width; + L.win_h = ev.xconfigure.height; + L.dirty = 1; + break; + case KeyPress: + handle_key(&ev.xkey); + break; + case ButtonPress: + if (ev.xbutton.button == Button4) { + /* scroll up = zoom in */ + L.fit = 0; + L.zoom *= 1.15; + if (L.zoom > 32.0) L.zoom = 32.0; + L.dirty = 1; + } else if (ev.xbutton.button == Button5) { + L.fit = 0; + L.zoom /= 1.15; + if (L.zoom < 0.02) L.zoom = 0.02; + L.dirty = 1; + } else if (ev.xbutton.button == Button1) { + L.drag = 1; + L.drag_sx = ev.xbutton.x; + L.drag_sy = ev.xbutton.y; + L.drag_px = L.pan_x; + L.drag_py = L.pan_y; + } + break; + case ButtonRelease: + if (ev.xbutton.button == Button1) L.drag = 0; + break; + case MotionNotify: + if (L.drag && !L.fit) { + L.pan_x = L.drag_px + ev.xmotion.x - L.drag_sx; + L.pan_y = L.drag_py + ev.xmotion.y - L.drag_sy; + L.dirty = 1; + } + break; + case ClientMessage: + if ((Atom)ev.xclient.data.l[0] == L.wm_del) L.running = 0; + break; + } + } + + if (L.img) { imlib_context_set_image(L.img); imlib_free_image(); } + for (int i = 0; i < L.nfiles; i++) free(L.files[i]); + free(L.files); + XftFontClose(L.dpy, L.font); + XFreeGC(L.dpy, L.gc); + XDestroyWindow(L.dpy, L.win); + XCloseDisplay(L.dpy); + return 0; +} diff --git a/quill/Makefile b/quill/Makefile new file mode 100644 index 0000000..0586e37 --- /dev/null +++ b/quill/Makefile @@ -0,0 +1,12 @@ +CC = gcc +CFLAGS = -O2 -Wall -Wextra \ + $(shell pkg-config --cflags fontconfig xft x11) +LIBS = $(shell pkg-config --libs fontconfig xft x11) + +quill: quill.c + $(CC) $(CFLAGS) -o quill quill.c $(LIBS) + +clean: + rm -f quill + +.PHONY: clean diff --git a/quill/quill.c b/quill/quill.c new file mode 100644 index 0000000..29bd032 --- /dev/null +++ b/quill/quill.c @@ -0,0 +1,840 @@ +/* quill.c — OrionOS Text Editor + * Named for the writing instrument. Native C/Xlib, no GTK. + * + * Keybindings: + * Ctrl+N New file + * Ctrl+O Open file (shows path bar) + * Ctrl+S Save + * Ctrl+F Find (Esc or Enter to dismiss) + * Ctrl+Z Undo (single snapshot) + * Ctrl+Left/Right Word skip + * Ctrl+Home/End File start/end + * Ctrl+G Go to line + */ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +/* ── Geometry ─────────────────────────────────────────────── */ +#define WIN_W 900 +#define WIN_H 620 +#define HDR_H 38 +#define STAT_H 22 +#define GUTTER_W 54 +#define ROW_H 18 + +#define TEXT_X GUTTER_W +#define TEXT_Y HDR_H +#define TEXT_W (WIN_W - GUTTER_W) +#define TEXT_H (WIN_H - HDR_H - STAT_H) +#define VIS_ROWS (TEXT_H / ROW_H) + +/* find bar overlays bottom of text area */ +#define FIND_H 28 +#define FIND_Y (WIN_H - STAT_H - FIND_H) + +/* ── Buffer ───────────────────────────────────────────────── */ +#define MAX_LINES 8192 +#define LINE_CAP 2048 + +/* ── Palette ──────────────────────────────────────────────── */ +#define C_BG 0x0D1117UL +#define C_HDR 0x090C12UL +#define C_GUTTER 0x0A0E16UL +#define C_CURLINE 0x13192AUL +#define C_BORDER 0x1A2035UL +#define C_TEXT 0xCDD6F4UL +#define C_DIM 0x3A4860UL +#define C_LINENUM 0x4A5880UL +#define C_ACCENT 0x1E58A8UL +#define C_CURSOR 0x89B4FAUL +#define C_FIND_BG 0x141A28UL +#define C_FIND_HL 0x264878UL +#define C_MOD 0xF38BA8UL +#define C_SAVED 0xA6E3A1UL +#define C_SEL 0x1E3A5AUL + +/* ── Editor state ─────────────────────────────────────────── */ +typedef struct { + char lines[MAX_LINES][LINE_CAP]; + int nlines; + int cur; /* cursor line */ + int col; /* cursor column */ + int scroll; /* first visible line */ + char path[4096]; + int modified; + /* find */ + char find[128]; + int find_len; + int find_on; /* find bar visible */ + /* open overlay */ + char open_buf[4096]; + int open_len; + int open_on; + /* goto overlay */ + char goto_buf[16]; + int goto_len; + int goto_on; + /* undo */ + char undo_lines[MAX_LINES][LINE_CAP]; + int undo_nlines, undo_cur, undo_col; + int undo_valid; +} Editor; + +/* ── App state ────────────────────────────────────────────── */ +static Display *dpy; +static int scr; +static Window win; +static GC gc; +static Pixmap buf; +static XftFont *fmono; /* editor body font */ +static XftFont *fui; /* UI chrome font */ +static Visual *vis; +static Colormap cmap; +static int depth; +static Atom wm_del; +static int running = 1; +static int dirty = 1; +static int char_w = 0; +static Editor ed; + +/* ── Colour helpers ───────────────────────────────────────── */ +static unsigned long px(unsigned long rgb) { + XColor c; + c.red = ((rgb >> 16) & 0xFF) * 257; + c.green = ((rgb >> 8) & 0xFF) * 257; + c.blue = ( rgb & 0xFF) * 257; + c.flags = DoRed | DoGreen | DoBlue; + XAllocColor(dpy, cmap, &c); + return c.pixel; +} + +static void fill(int x, int y, int w, int h, unsigned long rgb) { + XSetForeground(dpy, gc, px(rgb)); + XFillRectangle(dpy, buf, gc, x, y, w, h); +} + +static void rect(int x, int y, int w, int h, unsigned long rgb) { + XSetForeground(dpy, gc, px(rgb)); + XDrawRectangle(dpy, buf, gc, x, y, w - 1, h - 1); +} + +static void hline(int x, int y, int w, unsigned long rgb) { + XSetForeground(dpy, gc, px(rgb)); + XDrawLine(dpy, buf, gc, x, y, x + w - 1, y); +} + +static XftColor xftc(unsigned long rgb) { + XftColor c; + XRenderColor rc = { + .red = ((rgb >> 16) & 0xFF) * 257, + .green = ((rgb >> 8) & 0xFF) * 257, + .blue = ( rgb & 0xFF) * 257, + .alpha = 0xFFFF + }; + XftColorAllocValue(dpy, vis, cmap, &rc, &c); + return c; +} + +static void xft_str(XftDraw *xd, XftFont *f, unsigned long rgb, + int x, int y, const char *s, int len) { + XftColor c = xftc(rgb); + XftDrawStringUtf8(xd, &c, f, x, y, (const FcChar8 *)s, len); + XftColorFree(dpy, vis, cmap, &c); +} + +static int text_w(XftFont *f, const char *s, int len) { + XGlyphInfo ext; + XftTextExtentsUtf8(dpy, f, (const FcChar8 *)s, len, &ext); + return ext.xOff; +} + +/* baseline for vertically centred text in a band */ +static int baseline(XftFont *f, int y, int h) { + return y + (h + f->ascent - f->descent) / 2; +} + +/* ── Editor operations ────────────────────────────────────── */ +static void ed_save_undo(void) { + memcpy(ed.undo_lines, ed.lines, sizeof(ed.lines)); + ed.undo_nlines = ed.nlines; + ed.undo_cur = ed.cur; + ed.undo_col = ed.col; + ed.undo_valid = 1; +} + +static void ed_undo(void) { + if (!ed.undo_valid) return; + memcpy(ed.lines, ed.undo_lines, sizeof(ed.lines)); + ed.nlines = ed.undo_nlines; + ed.cur = ed.undo_cur; + ed.col = ed.undo_col; + ed.modified = 1; + ed.undo_valid = 0; +} + +static void ed_clamp_col(void) { + int len = (int)strlen(ed.lines[ed.cur]); + if (ed.col > len) ed.col = len; +} + +static void ed_ensure_visible(void) { + if (ed.cur < ed.scroll) + ed.scroll = ed.cur; + else if (ed.cur >= ed.scroll + VIS_ROWS) + ed.scroll = ed.cur - VIS_ROWS + 1; + if (ed.scroll < 0) ed.scroll = 0; +} + +static void ed_init(void) { + memset(&ed, 0, sizeof(ed)); + ed.lines[0][0] = '\0'; + ed.nlines = 1; +} + +static void ed_load(const char *path) { + FILE *f = fopen(path, "r"); + if (!f) return; + ed.nlines = 0; + while (ed.nlines < MAX_LINES && + fgets(ed.lines[ed.nlines], LINE_CAP, f)) { + char *ln = ed.lines[ed.nlines]; + int len = (int)strlen(ln); + if (len > 0 && ln[len-1] == '\n') ln[len-1] = '\0'; + ed.nlines++; + } + if (ed.nlines == 0) { ed.lines[0][0] = '\0'; ed.nlines = 1; } + fclose(f); + snprintf(ed.path, sizeof(ed.path), "%s", path); + ed.cur = ed.col = ed.scroll = 0; + ed.modified = 0; + ed.undo_valid = 0; +} + +static void ed_save(void) { + if (!ed.path[0]) return; + FILE *f = fopen(ed.path, "w"); + if (!f) return; + for (int i = 0; i < ed.nlines; i++) + fprintf(f, "%s\n", ed.lines[i]); + fclose(f); + ed.modified = 0; +} + +static void ed_insert(char c) { + char *line = ed.lines[ed.cur]; + int len = (int)strlen(line); + if (len >= LINE_CAP - 1) return; + memmove(line + ed.col + 1, line + ed.col, len - ed.col + 1); + line[ed.col++] = c; + ed.modified = 1; +} + +static void ed_backspace(void) { + if (ed.col > 0) { + char *line = ed.lines[ed.cur]; + int len = (int)strlen(line); + memmove(line + ed.col - 1, line + ed.col, len - ed.col + 1); + ed.col--; + } else if (ed.cur > 0) { + char *prev = ed.lines[ed.cur - 1]; + char *curr = ed.lines[ed.cur]; + int plen = (int)strlen(prev); + int clen = (int)strlen(curr); + if (plen + clen < LINE_CAP - 1) { + memcpy(prev + plen, curr, clen + 1); + memmove(ed.lines + ed.cur, ed.lines + ed.cur + 1, + (ed.nlines - ed.cur - 1) * LINE_CAP); + ed.nlines--; + ed.col = plen; + ed.cur--; + } + } + ed.modified = 1; +} + +static void ed_delete(void) { + char *line = ed.lines[ed.cur]; + int len = (int)strlen(line); + if (ed.col < len) { + memmove(line + ed.col, line + ed.col + 1, len - ed.col); + ed.modified = 1; + } else if (ed.cur < ed.nlines - 1) { + char *next = ed.lines[ed.cur + 1]; + int nlen = (int)strlen(next); + if (len + nlen < LINE_CAP - 1) { + memcpy(line + len, next, nlen + 1); + memmove(ed.lines + ed.cur + 1, ed.lines + ed.cur + 2, + (ed.nlines - ed.cur - 2) * LINE_CAP); + ed.nlines--; + ed.modified = 1; + } + } +} + +static void ed_newline(void) { + if (ed.nlines >= MAX_LINES) return; + memmove(ed.lines + ed.cur + 2, ed.lines + ed.cur + 1, + (ed.nlines - ed.cur - 1) * LINE_CAP); + ed.nlines++; + char *curr = ed.lines[ed.cur]; + char *next = ed.lines[ed.cur + 1]; + int tail = (int)strlen(curr + ed.col); + memcpy(next, curr + ed.col, tail + 1); + curr[ed.col] = '\0'; + /* auto-indent: copy leading whitespace */ + int ws = 0; + while (curr[ws] == ' ' || curr[ws] == '\t') ws++; + if (ws > 0 && ws + tail < LINE_CAP - 1) { + memmove(next + ws, next, tail + 1); + memcpy(next, curr, ws); + ed.col = ws; + } else { + ed.col = 0; + } + ed.cur++; + ed.modified = 1; +} + +static void ed_word_left(void) { + if (ed.col == 0) { + if (ed.cur > 0) { ed.cur--; ed.col = (int)strlen(ed.lines[ed.cur]); } + return; + } + ed.col--; + char *l = ed.lines[ed.cur]; + while (ed.col > 0 && !isalnum((unsigned char)l[ed.col - 1])) + ed.col--; + while (ed.col > 0 && isalnum((unsigned char)l[ed.col - 1])) + ed.col--; +} + +static void ed_word_right(void) { + char *l = ed.lines[ed.cur]; + int len = (int)strlen(l); + if (ed.col == len) { + if (ed.cur < ed.nlines - 1) { ed.cur++; ed.col = 0; } + return; + } + while (ed.col < len && !isalnum((unsigned char)l[ed.col])) ed.col++; + while (ed.col < len && isalnum((unsigned char)l[ed.col])) ed.col++; +} + +/* find: returns line of next match, -1 if none */ +static int ed_find_next(int from_line, int from_col) { + if (!ed.find[0]) return -1; + int flen = ed.find_len; + for (int pass = 0; pass < 2; pass++) { + int start_line = (pass == 0) ? from_line : 0; + for (int i = start_line; i < ed.nlines; i++) { + char *s = ed.lines[i]; + int start_c = (i == from_line && pass == 0) ? from_col : 0; + char *found = strstr(s + start_c, ed.find); + if (found) { + ed.cur = i; + ed.col = (int)(found - s); + ed_ensure_visible(); + return i; + } + } + /* wrap: search from beginning */ + } + return -1; +} + +/* ── Window title ─────────────────────────────────────────── */ +static void set_title(void) { + char t[4096 + 32]; + const char *name = ed.path[0] ? strrchr(ed.path, '/') : NULL; + name = name ? name + 1 : (ed.path[0] ? ed.path : "[untitled]"); + snprintf(t, sizeof(t), "Quill — %s%s", name, ed.modified ? " *" : ""); + XStoreName(dpy, win, t); +} + +/* ── Drawing ──────────────────────────────────────────────── */ +static void redraw(void) { + XftDraw *xd = XftDrawCreate(dpy, buf, vis, cmap); + + /* header */ + fill(0, 0, WIN_W, HDR_H, C_HDR); + hline(0, HDR_H - 1, WIN_W, C_BORDER); + + /* gutter */ + fill(0, HDR_H, GUTTER_W, TEXT_H, C_GUTTER); + hline(GUTTER_W, HDR_H, 1, C_BORDER); + + /* text area background */ + fill(TEXT_X, TEXT_Y, TEXT_W, TEXT_H, C_BG); + + /* status bar */ + fill(0, WIN_H - STAT_H, WIN_W, STAT_H, C_HDR); + hline(0, WIN_H - STAT_H, WIN_W, C_BORDER); + + /* find bar overlay */ + if (ed.find_on) { + fill(0, FIND_Y, WIN_W, FIND_H, C_FIND_BG); + hline(0, FIND_Y, WIN_W, C_BORDER); + } + + /* visible lines */ + for (int row = 0; row < VIS_ROWS; row++) { + int lnum = ed.scroll + row; + if (lnum >= ed.nlines) break; + int ry = TEXT_Y + row * ROW_H; + int bl = baseline(fmono, ry, ROW_H); + + /* current line highlight */ + if (lnum == ed.cur) + fill(0, ry, WIN_W, ROW_H, C_CURLINE); + + /* line number */ + char num[12]; + snprintf(num, sizeof(num), "%d", lnum + 1); + int nw = text_w(fui, num, (int)strlen(num)); + xft_str(xd, fui, C_LINENUM, + GUTTER_W - nw - 8, baseline(fui, ry, ROW_H), + num, (int)strlen(num)); + + /* line text */ + char *line = ed.lines[lnum]; + int llen = (int)strlen(line); + if (llen > 0) + xft_str(xd, fmono, C_TEXT, TEXT_X + 4, bl, line, llen); + + /* find highlights */ + if (ed.find_on && ed.find[0]) { + char *s = line; + char *p; + while ((p = strstr(s, ed.find)) != NULL) { + int fx = TEXT_X + 4 + text_w(fmono, line, (int)(p - line)); + int fw = text_w(fmono, ed.find, ed.find_len); + fill(fx, ry, fw, ROW_H, C_FIND_HL); + xft_str(xd, fmono, C_TEXT, fx, bl, ed.find, ed.find_len); + s = p + ed.find_len; + } + } + + /* cursor */ + if (lnum == ed.cur) { + int cx = TEXT_X + 4 + text_w(fmono, line, ed.col); + fill(cx, ry, 2, ROW_H, C_CURSOR); + } + } + + /* ── header chrome ─────────────────────────────────────── */ + if (ed.open_on) { + /* open-file path bar in header */ + fill(4, 6, WIN_W - 8, HDR_H - 12, C_FIND_BG); + rect(4, 6, WIN_W - 8, HDR_H - 12, C_ACCENT); + int bl2 = baseline(fmono, 6, HDR_H - 12); + xft_str(xd, fui, C_DIM, 10, bl2, "Open: ", 6); + int pfx = 10 + text_w(fui, "Open: ", 6); + if (ed.open_len > 0) + xft_str(xd, fmono, C_TEXT, pfx, bl2, ed.open_buf, ed.open_len); + /* cursor */ + int cx = pfx + text_w(fmono, ed.open_buf, ed.open_len); + fill(cx, 8, 2, HDR_H - 16, C_CURSOR); + } else if (ed.goto_on) { + fill(4, 6, 200, HDR_H - 12, C_FIND_BG); + rect(4, 6, 200, HDR_H - 12, C_ACCENT); + int bl2 = baseline(fmono, 6, HDR_H - 12); + xft_str(xd, fui, C_DIM, 10, bl2, "Go to line: ", 12); + int pfx = 10 + text_w(fui, "Go to line: ", 12); + if (ed.goto_len > 0) + xft_str(xd, fmono, C_TEXT, pfx, bl2, ed.goto_buf, ed.goto_len); + int cx = pfx + text_w(fmono, ed.goto_buf, ed.goto_len); + fill(cx, 8, 2, HDR_H - 16, C_CURSOR); + } else { + /* normal header: filename */ + const char *name = ed.path[0] ? strrchr(ed.path, '/') : NULL; + name = name ? name + 1 : (ed.path[0] ? ed.path : "[untitled]"); + int bl2 = baseline(fui, 0, HDR_H); + xft_str(xd, fui, C_TEXT, 12, bl2, name, (int)strlen(name)); + if (ed.modified) { + int nx = 12 + text_w(fui, name, (int)strlen(name)); + xft_str(xd, fui, C_MOD, nx + 6, bl2, "●", 3); + } + /* right side hints */ + const char *hint = "Ctrl+S Save Ctrl+O Open Ctrl+F Find"; + int hw = text_w(fui, hint, (int)strlen(hint)); + xft_str(xd, fui, C_DIM, WIN_W - hw - 12, bl2, hint, (int)strlen(hint)); + } + + /* ── find bar ───────────────────────────────────────────── */ + if (ed.find_on) { + int bl2 = baseline(fmono, FIND_Y, FIND_H); + xft_str(xd, fui, C_DIM, 10, bl2, "Find: ", 6); + int pfx = 10 + text_w(fui, "Find: ", 6); + if (ed.find_len > 0) + xft_str(xd, fmono, C_TEXT, pfx, bl2, ed.find, ed.find_len); + int cx = pfx + text_w(fmono, ed.find, ed.find_len); + fill(cx, FIND_Y + 4, 2, FIND_H - 8, C_CURSOR); + xft_str(xd, fui, C_DIM, cx + 10, bl2, "Enter: next Esc: close", 23); + } + + /* ── status bar ─────────────────────────────────────────── */ + { + char stat[128]; + snprintf(stat, sizeof(stat), "Line %d, Col %d %d lines", + ed.cur + 1, ed.col + 1, ed.nlines); + int bl2 = baseline(fui, WIN_H - STAT_H, STAT_H); + xft_str(xd, fui, C_DIM, 12, bl2, stat, (int)strlen(stat)); + const char *enc = "UTF-8"; + int ew = text_w(fui, enc, 5); + xft_str(xd, fui, C_DIM, WIN_W - ew - 12, bl2, enc, 5); + } + + XftDrawDestroy(xd); + XCopyArea(dpy, buf, win, gc, 0, 0, WIN_W, WIN_H, 0, 0); + XFlush(dpy); +} + +/* ── Key handling ─────────────────────────────────────────── */ +static void handle_key(XKeyEvent *ev) { + char text[32] = {0}; + KeySym ksym = 0; + int n = XLookupString(ev, text, sizeof(text)-1, &ksym, NULL); + int ctrl = (ev->state & ControlMask) != 0; + int shift = (ev->state & ShiftMask) != 0; + + /* ── open-file overlay ─────────────────────────────────── */ + if (ed.open_on) { + if (ksym == XK_Escape) { ed.open_on = 0; } + else if (ksym == XK_Return) { + if (ed.open_buf[0]) ed_load(ed.open_buf); + ed.open_on = 0; + } else if (ksym == XK_BackSpace) { + if (ed.open_len > 0) ed.open_buf[--ed.open_len] = '\0'; + } else if (n > 0 && (unsigned char)text[0] >= 0x20) { + if (ed.open_len < (int)sizeof(ed.open_buf) - 1) { + ed.open_buf[ed.open_len++] = text[0]; + ed.open_buf[ed.open_len] = '\0'; + } + } + dirty = 1; return; + } + + /* ── goto overlay ──────────────────────────────────────── */ + if (ed.goto_on) { + if (ksym == XK_Escape) { ed.goto_on = 0; } + else if (ksym == XK_Return) { + int target = atoi(ed.goto_buf) - 1; + if (target >= 0 && target < ed.nlines) { + ed.cur = target; ed.col = 0; ed_ensure_visible(); + } + ed.goto_on = 0; + } else if (ksym == XK_BackSpace) { + if (ed.goto_len > 0) ed.goto_buf[--ed.goto_len] = '\0'; + } else if (n > 0 && isdigit((unsigned char)text[0])) { + if (ed.goto_len < 9) { + ed.goto_buf[ed.goto_len++] = text[0]; + ed.goto_buf[ed.goto_len] = '\0'; + } + } + dirty = 1; return; + } + + /* ── find bar ──────────────────────────────────────────── */ + if (ed.find_on) { + if (ksym == XK_Escape) { + ed.find_on = 0; + } else if (ksym == XK_Return) { + ed_find_next(ed.cur, ed.col + 1); + } else if (ksym == XK_BackSpace) { + if (ed.find_len > 0) { + ed.find[--ed.find_len] = '\0'; + ed_find_next(0, 0); + } + } else if (n > 0 && (unsigned char)text[0] >= 0x20) { + if (ed.find_len < 127) { + ed.find[ed.find_len++] = text[0]; + ed.find[ed.find_len] = '\0'; + ed_find_next(0, 0); + } + } + dirty = 1; return; + } + + /* ── global shortcuts ──────────────────────────────────── */ + if (ctrl) { + switch (ksym) { + case XK_n: + ed_save_undo(); + ed_init(); + dirty = 1; return; + case XK_o: + memset(ed.open_buf, 0, sizeof(ed.open_buf)); + ed.open_len = 0; + ed.open_on = 1; + dirty = 1; return; + case XK_s: + if (!ed.path[0]) { + memset(ed.open_buf, 0, sizeof(ed.open_buf)); + ed.open_len = 0; + ed.open_on = 1; + } else { + ed_save(); + set_title(); + } + dirty = 1; return; + case XK_f: + ed.find_on = 1; + ed.find[0] = '\0'; + ed.find_len = 0; + dirty = 1; return; + case XK_z: + ed_undo(); + ed_ensure_visible(); + dirty = 1; return; + case XK_g: + memset(ed.goto_buf, 0, sizeof(ed.goto_buf)); + ed.goto_len = 0; + ed.goto_on = 1; + dirty = 1; return; + case XK_Home: + ed.cur = 0; ed.col = 0; ed_ensure_visible(); + dirty = 1; return; + case XK_End: + ed.cur = ed.nlines - 1; + ed.col = (int)strlen(ed.lines[ed.cur]); + ed_ensure_visible(); + dirty = 1; return; + case XK_Left: + ed_word_left(); + ed_ensure_visible(); + dirty = 1; return; + case XK_Right: + ed_word_right(); + ed_ensure_visible(); + dirty = 1; return; + } + } + + /* ── navigation ────────────────────────────────────────── */ + switch (ksym) { + case XK_Up: + if (ed.cur > 0) { ed.cur--; ed_clamp_col(); } + ed_ensure_visible(); dirty = 1; return; + case XK_Down: + if (ed.cur < ed.nlines - 1) { ed.cur++; ed_clamp_col(); } + ed_ensure_visible(); dirty = 1; return; + case XK_Left: + if (ed.col > 0) ed.col--; + else if (ed.cur > 0) { + ed.cur--; + ed.col = (int)strlen(ed.lines[ed.cur]); + ed_ensure_visible(); + } + dirty = 1; return; + case XK_Right: + if (ed.col < (int)strlen(ed.lines[ed.cur])) ed.col++; + else if (ed.cur < ed.nlines - 1) { + ed.cur++; ed.col = 0; ed_ensure_visible(); + } + dirty = 1; return; + case XK_Home: + /* smart home: first non-ws char, then col 0 */ + { + char *l = ed.lines[ed.cur]; + int ws = 0; + while (l[ws] == ' ' || l[ws] == '\t') ws++; + ed.col = (ed.col != ws) ? ws : 0; + } + dirty = 1; return; + case XK_End: + ed.col = (int)strlen(ed.lines[ed.cur]); + dirty = 1; return; + case XK_Page_Up: + ed.cur -= VIS_ROWS; + if (ed.cur < 0) ed.cur = 0; + ed_clamp_col(); ed_ensure_visible(); + dirty = 1; return; + case XK_Page_Down: + ed.cur += VIS_ROWS; + if (ed.cur >= ed.nlines) ed.cur = ed.nlines - 1; + ed_clamp_col(); ed_ensure_visible(); + dirty = 1; return; + case XK_Escape: + ed.find_on = 0; + dirty = 1; return; + case XK_BackSpace: + ed_save_undo(); + ed_backspace(); + ed_ensure_visible(); + dirty = 1; return; + case XK_Delete: + ed_save_undo(); + ed_delete(); + dirty = 1; return; + case XK_Return: + ed_save_undo(); + ed_newline(); + ed_ensure_visible(); + dirty = 1; return; + case XK_Tab: + if (shift) { + /* unindent: remove leading spaces/tab */ + char *l = ed.lines[ed.cur]; + if (l[0] == '\t') { + memmove(l, l+1, strlen(l)); + if (ed.col > 0) ed.col--; + } else { + int rm = 0; + while (rm < 4 && l[0] == ' ') { + memmove(l, l+1, strlen(l)); rm++; + if (ed.col > 0) ed.col--; + } + } + } else { + /* indent: insert 4 spaces */ + for (int i = 0; i < 4; i++) ed_insert(' '); + } + ed.modified = 1; + dirty = 1; return; + } + + /* ── printable text ─────────────────────────────────────── */ + if (n > 0) { + for (int i = 0; i < n; i++) { + unsigned char c = (unsigned char)text[i]; + if (c >= 0x20 || c == 0x09) { + if (ed.col == 0 && (int)strlen(ed.lines[ed.cur]) == 0) + ed_save_undo(); + ed_insert((char)c); + } + } + ed_ensure_visible(); + dirty = 1; + } +} + +/* ── Mouse handling ───────────────────────────────────────── */ +static void handle_button(XButtonEvent *ev) { + if (ev->y < HDR_H || ev->y >= WIN_H - STAT_H) return; + int ry = ev->y - TEXT_Y; + if (ry < 0) return; + int row = ry / ROW_H; + int lnum = ed.scroll + row; + if (lnum >= ed.nlines) return; + ed.cur = lnum; + /* click to column */ + int click_x = ev->x - TEXT_X - 4; + char *line = ed.lines[lnum]; + int len = (int)strlen(line); + int best = 0, bestd = abs(click_x); + for (int c = 1; c <= len; c++) { + int cx = text_w(fmono, line, c); + int d = abs(click_x - cx); + if (d < bestd) { bestd = d; best = c; } + } + ed.col = best; + dirty = 1; +} + +static void handle_scroll(XButtonEvent *ev) { + int delta = (ev->button == Button4) ? -3 : 3; + ed.scroll += delta; + if (ed.scroll < 0) ed.scroll = 0; + int max_sc = ed.nlines - VIS_ROWS; + if (max_sc < 0) max_sc = 0; + if (ed.scroll > max_sc) ed.scroll = max_sc; + dirty = 1; +} + +/* ── Main ─────────────────────────────────────────────────── */ +int main(int argc, char **argv) { + dpy = XOpenDisplay(NULL); + if (!dpy) { fputs("quill: cannot open display\n", stderr); return 1; } + scr = DefaultScreen(dpy); + vis = DefaultVisual(dpy, scr); + cmap = DefaultColormap(dpy, scr); + depth = DefaultDepth(dpy, scr); + + fmono = XftFontOpenName(dpy, scr, "Monospace:size=11"); + if (!fmono) fmono = XftFontOpenName(dpy, scr, "Sans:size=11"); + fui = XftFontOpenName(dpy, scr, "Sans:size=9"); + if (!fui) fui = fmono; + + /* measure monospace char width */ + { + XGlyphInfo ext; + XftTextExtentsUtf8(dpy, fmono, (const FcChar8 *)"M", 1, &ext); + char_w = ext.xOff; + } + + XSetWindowAttributes swa = { + .background_pixel = px(C_BG), + .event_mask = ExposureMask | KeyPressMask + | ButtonPressMask | StructureNotifyMask + }; + win = XCreateWindow(dpy, RootWindow(dpy, scr), + 100, 60, WIN_W, WIN_H, 0, + depth, InputOutput, vis, + CWBackPixel | CWEventMask, &swa); + + buf = XCreatePixmap(dpy, win, WIN_W, WIN_H, depth); + gc = XCreateGC(dpy, buf, 0, NULL); + + wm_del = XInternAtom(dpy, "WM_DELETE_WINDOW", False); + XSetWMProtocols(dpy, win, &wm_del, 1); + + /* set WM_CLASS */ + XClassHint *ch = XAllocClassHint(); + ch->res_name = "quill"; + ch->res_class = "Quill"; + XSetClassHint(dpy, win, ch); + XFree(ch); + + ed_init(); + if (argc > 1) ed_load(argv[1]); + set_title(); + + XMapWindow(dpy, win); + XFlush(dpy); + + while (running) { + if (dirty && !XPending(dpy)) { + redraw(); + set_title(); + dirty = 0; + } + + XEvent ev; + XNextEvent(dpy, &ev); + switch (ev.type) { + case Expose: + if (ev.xexpose.count == 0) dirty = 1; + break; + case KeyPress: + handle_key(&ev.xkey); + break; + case ButtonPress: + if (ev.xbutton.button == Button4 || ev.xbutton.button == Button5) + handle_scroll(&ev.xbutton); + else + handle_button(&ev.xbutton); + break; + case ClientMessage: + if ((Atom)ev.xclient.data.l[0] == wm_del) running = 0; + break; + } + } + + XftFontClose(dpy, fmono); + if (fui != fmono) XftFontClose(dpy, fui); + XFreePixmap(dpy, buf); + XFreeGC(dpy, gc); + XDestroyWindow(dpy, win); + XCloseDisplay(dpy); + return 0; +} diff --git a/rigel/rigel.c b/rigel/rigel.c index cf5dd53..c439daa 100644 --- a/rigel/rigel.c +++ b/rigel/rigel.c @@ -10,7 +10,7 @@ * [ | IMG photo.jpg 2 MB Jul 10 ] * [ Home | ] * [ Docs |────────────────────────────────────── ] - * [ Down | 47 items ] + * [ Down | 47 items [search bar when active] ] */ #include #include @@ -64,6 +64,12 @@ #define PATH_W (WIN_W - PATH_X - 8) #define PATH_H 26 +/* context menu */ +#define CTX_W 200 +#define CTX_IH 24 +#define CTX_SH 8 +#define CTX_PAD 4 + /* ── File types ───────────────────────────────────────────────────── */ typedef enum { FT_DIR, FT_IMAGE, FT_AUDIO, FT_VIDEO, FT_ARCHIVE, @@ -127,6 +133,21 @@ static const struct { const char *name; const char *path; } BKMARKS[] = { }; #define NBKMARKS ((int)(sizeof(BKMARKS)/sizeof(BKMARKS[0]))) +/* ── Context menu items ───────────────────────────────────────────── */ +typedef struct { const char *lbl; int sep; } CtxItem; +static const CtxItem CTX[] = { + { "Open", 0 }, + { "Open in Quill", 0 }, + { "Open in Terminal", 0 }, + { NULL, 1 }, + { "Copy Path", 0 }, + { "Rename", 0 }, + { "Move to Trash", 0 }, + { NULL, 1 }, + { "Properties", 0 }, +}; +#define CTX_N ((int)(sizeof(CTX)/sizeof(CTX[0]))) + /* ── App struct ───────────────────────────────────────────────────── */ #define HIST_MAX 64 @@ -147,21 +168,41 @@ typedef struct { FileEntry files[FMAX]; int nfiles; int scroll; - int hover_row; - int sel_row; + int hover_row; /* files[] index, -1 if none */ + int sel_row; /* files[] index, -1 if none */ int show_hidden; - int sort_col; /* 0=name 1=size 2=date */ - int sort_dir; /* +1=asc -1=desc */ + int sort_col; /* 0=name 1=size 2=date */ + int sort_dir; /* +1=asc -1=desc */ int sidebar_hover; - int hover_btn; /* 1=back 2=fwd 3=up */ + int hover_btn; /* 1=back 2=fwd 3=up */ char path_buf[4096]; int path_editing; int path_cursor; Time last_click_time; - int last_click_row; + int last_click_row; /* files[] index */ + + /* ── search / filter ──────────────────────────── */ + char search_str[256]; + int search_len; + int search_on; + int filtered[FMAX]; /* files[] indices */ + int nfiltered; + + /* ── right-click context menu ─────────────────── */ + Window ctx_win; + int ctx_vis; + int ctx_x, ctx_y; + int ctx_file_idx; /* files[] index */ + int ctx_hover; /* item index, -1 if none */ + + /* ── rename overlay ───────────────────────────── */ + char rename_buf[512]; + int rename_len; + int rename_on; + int rename_idx; /* files[] index */ } Rigel; /* ── Path helpers ─────────────────────────────────────────────────── */ @@ -198,7 +239,6 @@ static int g_sort_col, g_sort_dir; static int cmp_files(const void *a, const void *b) { const FileEntry *fa = (const FileEntry *)a; const FileEntry *fb = (const FileEntry *)b; - /* dirs always first */ int da = (fa->type == FT_DIR), db = (fb->type == FT_DIR); if (da != db) return da ? -1 : 1; int r = 0; @@ -215,6 +255,10 @@ static void list_dir(Rigel *r) { r->nfiles = 0; r->scroll = 0; r->sel_row = -1; + r->hover_row = -1; + r->search_on = 0; + r->search_len = 0; + r->search_str[0] = '\0'; DIR *dir = opendir(r->cwd); if (!dir) return; @@ -245,6 +289,37 @@ static void list_dir(Rigel *r) { qsort(r->files, r->nfiles, sizeof(FileEntry), cmp_files); } +/* ── Search filter ───────────────────────────────────────────────── */ +static void search_filter(Rigel *r) { + r->nfiltered = 0; + r->scroll = 0; + r->sel_row = -1; + if (!r->search_str[0]) return; + for (int i = 0; i < r->nfiles; i++) { + if (strcasestr(r->files[i].name, r->search_str)) + r->filtered[r->nfiltered++] = i; + } +} + +static int get_nvis(Rigel *r) { return r->search_on ? r->nfiltered : r->nfiles; } +static FileEntry *get_file(Rigel *r, int vis_idx) { + if (r->search_on) { + if (vis_idx < 0 || vis_idx >= r->nfiltered) return NULL; + return &r->files[r->filtered[vis_idx]]; + } + if (vis_idx < 0 || vis_idx >= r->nfiles) return NULL; + return &r->files[vis_idx]; +} +/* translate visual row (vis_idx) → files[] index */ +static int get_fidx(Rigel *r, int vis_idx) { + if (r->search_on) { + if (vis_idx < 0 || vis_idx >= r->nfiltered) return -1; + return r->filtered[vis_idx]; + } + if (vis_idx < 0 || vis_idx >= r->nfiles) return -1; + return vis_idx; +} + /* ── Navigation ──────────────────────────────────────────────────── */ static void nav_cd(Rigel *r, const char *path) { char real[4096]; @@ -253,7 +328,6 @@ static void nav_cd(Rigel *r, const char *path) { } else { snprintf(r->cwd, sizeof(r->cwd), "%s", path); } - /* push history */ if (r->hist_len > 0 && strcmp(r->history[r->hist_pos], r->cwd) == 0) goto done; r->hist_pos++; @@ -292,9 +366,9 @@ static void nav_up(Rigel *r) { else { *sl = '\0'; nav_cd(r, parent); } } -static void open_entry(Rigel *r, int idx) { - if (idx < 0 || idx >= r->nfiles) return; - FileEntry *fe = &r->files[idx]; +static void open_entry(Rigel *r, int fidx) { + if (fidx < 0 || fidx >= r->nfiles) return; + FileEntry *fe = &r->files[fidx]; if (fe->type == FT_DIR) { char path[4096*2]; snprintf(path, sizeof(path), "%s/%s", r->cwd, fe->name); @@ -307,6 +381,179 @@ static void open_entry(Rigel *r, int idx) { } } +/* ── Context menu ────────────────────────────────────────────────── */ +static int ctx_item_h(int i) { return CTX[i].sep ? CTX_SH : CTX_IH; } + +static int ctx_total_h(void) { + int h = CTX_PAD * 2; + for (int i = 0; i < CTX_N; i++) h += ctx_item_h(i); + return h; +} + +static int ctx_item_y(int idx) { + int y = CTX_PAD; + for (int i = 0; i < idx; i++) y += ctx_item_h(i); + return y; +} + +static int ctx_item_at(int py) { + int y = CTX_PAD; + for (int i = 0; i < CTX_N; i++) { + int h = ctx_item_h(i); + if (!CTX[i].sep && py >= y && py < y + h) return i; + y += h; + } + return -1; +} + +static void ctx_hide(Rigel *r) { + if (!r->ctx_vis) return; + XUngrabPointer(r->sk.dpy, CurrentTime); + XDestroyWindow(r->sk.dpy, r->ctx_win); + r->ctx_vis = 0; + r->ctx_win = 0; + r->ctx_hover = -1; +} + +static void ctx_draw(Rigel *r) { + if (!r->ctx_vis) return; + int ch = ctx_total_h(); + XftDraw *xd = XftDrawCreate(r->sk.dpy, r->ctx_win, r->sk.vis, r->sk.cmap); + + XSetForeground(r->sk.dpy, r->gc, sk_color(&r->sk, 0x1A2030UL)); + XFillRectangle(r->sk.dpy, r->ctx_win, r->gc, 0, 0, CTX_W, ch); + + for (int i = 0; i < CTX_N; i++) { + int iy = ctx_item_y(i); + if (CTX[i].sep) { + XSetForeground(r->sk.dpy, r->gc, sk_color(&r->sk, 0x2A3040UL)); + XDrawLine(r->sk.dpy, r->ctx_win, r->gc, 8, iy + 4, CTX_W - 8, iy + 4); + continue; + } + if (i == r->ctx_hover) { + XSetForeground(r->sk.dpy, r->gc, sk_color(&r->sk, 0x264878UL)); + XFillRectangle(r->sk.dpy, r->ctx_win, r->gc, 2, iy, CTX_W - 4, CTX_IH); + } + unsigned long fg = (i == r->ctx_hover) ? 0xF0F4FFUL : 0xC8D4E8UL; + XftColor col; + XRenderColor rc = { + .red = ((fg >> 16) & 0xFF) * 257, + .green = ((fg >> 8) & 0xFF) * 257, + .blue = ( fg & 0xFF) * 257, + .alpha = 0xFFFF + }; + XftColorAllocValue(r->sk.dpy, r->sk.vis, r->sk.cmap, &rc, &col); + int ty = iy + (CTX_IH + r->sk.font_ui->ascent - r->sk.font_ui->descent) / 2; + XftDrawStringUtf8(xd, &col, r->sk.font_ui, + CTX_PAD + 4, ty, + (const FcChar8 *)CTX[i].lbl, (int)strlen(CTX[i].lbl)); + XftColorFree(r->sk.dpy, r->sk.vis, r->sk.cmap, &col); + } + XftDrawDestroy(xd); + XFlush(r->sk.dpy); +} + +static void ctx_show(Rigel *r, int x, int y, int fidx) { + if (r->ctx_vis) ctx_hide(r); + r->ctx_file_idx = fidx; + r->ctx_hover = -1; + + int sw = DisplayWidth(r->sk.dpy, r->sk.screen); + int sh = DisplayHeight(r->sk.dpy, r->sk.screen); + int ch = ctx_total_h(); + int wx, wy; + Window dummy; + XTranslateCoordinates(r->sk.dpy, r->win, r->sk.root, x, y, &wx, &wy, &dummy); + if (wx + CTX_W > sw) wx = sw - CTX_W - 2; + if (wy + ch > sh) wy = sh - ch - 2; + + r->ctx_x = wx; r->ctx_y = wy; + + XSetWindowAttributes swa = { + .override_redirect = True, + .background_pixel = sk_color(&r->sk, 0x1A2030UL), + .event_mask = ExposureMask | ButtonPressMask + | ButtonReleaseMask | PointerMotionMask + | LeaveWindowMask, + .save_under = True, + }; + r->ctx_win = XCreateWindow(r->sk.dpy, r->sk.root, + wx, wy, CTX_W, ch, 1, + r->sk.depth, InputOutput, r->sk.vis, + CWOverrideRedirect | CWBackPixel | CWEventMask | CWSaveUnder, + &swa); + XSetWindowBorder(r->sk.dpy, r->ctx_win, sk_color(&r->sk, SK_BORDER)); + XMapRaised(r->sk.dpy, r->ctx_win); + XGrabPointer(r->sk.dpy, r->ctx_win, False, + ButtonPressMask | ButtonReleaseMask | PointerMotionMask, + GrabModeAsync, GrabModeAsync, None, None, CurrentTime); + r->ctx_vis = 1; + ctx_draw(r); +} + +static void ctx_exec(Rigel *r, int item) { + int fidx = r->ctx_file_idx; + if (fidx < 0 || fidx >= r->nfiles) return; + FileEntry *fe = &r->files[fidx]; + char full[4096 + 512]; + snprintf(full, sizeof(full), "%s/%s", r->cwd, fe->name); + char cmd[sizeof(full) + 256]; + + switch (item) { + case 0: /* Open */ + open_entry(r, fidx); + break; + case 1: /* Open in Quill */ + snprintf(cmd, sizeof(cmd), "quill \"%s\" &", full); + system(cmd); + break; + case 2: /* Open in Terminal */ + snprintf(cmd, sizeof(cmd), + "alacritty --working-directory \"%s\" &", + fe->type == FT_DIR ? full : r->cwd); + system(cmd); + break; + case 4: /* Copy Path */ + snprintf(cmd, sizeof(cmd), + "printf '%%s' \"%s\" | xclip -selection clipboard 2>/dev/null" + " || printf '%%s' \"%s\" | xsel --clipboard --input 2>/dev/null", + full, full); + system(cmd); + break; + case 5: /* Rename */ + snprintf(r->rename_buf, sizeof(r->rename_buf), "%s", fe->name); + r->rename_len = (int)strlen(r->rename_buf); + r->rename_idx = fidx; + r->rename_on = 1; + break; + case 6: /* Move to Trash */ + snprintf(cmd, sizeof(cmd), + "gio trash \"%s\" 2>/dev/null" + " || mv \"%s\" ~/.local/share/Trash/files/ 2>/dev/null", + full, full); + system(cmd); + list_dir(r); + break; + case 8: /* Properties */ + { + struct stat st; + if (stat(full, &st) == 0) { + char sbuf[32]; fmt_size(st.st_size, sbuf, sizeof(sbuf)); + char dbuf[32]; fmt_date(st.st_mtime, dbuf, sizeof(dbuf)); + char msg[1024]; + snprintf(msg, sizeof(msg), + "Name: %s\nPath: %s\nSize: %s\nModified: %s\nMode: %o", + fe->name, full, sbuf, dbuf, (unsigned)st.st_mode); + snprintf(cmd, sizeof(cmd), + "alacritty -T 'Properties' -e bash -c " + "'echo \"%s\"; read -p \"Press Enter to close...\"' &", msg); + system(cmd); + } + } + break; + } +} + /* ── Drawing helpers ─────────────────────────────────────────────── */ static void draw_flush(Rigel *r) { XCopyArea(r->sk.dpy, r->buf, r->win, r->gc, @@ -318,7 +565,6 @@ static void draw_toolbar(Rigel *r) { sk_fill(&r->p, SK_SURFACE, 0, 0, WIN_W, TOOL_H); sk_line(&r->p, SK_BORDER, 0, TOOL_H-1, WIN_W, TOOL_H-1); - /* back / fwd / up buttons */ int can_back = r->hist_pos > 0; int can_fwd = r->hist_pos < r->hist_len - 1; @@ -351,7 +597,6 @@ static void draw_toolbar(Rigel *r) { px + 6, sk_baseline(r->sk.font_ui, py, ph), ptext, px+1, py+1, pw-2, ph-2); - /* cursor when editing */ if (r->path_editing) { char tmp[4096]; int cpos = r->path_cursor; @@ -366,7 +611,6 @@ static void draw_sidebar(Rigel *r) { sk_fill(&r->p, SK_SIDEBAR, 0, 0, SIDE_W, WIN_H); sk_line(&r->p, SK_BORDER, SIDE_W, 0, SIDE_W, WIN_H); - /* header */ sk_fill(&r->p, 0x06090FUL, 0, TOOL_H, SIDE_W, SIDE_HDR_H); sk_text(&r->p, r->sk.font_sm, SK_TEXT_DIM, 12, sk_baseline(r->sk.font_sm, TOOL_H, SIDE_HDR_H), "BOOKMARKS"); @@ -375,7 +619,6 @@ static void draw_sidebar(Rigel *r) { int iy = TOOL_H + SIDE_HDR_H; for (int i = 0; i < NBKMARKS; i++, iy += SIDE_ITEM_H) { int hov = (r->sidebar_hover == i); - /* check if active */ char expanded[4096]; expand_path(BKMARKS[i].path, expanded, sizeof(expanded)); int active = (strcmp(expanded, r->cwd) == 0); @@ -383,7 +626,6 @@ static void draw_sidebar(Rigel *r) { unsigned long bg = active ? SK_SEL : (hov ? SK_ROW_HOV : SK_SIDEBAR); sk_fill(&r->p, bg, 0, iy, SIDE_W, SIDE_ITEM_H); - /* accent bar for active */ if (active) sk_fill(&r->p, SK_ACCENT, 0, iy, 3, SIDE_ITEM_H); unsigned long fg = active ? SK_TEXT_HDR : (hov ? SK_TEXT_HOV : SK_TEXT); @@ -406,7 +648,6 @@ static void draw_col_header(Rigel *r) { unsigned long fc = (r->sort_col == cols[i].col) ? SK_TEXT_HDR : SK_TEXT_DIM; sk_text(&r->p, r->sk.font_sm, fc, cols[i].x + 4, sk_baseline(r->sk.font_sm, hy, COL_H), cols[i].lbl); - /* sort arrow */ if (r->sort_col == cols[i].col) { const char *arr = (r->sort_dir > 0) ? "▲" : "▼"; sk_text(&r->p, r->sk.font_sm, SK_ACCENT, @@ -419,19 +660,21 @@ static void draw_col_header(Rigel *r) { static void draw_filelist(Rigel *r) { sk_fill(&r->p, SK_BG, FILE_X, LIST_Y, FILE_W, LIST_H); - int visible = r->nfiles - r->scroll; + int nvis = get_nvis(r); + int visible = nvis - r->scroll; if (visible > LIST_ROWS) visible = LIST_ROWS; for (int i = 0; i < visible; i++) { - int ri = i + r->scroll; - int ry = LIST_Y + i * ROW_H; - FileEntry *fe = &r->files[ri]; + int vis_idx = i + r->scroll; + int ry = LIST_Y + i * ROW_H; + int fidx = get_fidx(r, vis_idx); + FileEntry *fe = (fidx >= 0) ? &r->files[fidx] : NULL; + if (!fe) break; - /* row background */ unsigned long bg = SK_BG; - if (ri == r->sel_row) bg = SK_SEL; - else if (ri == r->hover_row) bg = SK_ROW_HOV; - else if (i % 2 == 1) bg = SK_ROW_ALT; + if (fidx == r->sel_row) bg = SK_SEL; + else if (fidx == r->hover_row) bg = SK_ROW_HOV; + else if (i % 2 == 1) bg = SK_ROW_ALT; sk_fill(&r->p, bg, FILE_X, ry, FILE_W - SB_W, ROW_H); /* type badge */ @@ -444,10 +687,24 @@ static void draw_filelist(Rigel *r) { /* name */ int ty = sk_baseline(r->sk.font_ui, ry, ROW_H); - unsigned long fg = (ri == r->sel_row || ri == r->hover_row) ? SK_TEXT_HDR : SK_TEXT; + unsigned long fg = (fidx == r->sel_row || fidx == r->hover_row) ? SK_TEXT_HDR : SK_TEXT; + if (r->search_on && r->search_str[0]) { + char *match = strcasestr(fe->name, r->search_str); + if (match) { + /* accent rect behind the matching substring */ + int pre_len = (int)(match - fe->name); + int match_len = r->search_len; + int nx = FILE_X + BADGE_W + 2; + char tmp[512]; + snprintf(tmp, sizeof(tmp), "%.*s", pre_len, fe->name); + int mx = nx + sk_textw(&r->sk, r->sk.font_ui, tmp); + snprintf(tmp, sizeof(tmp), "%.*s", match_len, match); + int mw = sk_textw(&r->sk, r->sk.font_ui, tmp); + sk_fill(&r->p, SK_ACCENT, mx, ry + 2, mw, ROW_H - 4); + } + } sk_text_clip(&r->p, r->sk.font_ui, fg, - FILE_X + BADGE_W + 2, ty, - fe->name, + FILE_X + BADGE_W + 2, ty, fe->name, FILE_X + BADGE_W, ry, NAME_W, ROW_H); /* size */ @@ -462,17 +719,16 @@ static void draw_filelist(Rigel *r) { sk_text_clip(&r->p, r->sk.font_ui, SK_TEXT_DIM, dx, ty, dbuf, dx, ry, DATE_W - 4, ROW_H); - /* row separator */ sk_line(&r->p, SK_SEP, FILE_X, ry + ROW_H - 1, WIN_W - SB_W, ry + ROW_H - 1); } /* scrollbar */ - if (r->nfiles > LIST_ROWS) { + if (nvis > LIST_ROWS) { int sbx = WIN_W - SB_W; sk_fill(&r->p, SK_PANEL, sbx, LIST_Y, SB_W, LIST_H); - int th = LIST_H * LIST_ROWS / r->nfiles; + int th = LIST_H * LIST_ROWS / nvis; if (th < 20) th = 20; - int ty2 = LIST_Y + (LIST_H - th) * r->scroll / (r->nfiles - LIST_ROWS); + int ty2 = LIST_Y + (LIST_H - th) * r->scroll / (nvis - LIST_ROWS); sk_fill(&r->p, SK_ACCENT, sbx, ty2, SB_W, th); } } @@ -482,35 +738,77 @@ static void draw_statusbar(Rigel *r) { sk_fill(&r->p, SK_SURFACE, 0, sy, WIN_W, STAT_H); sk_line(&r->p, SK_BORDER, 0, sy, WIN_W, sy); - char msg[128]; - if (r->sel_row >= 0 && r->sel_row < r->nfiles) { - char sb[32]; - fmt_size(r->files[r->sel_row].size, sb, sizeof(sb)); - snprintf(msg, sizeof(msg), "%d items — \"%s\" %s", - r->nfiles, r->files[r->sel_row].name, - r->files[r->sel_row].type == FT_DIR ? "(folder)" : sb); - } else { - snprintf(msg, sizeof(msg), "%d item%s", r->nfiles, r->nfiles==1?"":"s"); - } - sk_text(&r->p, r->sk.font_sm, SK_TEXT_DIM, 10, - sk_baseline(r->sk.font_sm, sy, STAT_H), msg); - - /* show-hidden indicator */ - if (r->show_hidden) { - const char *hint = "· showing hidden"; + if (r->search_on) { + /* show search bar */ + sk_fill(&r->p, 0x0A1020UL, FILE_X, sy, FILE_W, STAT_H); sk_text(&r->p, r->sk.font_sm, SK_TEXT_DIM, - WIN_W - sk_textw(&r->sk, r->sk.font_sm, hint) - 10, - sk_baseline(r->sk.font_sm, sy, STAT_H), hint); + FILE_X + 8, sk_baseline(r->sk.font_sm, sy, STAT_H), "Filter: "); + int pfx = FILE_X + 8 + sk_textw(&r->sk, r->sk.font_sm, "Filter: "); + if (r->search_len > 0) + sk_text(&r->p, r->sk.font_ui, SK_TEXT_HDR, pfx, + sk_baseline(r->sk.font_ui, sy, STAT_H), r->search_str); + /* cursor */ + int cx = pfx + sk_textw(&r->sk, r->sk.font_ui, r->search_str); + sk_fill(&r->p, SK_ACCENT, cx, sy + 4, 2, STAT_H - 8); + /* result count */ + char cnt[32]; + snprintf(cnt, sizeof(cnt), "%d match%s Esc: clear", + r->nfiltered, r->nfiltered == 1 ? "" : "es"); + sk_text(&r->p, r->sk.font_sm, SK_TEXT_DIM, + WIN_W - sk_textw(&r->sk, r->sk.font_sm, cnt) - 10, + sk_baseline(r->sk.font_sm, sy, STAT_H), cnt); + } else { + char msg[128]; + if (r->sel_row >= 0 && r->sel_row < r->nfiles) { + char sb[32]; + fmt_size(r->files[r->sel_row].size, sb, sizeof(sb)); + snprintf(msg, sizeof(msg), "%d items — \"%s\" %s", + r->nfiles, r->files[r->sel_row].name, + r->files[r->sel_row].type == FT_DIR ? "(folder)" : sb); + } else { + snprintf(msg, sizeof(msg), "%d item%s", r->nfiles, r->nfiles==1?"":"s"); + } + sk_text(&r->p, r->sk.font_sm, SK_TEXT_DIM, 10, + sk_baseline(r->sk.font_sm, sy, STAT_H), msg); + + if (r->show_hidden) { + const char *hint = "· showing hidden Ctrl+F: filter"; + sk_text(&r->p, r->sk.font_sm, SK_TEXT_DIM, + WIN_W - sk_textw(&r->sk, r->sk.font_sm, hint) - 10, + sk_baseline(r->sk.font_sm, sy, STAT_H), hint); + } else { + const char *hint = "Ctrl+F: filter"; + sk_text(&r->p, r->sk.font_sm, SK_TEXT_DIM, + WIN_W - sk_textw(&r->sk, r->sk.font_sm, hint) - 10, + sk_baseline(r->sk.font_sm, sy, STAT_H), hint); + } } } +static void draw_rename_overlay(Rigel *r) { + if (!r->rename_on) return; + int ry = WIN_H - STAT_H - 34; + sk_fill(&r->p, 0x0A1020UL, FILE_X, ry, FILE_W, 32); + sk_rect(&r->p, SK_ACCENT, FILE_X, ry, FILE_W, 32); + sk_text(&r->p, r->sk.font_sm, SK_TEXT_DIM, + FILE_X + 8, sk_baseline(r->sk.font_sm, ry, 32), "Rename: "); + int pfx = FILE_X + 8 + sk_textw(&r->sk, r->sk.font_sm, "Rename: "); + if (r->rename_len > 0) + sk_text(&r->p, r->sk.font_ui, SK_TEXT_HDR, + pfx, sk_baseline(r->sk.font_ui, ry, 32), r->rename_buf); + int cx = pfx + sk_textw(&r->sk, r->sk.font_ui, r->rename_buf); + sk_fill(&r->p, SK_ACCENT, cx, ry + 6, 2, 20); +} + static void draw(Rigel *r) { draw_toolbar(r); draw_sidebar(r); draw_col_header(r); draw_filelist(r); draw_statusbar(r); + draw_rename_overlay(r); draw_flush(r); + if (r->ctx_vis) ctx_draw(r); } /* ── Hit testing ─────────────────────────────────────────────────── */ @@ -523,7 +821,7 @@ static int toolbar_btn_at(int x, int y) { return 0; } -static int file_row_at(int x, int y) { +static int file_vis_row_at(int x, int y) { if (!sk_in_rect(x, y, FILE_X, LIST_Y, FILE_W, LIST_H)) return -1; return (y - LIST_Y) / ROW_H; } @@ -543,22 +841,27 @@ static int sidebar_item_at(int x, int y) { /* ── Event handlers ──────────────────────────────────────────────── */ static void on_motion(Rigel *r, int x, int y) { - int hrow = file_row_at(x, y); - if (hrow >= 0) hrow += r->scroll; - int hbtn = toolbar_btn_at(x, y); + int vrow = file_vis_row_at(x, y); + int hrow = -1; + if (vrow >= 0) { + int vis_idx = vrow + r->scroll; + hrow = get_fidx(r, vis_idx); + } + int hbtn = toolbar_btn_at(x, y); int hside = sidebar_item_at(x, y); int redraw = (hrow != r->hover_row || hbtn != r->hover_btn || hside != r->sidebar_hover); - r->hover_row = hrow; - r->hover_btn = hbtn; + r->hover_row = hrow; + r->hover_btn = hbtn; r->sidebar_hover = hside; if (redraw) draw(r); } static void on_scroll(Rigel *r, int dir) { - int max = r->nfiles - LIST_ROWS; + int nvis = get_nvis(r); + int max = nvis - LIST_ROWS; if (max < 0) max = 0; r->scroll += dir * 3; if (r->scroll < 0) r->scroll = 0; @@ -573,7 +876,12 @@ static void path_commit(Rigel *r) { nav_cd(r, expanded); } -static void on_click(Rigel *r, int x, int y, Time t) { +static void on_click(Rigel *r, int x, int y, Time t, int btn) { + /* dismiss context if open */ + if (r->ctx_vis) { ctx_hide(r); draw(r); return; } + /* dismiss rename if open and clicking outside */ + if (r->rename_on) { r->rename_on = 0; } + /* path bar */ int py = (TOOL_H - PATH_H) / 2; if (sk_in_rect(x, y, PATH_X, py, PATH_W, PATH_H)) { @@ -586,22 +894,23 @@ static void on_click(Rigel *r, int x, int y, Time t) { return; } - /* deactivate path edit if clicked elsewhere */ if (r->path_editing) { r->path_editing = 0; snprintf(r->path_buf, sizeof(r->path_buf), "%s", r->cwd); draw(r); } - /* toolbar buttons */ - int btn = toolbar_btn_at(x, y); - if (btn == 1) { nav_back(r); draw(r); return; } - if (btn == 2) { nav_fwd(r); draw(r); return; } - if (btn == 3) { nav_up(r); draw(r); return; } + /* toolbar buttons (left click only) */ + if (btn == Button1) { + int b = toolbar_btn_at(x, y); + if (b == 1) { nav_back(r); draw(r); return; } + if (b == 2) { nav_fwd(r); draw(r); return; } + if (b == 3) { nav_up(r); draw(r); return; } + } /* sidebar */ int si = sidebar_item_at(x, y); - if (si >= 0) { + if (si >= 0 && btn == Button1) { char expanded[4096]; expand_path(BKMARKS[si].path, expanded, sizeof(expanded)); nav_cd(r, expanded); @@ -611,37 +920,84 @@ static void on_click(Rigel *r, int x, int y, Time t) { /* column header */ int col = col_header_at(x, y); - if (col >= 0) { + if (col >= 0 && btn == Button1) { if (r->sort_col == col) r->sort_dir = -r->sort_dir; else { r->sort_col = col; r->sort_dir = 1; } g_sort_col = r->sort_col; g_sort_dir = r->sort_dir; qsort(r->files, r->nfiles, sizeof(FileEntry), cmp_files); + if (r->search_on) search_filter(r); draw(r); return; } /* file list */ - int ri = file_row_at(x, y); - if (ri < 0) return; - int row = ri + r->scroll; - if (row >= r->nfiles) return; + int vrow = file_vis_row_at(x, y); + if (vrow < 0) return; + int vis_idx = vrow + r->scroll; + int fidx = get_fidx(r, vis_idx); + if (fidx < 0) return; - /* double-click detection (300ms) */ - int dbl = (row == r->last_click_row && (t - r->last_click_time) < 300); + if (btn == Button3) { + r->sel_row = fidx; + draw(r); + ctx_show(r, x, y, fidx); + return; + } + + /* double-click detection */ + int dbl = (fidx == r->last_click_row && (t - r->last_click_time) < 300); r->last_click_time = t; - r->last_click_row = row; + r->last_click_row = fidx; if (dbl) { - open_entry(r, row); + open_entry(r, fidx); } else { - r->sel_row = (r->sel_row == row) ? -1 : row; + r->sel_row = (r->sel_row == fidx) ? -1 : fidx; } draw(r); } static void on_key(Rigel *r, XKeyEvent *ke) { KeySym sym = XLookupKeysym(ke, 0); + int mod = ke->state; + /* ── rename overlay ────────────────────────────────────── */ + if (r->rename_on) { + if (sym == XK_Escape) { + r->rename_on = 0; + } else if (sym == XK_Return || sym == XK_KP_Enter) { + /* commit rename */ + if (r->rename_len > 0 && r->rename_idx >= 0 + && r->rename_idx < r->nfiles) { + char old_path[4096 + 512]; + char new_path[4096 + 512]; + snprintf(old_path, sizeof(old_path), "%s/%s", + r->cwd, r->files[r->rename_idx].name); + snprintf(new_path, sizeof(new_path), "%s/%s", + r->cwd, r->rename_buf); + rename(old_path, new_path); + list_dir(r); + } + r->rename_on = 0; + } else if (sym == XK_BackSpace) { + if (r->rename_len > 0) + r->rename_buf[--r->rename_len] = '\0'; + } else { + char buf[16]; + int n = XLookupString(ke, buf, sizeof(buf)-1, NULL, NULL); + if (n > 0 && (unsigned char)buf[0] >= 0x20) { + buf[n] = '\0'; + int len = (int)strlen(r->rename_buf); + if (len + n < (int)sizeof(r->rename_buf) - 1) { + memcpy(r->rename_buf + len, buf, n + 1); + r->rename_len += n; + } + } + } + draw(r); return; + } + + /* ── path bar editing ──────────────────────────────────── */ if (r->path_editing) { if (sym == XK_Return || sym == XK_KP_Enter) { path_commit(r); @@ -666,7 +1022,6 @@ static void on_key(Rigel *r, XKeyEvent *ke) { } else if (sym == XK_End) { r->path_cursor = (int)strlen(r->path_buf); } else { - /* printable character */ char buf[16]; int n = XLookupString(ke, buf, sizeof(buf)-1, NULL, NULL); if (n > 0 && (unsigned char)buf[0] >= 32) { @@ -681,12 +1036,42 @@ static void on_key(Rigel *r, XKeyEvent *ke) { } } } - draw(r); - return; + draw(r); return; } - /* navigation shortcuts */ - int mod = ke->state; + /* ── search mode ───────────────────────────────────────── */ + if (r->search_on) { + if (sym == XK_Escape) { + r->search_on = 0; + r->search_len = 0; + r->search_str[0] = '\0'; + } else if (sym == XK_BackSpace) { + if (r->search_len > 0) { + r->search_str[--r->search_len] = '\0'; + search_filter(r); + } + if (r->search_len == 0) { + r->search_on = 0; + } + } else if (sym == XK_Return) { + /* open first result */ + if (r->nfiltered > 0) open_entry(r, r->filtered[0]); + } else { + char buf[8]; + int n = XLookupString(ke, buf, sizeof(buf)-1, NULL, NULL); + if (n > 0 && (unsigned char)buf[0] >= 0x20) { + buf[n] = '\0'; + if (r->search_len + n < (int)sizeof(r->search_str) - 1) { + memcpy(r->search_str + r->search_len, buf, n + 1); + r->search_len += n; + search_filter(r); + } + } + } + draw(r); return; + } + + /* ── normal navigation ─────────────────────────────────── */ if (sym == XK_BackSpace || (sym == XK_Left && (mod & Mod1Mask))) { nav_back(r); } else if (sym == XK_Right && (mod & Mod1Mask)) { @@ -696,7 +1081,6 @@ static void on_key(Rigel *r, XKeyEvent *ke) { } else if (sym == XK_Up) { if (r->sel_row > 0) r->sel_row--; else if (r->sel_row < 0 && r->nfiles > 0) r->sel_row = 0; - /* scroll to keep selection visible */ if (r->sel_row >= 0 && r->sel_row < r->scroll) r->scroll = r->sel_row; } else if (sym == XK_Down) { if (r->sel_row < r->nfiles - 1) r->sel_row++; @@ -708,11 +1092,36 @@ static void on_key(Rigel *r, XKeyEvent *ke) { on_scroll(r, -4); return; } else if (sym == XK_Page_Down) { on_scroll(r, 4); return; + } else if (sym == XK_F2) { + /* rename selected */ + if (r->sel_row >= 0 && r->sel_row < r->nfiles) { + snprintf(r->rename_buf, sizeof(r->rename_buf), "%s", + r->files[r->sel_row].name); + r->rename_len = (int)strlen(r->rename_buf); + r->rename_idx = r->sel_row; + r->rename_on = 1; + } + } else if (sym == XK_Delete) { + /* trash selected */ + if (r->sel_row >= 0 && r->sel_row < r->nfiles) { + char cmd[4096 + 512]; + snprintf(cmd, sizeof(cmd), + "gio trash \"%s/%s\" 2>/dev/null" + " || mv \"%s/%s\" ~/.local/share/Trash/files/ 2>/dev/null", + r->cwd, r->files[r->sel_row].name, + r->cwd, r->files[r->sel_row].name); + system(cmd); + list_dir(r); + } } else if (sym == XK_h && (mod & ControlMask)) { r->show_hidden = !r->show_hidden; list_dir(r); + } else if ((sym == XK_f && (mod & ControlMask)) || sym == XK_F3) { + r->search_on = 1; + r->search_len = 0; + r->search_str[0] = '\0'; + search_filter(r); } else if (sym == XK_l && (mod & ControlMask)) { - /* focus path bar */ r->path_editing = 1; snprintf(r->path_buf, sizeof(r->path_buf), "%s", r->cwd); r->path_cursor = (int)strlen(r->path_buf); @@ -738,8 +1147,8 @@ static void init(Rigel *r) { r->hover_row = -1; r->sidebar_hover = -1; r->last_click_row = -1; + r->ctx_hover = -1; - /* start in home directory */ const char *home = getenv("HOME"); if (!home || chdir(home) != 0) home = "/"; snprintf(r->history[0], sizeof(r->history[0]), "%s", home); @@ -749,7 +1158,6 @@ static void init(Rigel *r) { list_dir(r); snprintf(r->path_buf, sizeof(r->path_buf), "%s", r->cwd); - /* window */ XSetWindowAttributes swa = { .background_pixel = sk_color(&r->sk, SK_BG), .event_mask = ExposureMask | ButtonPressMask | ButtonReleaseMask @@ -762,11 +1170,9 @@ static void init(Rigel *r) { CWBackPixel | CWEventMask, &swa); r->gc = XCreateGC(r->sk.dpy, r->win, 0, NULL); - /* double-buffer */ r->buf = XCreatePixmap(r->sk.dpy, r->win, WIN_W, WIN_H, r->sk.depth); r->p = (SKPainter){ .app = &r->sk, .drw = r->buf, .gc = r->gc }; - /* window hints */ XStoreName(r->sk.dpy, r->win, "Rigel — File Manager"); XClassHint *ch = XAllocClassHint(); if (ch) { ch->res_name = (char*)"rigel"; ch->res_class = (char*)"Rigel"; @@ -792,13 +1198,45 @@ static void run(Rigel *r) { while (r->running) { XEvent ev; XNextEvent(r->sk.dpy, &ev); + + /* ── context menu events ─────────────────────────────── */ + if (r->ctx_vis && ev.xany.window == r->ctx_win) { + if (ev.type == Expose) { + ctx_draw(r); + } else if (ev.type == MotionNotify) { + int it = ctx_item_at(ev.xmotion.y); + if (it != r->ctx_hover) { + r->ctx_hover = it; + ctx_draw(r); + } + } else if (ev.type == ButtonPress) { + int it = ctx_item_at(ev.xbutton.y); + ctx_hide(r); + if (it >= 0 && !CTX[it].sep) ctx_exec(r, it); + draw(r); + } else if (ev.type == LeaveNotify) { + r->ctx_hover = -1; + ctx_draw(r); + } + continue; + } + /* click outside context menu */ + if (r->ctx_vis && (ev.type == ButtonPress || ev.type == KeyPress)) { + ctx_hide(r); + draw(r); + if (ev.type == KeyPress) on_key(r, &ev.xkey); + continue; + } + + /* ── main window events ──────────────────────────────── */ switch (ev.type) { case Expose: if (ev.xexpose.count == 0) draw(r); break; case ButtonPress: - if (ev.xbutton.button == Button1) - on_click(r, ev.xbutton.x, ev.xbutton.y, ev.xbutton.time); + if (ev.xbutton.button == Button1 || ev.xbutton.button == Button3) + on_click(r, ev.xbutton.x, ev.xbutton.y, ev.xbutton.time, + ev.xbutton.button); else if (ev.xbutton.button == Button4) on_scroll(r, -1); else if (ev.xbutton.button == Button5) on_scroll(r, 1); break; diff --git a/skykit/template/Makefile b/skykit/template/Makefile new file mode 100644 index 0000000..48ced23 --- /dev/null +++ b/skykit/template/Makefile @@ -0,0 +1,14 @@ +CC = gcc +CFLAGS = -O2 -Wall -I.. $(shell pkg-config --cflags x11 xft fontconfig) +LIBS = -L.. -lskykit $(shell pkg-config --libs x11 xft fontconfig) + +hello: hello.c ../libskykit.a + $(CC) $(CFLAGS) -o hello hello.c $(LIBS) + +../libskykit.a: + $(MAKE) -C .. + +clean: + rm -f hello + +.PHONY: clean diff --git a/skykit/template/hello.c b/skykit/template/hello.c new file mode 100644 index 0000000..f4c9025 --- /dev/null +++ b/skykit/template/hello.c @@ -0,0 +1,146 @@ +/* hello.c — minimal SkyKit application template + * + * Build: + * cd skykit && make (builds libskykit.a) + * cd template + * gcc -O2 hello.c -I.. -L.. -lskykit \ + * $(pkg-config --libs --cflags x11 xft fontconfig) -o hello + * + * This template shows the minimum boilerplate for a native OrionOS app: + * - sk_app_init() opens the display and loads fonts + * - Create a window and a Pixmap back-buffer + * - Wrap them in an SKPainter + * - Draw to the Pixmap, then XCopyArea to the window + * - Respond to Expose, KeyPress, ButtonPress, ClientMessage + */ +#include +#include +#include +#include +#include +#include +#include "skykit.h" + +#define WIN_W 640 +#define WIN_H 400 + +int main(void) { + SKApp app; + sk_app_init(&app); + + /* ── Window ──────────────────────────────────────────── */ + XSetWindowAttributes swa = { + .background_pixel = sk_color(&app, SK_BG), + .event_mask = ExposureMask | KeyPressMask + | ButtonPressMask | StructureNotifyMask, + }; + Window win = XCreateWindow( + app.dpy, app.root, + 200, 100, WIN_W, WIN_H, 0, + app.depth, InputOutput, app.vis, + CWBackPixel | CWEventMask, &swa); + + /* Back-buffer for flicker-free rendering */ + GC gc = XCreateGC(app.dpy, win, 0, NULL); + Pixmap buf = XCreatePixmap(app.dpy, win, WIN_W, WIN_H, app.depth); + + /* SKPainter wraps the back-buffer */ + SKPainter p = { .app = &app, .drw = buf, .gc = gc }; + + /* WM_DELETE_WINDOW protocol */ + Atom wm_del = XInternAtom(app.dpy, "WM_DELETE_WINDOW", False); + XSetWMProtocols(app.dpy, win, &wm_del, 1); + + XStoreName(app.dpy, win, "Hello OrionOS"); + XClassHint *ch = XAllocClassHint(); + if (ch) { ch->res_name = (char*)"hello"; ch->res_class = (char*)"Hello"; XSetClassHint(app.dpy, win, ch); XFree(ch); } + + XMapWindow(app.dpy, win); + XFlush(app.dpy); + + /* Click counter for demo */ + int clicks = 0; + + /* ── Draw function ───────────────────────────────────── */ + /* We define a lambda-style local redraw — in a real app this would be + * a proper function taking your app struct. */ + #define REDRAW() do { \ + sk_fill(&p, SK_BG, 0, 0, WIN_W, WIN_H); \ + \ + /* Accent bar at top */ \ + sk_fill(&p, SK_ACCENT, 0, 0, WIN_W, 3); \ + \ + /* Centred heading */ \ + const char *title = "Hello, OrionOS"; \ + int tw = sk_textw(&app, app.font_hdr, title); \ + sk_text(&p, app.font_hdr, SK_TEXT_HDR, \ + (WIN_W - tw) / 2, sk_baseline(app.font_hdr, 60, 32), title); \ + \ + /* Subtitle */ \ + const char *sub = "A SkyKit native application"; \ + int sw = sk_textw(&app, app.font_ui, sub); \ + sk_text(&p, app.font_ui, SK_TEXT_DIM, \ + (WIN_W - sw) / 2, sk_baseline(app.font_ui, 100, 24), sub); \ + \ + /* Click counter button */ \ + SKBtn btn = { (WIN_W - 160) / 2, 160, 160, 36, "Click me", 1 }; \ + sk_draw_btn(&p, &btn, 0); \ + \ + /* Counter label */ \ + char cnt[64]; snprintf(cnt, sizeof(cnt), "Clicked %d time%s", clicks, clicks==1?"":"s"); \ + int cw = sk_textw(&app, app.font_ui, cnt); \ + sk_text(&p, app.font_ui, SK_TEXT, (WIN_W - cw) / 2, \ + sk_baseline(app.font_ui, 215, 24), cnt); \ + \ + /* Hint */ \ + const char *hint = "Press Q or Esc to quit"; \ + int hw = sk_textw(&app, app.font_sm, hint); \ + sk_text(&p, app.font_sm, SK_TEXT_DIM, (WIN_W - hw) / 2, \ + sk_baseline(app.font_sm, WIN_H - 36, 24), hint); \ + \ + /* Blit to window */ \ + XCopyArea(app.dpy, buf, win, gc, 0, 0, WIN_W, WIN_H, 0, 0); \ + XFlush(app.dpy); \ + } while (0) + + REDRAW(); + + /* ── Event loop ──────────────────────────────────────── */ + int running = 1; + while (running) { + XEvent ev; + XNextEvent(app.dpy, &ev); + switch (ev.type) { + case Expose: + if (ev.xexpose.count == 0) REDRAW(); + break; + case KeyPress: { + KeySym ks = XLookupKeysym(&ev.xkey, 0); + if (ks == XK_q || ks == XK_Q || ks == XK_Escape) running = 0; + break; + } + case ButtonPress: + if (ev.xbutton.button == Button1) { + /* check if inside the button */ + int bx = (WIN_W - 160) / 2, by = 160; + if (sk_in_rect(ev.xbutton.x, ev.xbutton.y, bx, by, 160, 36)) { + clicks++; + REDRAW(); + } + } + break; + case ClientMessage: + if ((Atom)ev.xclient.data.l[0] == wm_del) running = 0; + break; + } + } + + #undef REDRAW + + sk_app_free(&app); + XFreePixmap(app.dpy, buf); + XFreeGC(app.dpy, gc); + XDestroyWindow(app.dpy, win); + XCloseDisplay(app.dpy); + return 0; +} diff --git a/wm/src/menu.c b/wm/src/menu.c index 080f17f..91db61e 100644 --- a/wm/src/menu.c +++ b/wm/src/menu.c @@ -8,7 +8,7 @@ static const MenuItem MENU_ITEMS[] = { { "App Launcher", "orionlauncher &" }, { "Terminal", "alacritty &" }, { "File Manager", "rigel &" }, - { "Text Editor", "mousepad &" }, + { "Text Editor", "quill &" }, { "──────────────────", NULL }, { "Web Browser", "firefox &" }, { "System Monitor", "alacritty -T 'System Monitor' -e htop &" },