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:
1 parent
d19aca2333
commit
a9efede702
12 files changed
+2186
-92
No files matched your search
@@ -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
|
||||
+503
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user