Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 6 additions & 7 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ Each instance runs 3 threads + 1 child process:
| **Main thread** | Wayland event loop | `poll()` on `wl_display` fd, dispatches protocol events, handles config reload ticks |
| **Animation thread** | pthread | Runs frame state machine, calls `draw_bar()` when frame changes, sleeps via `eventfd` when idle |
| **Config watcher** | pthread | `inotify` on config file, debounces (300ms), triggers hot-reload |
| **Input child** | fork | Reads `/dev/input/eventX` via `poll()`, writes atomic key state + eventfd wake signal |
| **Input child** | fork | Reads `/dev/input/eventX` via `poll()`, pushes keycodes to a shared ring + eventfd wake signal |

## Data Flow

Expand All @@ -71,16 +71,16 @@ Each instance runs 3 threads + 1 child process:
Input Child Process
(poll on evdev fds)
|
| atomic_store(any_key_pressed, 1)
| atomic_store(last_key_code, code)
| write(eventfd) -- wake animation thread
| key_ring_push(key_ring, code) -- per key, lock-free
| write(eventfd) -- wake animation thread
|
v
Animation Thread
(poll on eventfd, nanosleep at FPS rate)
|
| anim_update_state() under anim_lock
| selects frame 0-4 based on key + hand mapping + sleep state
| drains key_ring -> per-paw deadlines -> frame 0-4
| (both-down when both paws live; sleep takes priority)
|
v
draw_bar() under anim_lock
Expand Down Expand Up @@ -144,8 +144,7 @@ Version negotiation uses `MIN(advertised, desired)` to handle compositors with o
|-----------|----------|-------|

| `anim_lock` (pthread_mutex) | `anim_index`, `pixels`, `surface`, `buffer`, `current_config` pointer, cached frames | Animation thread + Wayland main thread |
| `atomic_int any_key_pressed` | Key press flag | Input child -> Animation thread (via `MAP_SHARED` mmap) |
| `atomic_int last_key_code` | Last keycode for hand mapping | Input child -> Animation thread (via `MAP_SHARED` mmap) |
| `key_ring_t` (atomic `head` + per-consumer local `tail`) | Keycode stream | Input child (producer) -> Animation thread(s) (consumers) via `MAP_SHARED` mmap |
| `atomic_bool configured` | Surface ready flag | Wayland callbacks -> Animation thread |
| `atomic_bool fullscreen_detected` | Fullscreen state | Fullscreen module -> draw_bar() |
| `atomic_bool g_reload_pending` | Config change flag | Config watcher -> Main thread tick |
Expand Down
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,16 @@

All notable changes to this project will be documented in this file.

## [Unreleased]

### Fixed

- **Concurrent paw animation** - Pressing keys on both sides of the keyboard now
moves both paws at once (the `both-down` frame). Previously only the
last-pressed key's paw animated. Keycodes now flow through a lock-free ring
buffer to per-paw timers, which also fixes a multi-monitor keypress race on the
old single shared flag. Fixes #78.

## [2.0.0] - 2026-04-05

