/* 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 #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; }