/* * xpet: * * a suckless, X (and hopefully wayland) implementation of * xneko and other on screen pets * * > uint 2025 */ #include #include #include #include #include #include #include #include #include #include "xpet.h" #include "config.h" void create_window(void); void draw_bubble(void); void get_mouse_pos(void); void grab_keys(void); void hide_speech_bubble(void); void load_animations(void); void move_to(int tx, int ty); enum state find_octant(int dx, int dy); void on_button_press(XButtonEvent* b); void on_button_release(XButtonEvent* b); void on_key(KeySym sym); void on_motion(XMotionEvent* m); void pick_random_destination(void); void quit(void); void run(void); void set_pet_state(enum state ns); void setup(void); void show_speech_bubble(const char* s); void step(enum state d, double* step_x, double* step_y); void update_animation(void); void wander(void); Bool walking(enum state s); void xsleep(long ms); Display* dpy; Window root; int scr; int scr_width; int scr_height; XpmAttributes xpm_attrs; XFontSet font_set; struct mouse mouse; struct pet pet; void create_window(void) { pet.x = scr_width / 2; pet.y = scr_height / 2; pet.subpixel_x = pet.x; pet.subpixel_y = pet.y; XSetWindowAttributes attrs; attrs.override_redirect = True; pet.window = XCreateWindow( dpy, root, pet.x, pet.y, 64, 64, 0, CopyFromParent, InputOutput, CopyFromParent, CWOverrideRedirect, &attrs ); struct frame* f = &pet.current_animation->frames[0]; XShapeCombineMask( dpy, pet.window, ShapeBounding, 0, 0, f->mask, ShapeSet ); XSetWindowBackgroundPixmap(dpy, pet.window, f->pix); XSelectInput( dpy, pet.window, ButtonPressMask | ButtonReleaseMask | PointerMotionMask ); XMapWindow(dpy, pet.window); XClearWindow(dpy, pet.window); XRaiseWindow(dpy, pet.window); } void draw_bubble(void) { if (!pet.speech || !pet.bubble_window) { return; } XClearWindow(dpy, pet.bubble_window); GC graphics_context = DefaultGC(dpy, scr); XSetForeground(dpy, graphics_context, BlackPixel(dpy, scr)); if (font_set) { XRectangle ink; XRectangle log; XmbTextExtents(font_set, pet.speech, (int)strlen(pet.speech), &ink, &log); XmbDrawString( dpy, pet.bubble_window, font_set, graphics_context, SPEECH_PAD_X, SPEECH_PAD_Y - log.y, pet.speech, strlen(pet.speech) ); } else { XFontStruct* f = XQueryFont(dpy, XGContextFromGC(graphics_context)); int ascent = f ? f->ascent : 12; XDrawString( dpy, pet.bubble_window, graphics_context, SPEECH_PAD_X, SPEECH_PAD_Y + ascent, pet.speech, strlen(pet.speech) ); if (f) { XFreeFontInfo(NULL, f, 1); } } } void get_mouse_pos(void) { Window root_ret; Window child_ret; int x_ret; int y_ret; int winx_ret ; int winy_ret ; unsigned int mask_ret; XQueryPointer( dpy, root, &root_ret, &child_ret, &x_ret, &y_ret, &winx_ret, &winy_ret, &mask_ret ); mouse.x = x_ret; mouse.y = y_ret; } void grab_keys(void) { unsigned int mods[] = { 0, LockMask, Mod2Mask, LockMask | Mod2Mask }; for (unsigned i = 0; i < N_KEYBINDS; i++) { KeyCode code = XKeysymToKeycode(dpy, bindings[i].sym); for (unsigned j = 0; j < sizeof(mods) / sizeof(mods[0]); j++) { XGrabKey( dpy, code, bindings[i].mask | mods[j], root, True, GrabModeAsync, GrabModeAsync ); } } } void hide_speech_bubble(void) { if (pet.bubble_window) { XUnmapWindow(dpy, pet.bubble_window); } pet.speech = NULL; pet.speech_time = 0; } void load_animations(void) { memset(&xpm_attrs, 0, sizeof(xpm_attrs)); for (int i = 0; i < STATE_LAST; i++) { int frame_count = animations[i].n_frames; if (frame_count == 0) { char path[512]; frame_count = 0; for (;;) { snprintf(path, sizeof(path), "%s/%s/%d.xpm", PET_ASSET_DIR, animations[i].name, frame_count ); FILE* f = fopen(path, "r"); if (!f) { break; } fclose(f); frame_count++; } if (frame_count == 0) { fprintf(stderr, "xpet: warning: no frames for '%s'\n", animations[i].name ); animations[i].frames = NULL; continue; } } animations[i].frames = malloc(sizeof(struct frame) * frame_count); if (!animations[i].frames) { perror("malloc"); exit(1); } for (int frame_index = 0; frame_index < frame_count; frame_index++) { char path[512]; snprintf(path, sizeof(path), "%s/%s/%d.xpm", PET_ASSET_DIR, animations[i].name, frame_index ); Pixmap pixmap = 0; Pixmap mask = 0; int xpm_result = XpmReadFileToPixmap( dpy, root, path, &pixmap, &mask, &xpm_attrs ); if (xpm_result != XpmSuccess) { fprintf(stderr, "xpet: load failed '%s': %d\n", path, xpm_result); exit(EXIT_FAILURE); } animations[i].frames[frame_index].pix = pixmap; animations[i].frames[frame_index].mask = mask; animations[i].frames[frame_index].duration = animations[i].frame_durations ? animations[i].frame_durations[frame_index] : FRAME_DURATION; } animations[i].n_frames = frame_count; } } void move_to(int target_x, int target_y) { double delta_x = target_x - pet.subpixel_x; double delta_y = target_y - pet.subpixel_y; double distance_squared = delta_x * delta_x + delta_y * delta_y; if (distance_squared < 4.0) { set_pet_state(IDLE); return; } enum state direction = find_octant((int)delta_x, (int)delta_y); double step_x = 0; double step_y = 0; step(direction, &step_x, &step_y); if (distance_squared <= (PET_SPEED * PET_SPEED)) { pet.subpixel_x = target_x; pet.subpixel_y = target_y; } else { pet.subpixel_x += step_x; pet.subpixel_y += step_y; } pet.x = (int)(pet.subpixel_x + (pet.subpixel_x >= 0 ? 0.5 : -0.5)); pet.y = (int)(pet.subpixel_y + (pet.subpixel_y >= 0 ? 0.5 : -0.5)); set_pet_state(direction); XMoveWindow(dpy, pet.window, pet.x, pet.y); } enum state find_octant(int delta_x, int delta_y) { int abs_delta_x = ABS(delta_x); int abs_delta_y = ABS(delta_y); /* if horizontal movement stronger than vertical */ if (abs_delta_x > abs_delta_y * 2) { return delta_x > 0 ? E : W; } /* if vertical movement stronger than horizontal */ if (abs_delta_y > abs_delta_x * 2) { return delta_y > 0 ? S : N; } return delta_x > 0 ? (delta_y < 0 ? NE : SE) : (delta_y < 0 ? NW : SW); } void on_button_press(XButtonEvent* b) { if (b->button == Button1) { pet.was_chasing = pet.chasing; pet.was_frozen = pet.frozen; pet.dragging = True; pet.drag_offset_x = b->x; pet.drag_offset_y = b->y; pet.frozen = True; pet.chasing = False; set_pet_state(DRAGGED); } else if (b->button == Button3) { if (pet.state != HAPPY) { pet.previous_state = pet.state; set_pet_state(HAPPY); pet.happy_time = 0; int n = 0; while (pet_phrases[n]) { n++; } if (n > 0) { show_speech_bubble(pet_phrases[rand() % n]); } } } } void on_button_release(XButtonEvent* b) { if (b->button != Button1) { return; } pet.dragging = False; pet.frozen = pet.was_frozen; pet.chasing = pet.was_chasing; pet.subpixel_x = pet.x; pet.subpixel_y = pet.y; if (pet.was_frozen) { set_pet_state(IDLE); pet.frozen_time = 0; } else if (!pet.was_chasing) { pick_random_destination(); set_pet_state(IDLE); } else { set_pet_state(E); } } void on_key(KeySym sym) { if (sym == bindings[0].sym) { /* chasing */ pet.chasing = !pet.chasing; if (pet.chasing) { pet.frozen = False; set_pet_state(E); } else { pick_random_destination(); } } else if (sym == bindings[1].sym) { /* freeze */ pet.frozen = !pet.frozen; if (pet.frozen) { set_pet_state(IDLE); pet.frozen_time = 0; } } else if (sym == bindings[2].sym) { /* quit */ quit(); } } void on_motion(XMotionEvent* m) { (void)m; if (!pet.dragging) { return; } get_mouse_pos(); pet.x = mouse.x - pet.drag_offset_x; pet.y = mouse.y - pet.drag_offset_y; XMoveWindow(dpy, pet.window, pet.x, pet.y); if (pet.speech) { show_speech_bubble(pet.speech); } } void pick_random_destination(void) { int min_x = WANDER_MARGIN; int min_y = WANDER_MARGIN; int max_x = scr_width - WANDER_MARGIN - 64; int max_y = scr_height - WANDER_MARGIN - 64; /* clamping */ if (max_x < min_x) { max_x = min_x; } if (max_y < min_y) { max_y = min_y; } pet.target_x = min_x + (rand() % (max_x - min_x + 1)); pet.target_y = min_y + (rand() % (max_y - min_y + 1)); pet.wander_wait = 0; } void quit(void) { if (pet.bubble_window) { XDestroyWindow(dpy, pet.bubble_window); } if (font_set) { XFreeFontSet(dpy, font_set); } for (int i = 0; i < STATE_LAST; i++) { if (animations[i].frames) { for (int j = 0; j < animations[i].n_frames; j++) { XFreePixmap(dpy, animations[i].frames[j].pix); XFreePixmap(dpy, animations[i].frames[j].mask); } free(animations[i].frames); } } XCloseDisplay(dpy); exit(EXIT_SUCCESS); } void run(void) { for (;;) { while (XPending(dpy)) { XEvent ev; XNextEvent(dpy, &ev); if (ev.type == KeyPress) { on_key(XLookupKeysym(&ev.xkey, 0)); } else if (ev.type == ButtonPress && ev.xbutton.window == pet.window) { on_button_press(&ev.xbutton); } else if (ev.type == ButtonRelease && ev.xbutton.window == pet.window) { on_button_release(&ev.xbutton); } else if (ev.type == MotionNotify && ev.xmotion.window == pet.window) { on_motion(&ev.xmotion); } else if (ev.type == Expose && ev.xexpose.window == pet.bubble_window) { draw_bubble(); } } if (pet.speech) { pet.speech_time += PET_REFRESH; if (pet.speech_time >= SPEECH_DURATION) { hide_speech_bubble(); } } if (pet.state == HAPPY) { pet.happy_time += PET_REFRESH; if (pet.happy_time >= HAPPY_DURATION) { set_pet_state(pet.previous_state); } } else if (pet.dragging) { /* animate only */ } else if (!pet.frozen) { if (pet.chasing) { get_mouse_pos(); move_to(mouse.x, mouse.y); } else { wander(); } } else { pet.frozen_time += PET_REFRESH; if (pet.frozen_time >= SLEEP_DELAY && pet.state != SLEEPING) { set_pet_state(SLEEPING); } } update_animation(); /* keep windows on top of others */ XRaiseWindow(dpy, pet.window); if (pet.bubble_window) { XRaiseWindow(dpy, pet.bubble_window); } xsleep(PET_REFRESH); } } void set_pet_state(enum state new_state) { if (pet.state != new_state) { if (walking(pet.state) && walking(new_state)) { int current_frame = pet.current_frame; long frame_time = pet.frame_time; pet.state = new_state; pet.current_animation = &animations[new_state]; if (pet.current_animation->n_frames > 0) { pet.current_frame = current_frame % pet.current_animation->n_frames; int dur = pet.current_animation->frames[pet.current_frame].duration; pet.frame_time = (frame_time < dur) ? frame_time : 0; } else { pet.current_frame = pet.frame_time = 0; } } else { pet.state = new_state; pet.current_animation = &animations[new_state]; pet.current_frame = 0; pet.frame_time = 0; } } else if (pet.current_animation != &animations[new_state]) { pet.current_animation = &animations[new_state]; if (!walking(new_state)) { pet.current_frame = pet.frame_time = 0; } else if (pet.current_animation->n_frames > 0) { pet.current_frame %= pet.current_animation->n_frames; int duration = pet.current_animation->frames[pet.current_frame].duration; if (pet.frame_time >= duration) { pet.frame_time = 0; } } } } void setup(void) { /* for utf8 support */ setlocale(LC_ALL, ""); dpy = XOpenDisplay(NULL); if (!dpy) { fputs("xpet: no display\n", stderr); exit(1); } scr = DefaultScreen(dpy); scr_width = DisplayWidth(dpy, scr); scr_height = DisplayHeight(dpy, scr); root = RootWindow(dpy, scr); char** missing; /* missing character sets */ int n_missing; char* def; /* default string */ const char* patterns[] = { "-*-*-medium-r-*-*-14-*-*-*-*-*-*-*", "fixed", "*", NULL }; for (int i = 0; !font_set && patterns[i]; i++) { font_set = XCreateFontSet( dpy, patterns[i], &missing, &n_missing, &def ); if (font_set && n_missing > 0) { XFreeStringList(missing); } } if (!font_set) { fputs("xpet: warn: no fontset; non-ascii will fallback\n", stderr); } srand((unsigned)time(NULL)); load_animations(); pet.current_frame = 0; pet.frame_time = 0; pet.frozen_time = 0; pet.happy_time = 0; pet.previous_state = IDLE; pet.dragging = False; pet.was_chasing = False; pet.was_frozen = False; pet.speech = NULL; pet.speech_time = 0; pet.bubble_window = 0; pet.chasing = False; pet.frozen = False; set_pet_state(IDLE); pick_random_destination(); create_window(); grab_keys(); XSelectInput(dpy, root, KeyPressMask); } void show_speech_bubble(const char* s) { if (!s) { return; } if (!font_set) { for (const unsigned char* p = (const unsigned char*)s; *p; p++) { if (*p & 0x80) { /* if no font set, replace it with ":3" */ s = ":3"; break; } } } pet.speech = s; pet.speech_time = 0; if (!pet.bubble_window) { XSetWindowAttributes attr; attr.override_redirect = True; attr.background_pixel = WhitePixel(dpy, scr); attr.border_pixel = BlackPixel(dpy, scr); pet.bubble_window = XCreateWindow( dpy, root, 0, 0, 10, 10, 2, CopyFromParent, InputOutput, CopyFromParent, CWOverrideRedirect | CWBackPixel | CWBorderPixel, &attr ); XSelectInput(dpy, pet.bubble_window, ExposureMask); } int text_width = 0; int text_height = 0; if (font_set) { XRectangle ink, log; XmbTextExtents(font_set, s, (int)strlen(s), &ink, &log); text_width = log.width; text_height = log.height; } else { GContext gcontext = XGContextFromGC(DefaultGC(dpy, scr)); XFontStruct* font = XQueryFont(dpy, gcontext); if (font) { text_width = XTextWidth(font, s, (int)strlen(s)); text_height = font->ascent + font->descent; XFreeFontInfo(NULL, font, 1); } } int bw = CLAMP(text_width + 2 * SPEECH_PAD_X, SPEECH_MIN_W, 1 << 15); int bh = CLAMP(text_height + 2 * SPEECH_PAD_Y, SPEECH_MIN_H, 1 << 15); int bx = CLAMP(pet.x + 32 - bw / 2, 10, scr_width - 10 - bw); int by = CLAMP(pet.y - bh - 10, 10, scr_height - 10 - bh); XMoveResizeWindow(dpy, pet.bubble_window, bx, by, bw, bh); XMapWindow(dpy, pet.bubble_window); draw_bubble(); XRaiseWindow(dpy, pet.bubble_window); } void step(enum state d, double* step_x, double* step_y) { const double s = PET_SPEED; const double ds = PET_SPEED * 0.70710678118654752440; /* sqrt(2)^-1 */ switch (d) { case E: *step_x = s; *step_y = 0; break; case W: *step_x = -s; *step_y = 0; break; case N: *step_x = 0; *step_y = -s; break; case S: *step_x = 0; *step_y = s; break; case NE: *step_x = ds; *step_y = -ds; break; case SE: *step_x = ds; *step_y = ds; break; case NW: *step_x = -ds; *step_y = -ds; break; case SW: *step_x = -ds; *step_y = ds; break; default: *step_x = 0; *step_y = 0; break; } } void update_animation(void) { if (!pet.current_animation || pet.current_animation->n_frames <= 0) { if (pet.state != IDLE) { set_pet_state(IDLE); } return; } pet.frame_time += PET_REFRESH; struct frame* frame_ptr = &pet.current_animation->frames[pet.current_frame]; if (pet.frame_time >= frame_ptr->duration) { pet.frame_time = 0; pet.current_frame++; if (pet.current_frame >= pet.current_animation->n_frames) { pet.current_frame = pet.current_animation->loop ? 0 : pet.current_animation->n_frames - 1; } frame_ptr = &pet.current_animation->frames[pet.current_frame]; XShapeCombineMask(dpy, pet.window, ShapeBounding, 0, 0, frame_ptr->mask, ShapeSet); XSetWindowBackgroundPixmap(dpy, pet.window, frame_ptr->pix); XClearWindow(dpy, pet.window); } } void wander(void) { double delta_x = (double)pet.target_x - pet.subpixel_x; double delta_y = (double)pet.target_y - pet.subpixel_y; double distance_squared = delta_x * delta_x + delta_y * delta_y; if (distance_squared < 4.0) { if (pet.wander_wait <= 0) { pet.wander_wait = WANDER_MIN_WAIT + (rand() % (WANDER_MAX_WAIT - WANDER_MIN_WAIT)); set_pet_state(IDLE); } else { pet.wander_wait -= PET_REFRESH; if (pet.wander_wait <= 0) { pick_random_destination(); } } return; } move_to(pet.target_x, pet.target_y); } Bool walking(enum state s) { return s == N || s == S || s == E || s == W || s == NW || s == NE || s == SW || s == SE; } void xsleep(long ms) { struct timeval tv = {ms / 1000, (int)((ms % 1000) * 1000)}; select(0, 0, 0, 0, &tv); } int main(int argc, char** argv) { (void)argv; if (argc > 1) { printf("xpets " VERSION "\n> uint 2025\n"); return 0; } setup(); run(); return 0; }