### Breaking Changes
Expand Down
8 changes: 7 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -204,7 +204,13 @@ $(BUILDDIR)/test_config: $(TESTDIR)/test_config.c $(CONFIG_TEST_DEPS) | $(OBJDIR
$(BUILDDIR)/test_memory: $(TESTDIR)/test_memory.c $(MEMORY_TEST_DEPS) | $(OBJDIR)
$(CC) $(TEST_CFLAGS) $^ -o $@ $(TEST_LDFLAGS)

TEST_BINARIES = $(BUILDDIR)/test_config $(BUILDDIR)/test_memory
$(BUILDDIR)/test_key_ring: $(TESTDIR)/test_key_ring.c | $(OBJDIR)
$(CC) $(TEST_CFLAGS) $^ -o $@ $(TEST_LDFLAGS)

$(BUILDDIR)/test_paw_frame: $(TESTDIR)/test_paw_frame.c | $(OBJDIR)
$(CC) $(TEST_CFLAGS) $^ -o $@ $(TEST_LDFLAGS)

TEST_BINARIES = $(BUILDDIR)/test_config $(BUILDDIR)/test_memory $(BUILDDIR)/test_key_ring $(BUILDDIR)/test_paw_frame

test: $(TEST_BINARIES)
@echo "Running tests..."
Expand Down
3 changes: 0 additions & 3 deletions include/graphics/animation.h
Original file line number Diff line number Diff line change
Expand Up @@ -42,9 +42,6 @@ BONGOCAT_NODISCARD bongocat_error_t animation_start(void);
// Cleanup animation resources
void animation_cleanup(void);

// Trigger key press animation
void animation_trigger(void);

// =============================================================================
// RENDERING UTILITIES
// =============================================================================
Expand Down
25 changes: 25 additions & 0 deletions include/graphics/paw_frame.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
#ifndef BONGOCAT_PAW_FRAME_H
#define BONGOCAT_PAW_FRAME_H

// Pure mapping from per-paw liveness to a cat frame index.
// Sleep handling is the caller's responsibility (checked before this).

#include "core/bongocat.h"

#include <stdbool.h>

static inline int frame_from_paw_state(bool left_live, bool right_live,
int idle_frame) {
if (left_live && right_live) {
return BONGOCAT_FRAME_BOTH_DOWN;
}
if (left_live) {
return BONGOCAT_FRAME_LEFT_DOWN;
}
if (right_live) {
return BONGOCAT_FRAME_RIGHT_DOWN;
}
return idle_frame;
}

#endif // BONGOCAT_PAW_FRAME_H
8 changes: 3 additions & 5 deletions include/platform/input.h
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
#define INPUT_H

#include "core/bongocat.h"
#include "platform/key_ring.h"
#include "utils/error.h"

#include <stdatomic.h>
Expand All @@ -10,11 +11,8 @@
// INPUT STATE
// =============================================================================

// Shared memory for key press state (thread-safe)
extern atomic_int *any_key_pressed;

// Last pressed key code for hand mapping (0 = none)
extern atomic_int *last_key_code;
// Shared keycode ring: input child (producer) -> animation threads (consumers)
extern key_ring_t *key_ring;

// =============================================================================
// INPUT MONITORING FUNCTIONS
Expand Down
53 changes: 53 additions & 0 deletions include/platform/key_ring.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
#ifndef BONGOCAT_KEY_RING_H
#define BONGOCAT_KEY_RING_H

// Single-producer / multi-consumer broadcast ring for keycodes.
// Producer: the input child process. Consumers: each monitor's animation
// thread, each with its own process-local `tail`. Codes are never removed;
// a consumer lapped by more than KEY_RING_SIZE drops the oldest unread codes.
// Lives in MAP_SHARED memory so all processes see the same buffer.

#include <stdatomic.h>

#define KEY_RING_SIZE 64u // must be a power of two

typedef struct {
atomic_uint head; // monotonic publish counter (producer)
atomic_int codes[KEY_RING_SIZE]; // codes[seq & (KEY_RING_SIZE-1)]
} key_ring_t;

static inline void key_ring_init(key_ring_t *r) {
atomic_store_explicit(&r->head, 0u, memory_order_relaxed);
for (unsigned i = 0; i < KEY_RING_SIZE; i++) {
atomic_store_explicit(&r->codes[i], 0, memory_order_relaxed);
}
}

static inline void key_ring_push(key_ring_t *r, int code) {
unsigned head = atomic_load_explicit(&r->head, memory_order_relaxed);
atomic_store_explicit(&r->codes[head & (KEY_RING_SIZE - 1)], code,
memory_order_relaxed);
atomic_store_explicit(&r->head, head + 1u, memory_order_release);
}

// Copies up to out_cap codes (oldest first) into out, advances *tail, and
// returns the count written. If the consumer has been lapped (more than
// KEY_RING_SIZE codes pending), the oldest are dropped first.
static inline unsigned key_ring_drain(const key_ring_t *r, unsigned *tail,
int *out, unsigned out_cap) {
unsigned head = atomic_load_explicit(&r->head, memory_order_acquire);
unsigned avail = head - *tail; // unsigned wrap-safe
if (avail > KEY_RING_SIZE) {
*tail = head - KEY_RING_SIZE; // drop oldest
avail = KEY_RING_SIZE;
}
unsigned n = (avail < out_cap) ? avail : out_cap;
for (unsigned i = 0; i < n; i++) {
out[i] = atomic_load_explicit(&r->codes[(*tail + i) & (KEY_RING_SIZE - 1)],
memory_order_relaxed);
}
*tail += n;
return n;
}

#endif // BONGOCAT_KEY_RING_H
126 changes: 63 additions & 63 deletions src/graphics/animation.c
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@
#include "graphics/animation.h"

#include "graphics/embedded_assets.h"
#include "graphics/paw_frame.h"
#include "platform/input.h"
#include "platform/key_ring.h"
#include "platform/wayland.h"
#include "utils/memory.h"

Expand Down Expand Up @@ -49,7 +51,9 @@ static bool animation_initialized = false;
// =============================================================================

typedef struct {
long hold_until;
long left_hold_until; // paw down while now_us < left_hold_until
long right_hold_until;
unsigned key_ring_tail; // process-local consumer cursor into key_ring
int test_counter;
int test_interval_frames;
long frame_time_ns;
Expand Down Expand Up @@ -114,29 +118,29 @@ static int get_frame_for_keycode(int keycode) {
return 2; // Right hand (default for all other keys)
}

static int anim_get_active_frame(void) {
// Map a keycode to a paw frame constant, honoring hand mapping + mirror.
static int anim_classify_paw(int keycode) {
int frame;
if (current_config && current_config->enable_hand_mapping) {
int keycode = atomic_load(last_key_code);
int frame = get_frame_for_keycode(keycode);
// Flip hands when cat is mirrored horizontally
frame = get_frame_for_keycode(keycode); // 1 = left, 2 = right
if (current_config->mirror_x) {
frame = (frame == 1) ? 2 : 1;
}
return frame;
} else {
frame = (rand() % 2) + 1; // random hand
}
return (rand() % 2) + 1; // Random: frame 1 or 2
return (frame == 1) ? BONGOCAT_FRAME_LEFT_DOWN : BONGOCAT_FRAME_RIGHT_DOWN;
}

static void anim_trigger_frame_change(int new_frame, long duration_us,
long current_time_us,
animation_state_t *state) {
if (current_config->enable_debug) {
bongocat_log_debug("Animation frame change: %d (duration: %ld us)",
new_frame, duration_us);
// Extend the given paw's deadline to now + keypress_duration.
static void anim_press_paw(animation_state_t *state, int paw_frame,
long current_time_us) {
long duration_us = current_config->keypress_duration * 1000;
if (paw_frame == BONGOCAT_FRAME_LEFT_DOWN) {
state->left_hold_until = current_time_us + duration_us;
} else {
state->right_hold_until = current_time_us + duration_us;
}

anim_index = new_frame;
state->hold_until = current_time_us + duration_us;
}

static void anim_handle_test_animation(animation_state_t *state,
Expand All @@ -147,51 +151,49 @@ static void anim_handle_test_animation(animation_state_t *state,

state->test_counter++;
if (state->test_counter > state->test_interval_frames) {
int new_frame = anim_get_active_frame();
long duration_us = current_config->test_animation_duration * 1000;

bongocat_log_debug("Test animation trigger");
anim_trigger_frame_change(new_frame, duration_us, current_time_us, state);
anim_press_paw(state, anim_classify_paw(rand()), current_time_us);
state->test_counter = 0;
}
}

static void anim_handle_key_press(animation_state_t *state,
long current_time_us) {
if (!atomic_load(any_key_pressed)) {
// Drain all pending keycodes and extend per-paw deadlines.
static void anim_drain_keys(animation_state_t *state, long current_time_us) {
// During a scheduled-sleep window, ignore key intake (matches prior guard).
if (current_config->enable_scheduled_sleep &&
anim_is_sleep_time(current_config)) {
return;
}
if (!key_ring) {
return;
}

if (!current_config->enable_scheduled_sleep ||
!anim_is_sleep_time(current_config)) {
int new_frame = anim_get_active_frame();
long duration_us = current_config->keypress_duration * 1000;

bongocat_log_debug("Key press detected - switching to frame %d", new_frame);
anim_trigger_frame_change(new_frame, duration_us, current_time_us, state);
int codes[KEY_RING_SIZE];
unsigned n =
key_ring_drain(key_ring, &state->key_ring_tail, codes, KEY_RING_SIZE);
if (n == 0) {
return;
}

atomic_store(any_key_pressed, 0);
state->test_counter = 0; // Reset test counter
state->last_key_pressed_timestamp = current_time_us;
for (unsigned i = 0; i < n; i++) {
anim_press_paw(state, anim_classify_paw(codes[i]), current_time_us);
}
state->last_key_pressed_timestamp = current_time_us;
state->test_counter = 0;
}

static void anim_handle_idle_return(animation_state_t *state,
long current_time_us) {
// Derive anim_index from sleep state, then per-paw deadlines.
static void anim_select_frame(animation_state_t *state, long current_time_us) {
int show_sleep_frame = 0;
// Sleep Mode
if (current_config->enable_scheduled_sleep) {
if (anim_is_sleep_time(current_config)) {
show_sleep_frame = 1;
}
if (current_config->enable_scheduled_sleep &&
anim_is_sleep_time(current_config)) {
show_sleep_frame = 1;
}
// Idle Sleep
if (current_config->idle_sleep_timeout_sec > 0 &&
state->last_key_pressed_timestamp > 0) {
if (anim_get_current_time_us() - state->last_key_pressed_timestamp >=
current_config->idle_sleep_timeout_sec * 1000000L) {
show_sleep_frame = 1;
}
state->last_key_pressed_timestamp > 0 &&
anim_get_current_time_us() - state->last_key_pressed_timestamp >=
current_config->idle_sleep_timeout_sec * 1000000L) {
show_sleep_frame = 1;
}

if (show_sleep_frame) {
Expand All @@ -202,15 +204,15 @@ static void anim_handle_idle_return(animation_state_t *state,
return;
}

if (current_time_us <= state->hold_until) {
return;
}

if (anim_index != current_config->idle_frame) {
bongocat_log_debug("Returning to idle frame %d",
current_config->idle_frame);
anim_index = current_config->idle_frame;
bool left_live = current_time_us < state->left_hold_until;
bool right_live = current_time_us < state->right_hold_until;
int new_frame =
frame_from_paw_state(left_live, right_live, current_config->idle_frame);
if (new_frame != anim_index && current_config->enable_debug) {
bongocat_log_debug("Frame -> %d (left=%d right=%d)", new_frame,
(int)left_live, (int)right_live);
}
anim_index = new_frame;
}

static void anim_update_state(animation_state_t *state) {
Expand All @@ -219,8 +221,8 @@ static void anim_update_state(animation_state_t *state) {
pthread_mutex_lock(&anim_lock);

anim_handle_test_animation(state, current_time_us);
anim_handle_key_press(state, current_time_us);
anim_handle_idle_return(state, current_time_us);
anim_drain_keys(state, current_time_us);
anim_select_frame(state, current_time_us);

pthread_mutex_unlock(&anim_lock);
}
Expand All @@ -230,7 +232,11 @@ static void anim_update_state(animation_state_t *state) {
// =============================================================================

static void anim_init_state(animation_state_t *state) {
state->hold_until = 0;
state->left_hold_until = 0;
state->right_hold_until = 0;
state->key_ring_tail =
key_ring ? atomic_load_explicit(&key_ring->head, memory_order_acquire)
: 0; // start at current head; ignore prior codes
state->test_counter = 0;
state->test_interval_frames =
current_config->test_animation_interval * current_config->fps;
Expand Down Expand Up @@ -572,9 +578,3 @@ void animation_cleanup(void) {

bongocat_log_debug("Animation cleanup complete");
}

void animation_trigger(void) {
if (any_key_pressed) {
atomic_store(any_key_pressed, 1);
}
}
Loading