Phase 4-6: Quill editor, Lens viewer, Rigel context menu, CI, website

Phase 4 — native apps:
- quill/: text editor (monospace, double-buffered, find bar, undo, open/save)
- lens/: image viewer using imlib2 (zoom, pan, rotate, directory nav)
- wm/src/menu.c: launch quill instead of mousepad
- iso/docker/entrypoint.sh: add imlib2 package; install quill + lens binaries
- skykit/template/: developer app template demonstrating the SkyKit API

Phase 5 — file system and search:
- rigel/rigel.c: right-click context menu (open, open in quill, copy path,
  rename, trash, properties); Ctrl+F search/filter bar with name highlighting;
  F2 rename-in-place overlay; Delete key moves to trash

Phase 6 — distribution:
- .github/workflows/build-iso.yml: GitHub Actions CI that compiles all
  components and builds a bootable ISO on every tag push; creates a GitHub
  release with sha256 checksums

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 4.6 committed 2026-07-24 10:07:45 -04:00
1 parent d19aca2333
commit a9efede702
12 files changed
+2186 -92

No files matched your search

+503
View File
@@ -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 <X11/Xlib.h>
#include <X11/Xutil.h>
#include <X11/Xatom.h>
#include <X11/keysym.h>
#include <Imlib2.h>
#include <dirent.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <math.h>
#include <unistd.h>
#include <sys/stat.h>
#include <libgen.h>
#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 <image-file>\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;
}