Files
OrionOS/lens/lens.c
T
nmemmertandClaude Sonnet 4.6 a9efede702 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>
2026-07-24 10:07:45 -04:00

504 lines
15 KiB
C
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* 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;
}