summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/kd_deck.c1228
-rw-r--r--src/kd_image.c334
-rw-r--r--src/main.c976
-rw-r--r--src/nuklear_impl.c5
4 files changed, 2543 insertions, 0 deletions
diff --git a/src/kd_deck.c b/src/kd_deck.c
new file mode 100644
index 0000000..f638aa0
--- /dev/null
+++ b/src/kd_deck.c
@@ -0,0 +1,1228 @@
+#define _POSIX_C_SOURCE 200809L
+
+#include "kd_deck.h"
+
+#include <ctype.h>
+#include <dirent.h>
+#include <errno.h>
+#include <stdarg.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <sys/stat.h>
+#include <sys/types.h>
+#include <unistd.h>
+
+#define KD_LINE_MAX 8192
+#define KD_STATE_TMP_SUFFIX ".tmp"
+
+typedef struct KDNameList {
+ char **names;
+ size_t count;
+ size_t capacity;
+} KDNameList;
+
+typedef struct KDJukugoName {
+ char id[64];
+ int has_front;
+ int has_answer;
+} KDJukugoName;
+
+typedef struct KDJukugoNameList {
+ KDJukugoName *names;
+ size_t count;
+ size_t capacity;
+} KDJukugoNameList;
+
+static char *kd_strdup(const char *source)
+{
+ size_t length;
+ char *copy;
+
+ if (source == NULL) {
+ return NULL;
+ }
+ length = strlen(source);
+ copy = (char *)malloc(length + 1U);
+ if (copy != NULL) {
+ memcpy(copy, source, length + 1U);
+ }
+ return copy;
+}
+
+static int kd_path(char *target, size_t target_size,
+ const char *left, const char *right)
+{
+ int written;
+
+ written = snprintf(target, target_size, "%s/%s", left, right);
+ if (written < 0 || (size_t)written >= target_size) {
+ return 0;
+ }
+ return 1;
+}
+
+void kd_add_message(KDDeck *deck, int is_warning, const char *format, ...)
+{
+ char buffer[2048];
+ va_list args;
+ KDMessage *replacement;
+ size_t capacity;
+ char *copy;
+
+ if (deck == NULL || format == NULL) {
+ return;
+ }
+ va_start(args, format);
+ (void)vsnprintf(buffer, sizeof(buffer), format, args);
+ va_end(args);
+
+ if (deck->message_count == deck->message_capacity) {
+ capacity = deck->message_capacity == 0U ? 16U :
+ deck->message_capacity * 2U;
+ replacement = (KDMessage *)realloc(deck->messages,
+ capacity * sizeof(KDMessage));
+ if (replacement == NULL) {
+ (void)fprintf(stderr, "kanji-deck: out of memory adding message\n");
+ return;
+ }
+ deck->messages = replacement;
+ deck->message_capacity = capacity;
+ }
+ copy = kd_strdup(buffer);
+ if (copy == NULL) {
+ return;
+ }
+ deck->messages[deck->message_count].text = copy;
+ deck->messages[deck->message_count].is_warning = is_warning;
+ deck->message_count++;
+
+ (void)fprintf(stderr, "%s: %s\n", is_warning ? "WARNING" : "INFO", buffer);
+}
+
+void kd_deck_init(KDDeck *deck)
+{
+ if (deck != NULL) {
+ memset(deck, 0, sizeof(*deck));
+ }
+}
+
+static void kd_card_free(KDCard *card)
+{
+ free(card->jukugo);
+ memset(card, 0, sizeof(*card));
+}
+
+void kd_deck_free(KDDeck *deck)
+{
+ size_t i;
+
+ if (deck == NULL) {
+ return;
+ }
+ for (i = 0U; i < deck->card_count; i++) {
+ kd_card_free(&deck->cards[i]);
+ }
+ for (i = 0U; i < deck->message_count; i++) {
+ free(deck->messages[i].text);
+ }
+ free(deck->cards);
+ free(deck->items);
+ free(deck->reviews);
+ free(deck->messages);
+ memset(deck, 0, sizeof(*deck));
+}
+
+static int kd_reserve(void **buffer, size_t *capacity, size_t needed,
+ size_t element_size)
+{
+ void *replacement;
+ size_t next_capacity;
+
+ if (needed <= *capacity) {
+ return 1;
+ }
+ next_capacity = *capacity == 0U ? 8U : *capacity;
+ while (next_capacity < needed) {
+ if (next_capacity > ((size_t)-1) / 2U) {
+ return 0;
+ }
+ next_capacity *= 2U;
+ }
+ if (element_size != 0U && next_capacity > ((size_t)-1) / element_size) {
+ return 0;
+ }
+ replacement = realloc(*buffer, next_capacity * element_size);
+ if (replacement == NULL) {
+ return 0;
+ }
+ *buffer = replacement;
+ *capacity = next_capacity;
+ return 1;
+}
+
+static int kd_is_directory(const char *path)
+{
+ struct stat info;
+
+ if (stat(path, &info) != 0) {
+ return 0;
+ }
+ return S_ISDIR(info.st_mode) != 0;
+}
+
+static int kd_is_regular_file(const char *path)
+{
+ struct stat info;
+
+ if (stat(path, &info) != 0) {
+ return 0;
+ }
+ return S_ISREG(info.st_mode) != 0;
+}
+
+static int kd_numeric_id(const char *name)
+{
+ const unsigned char *cursor;
+
+ if (name == NULL || name[0] == '\0' || strlen(name) >= 64U) {
+ return 0;
+ }
+ for (cursor = (const unsigned char *)name; *cursor != '\0'; cursor++) {
+ if (!isdigit(*cursor)) {
+ return 0;
+ }
+ }
+ return 1;
+}
+
+static int kd_name_compare(const void *left, const void *right)
+{
+ const char *const *a;
+ const char *const *b;
+
+ a = (const char *const *)left;
+ b = (const char *const *)right;
+ return strcmp(*a, *b);
+}
+
+static void kd_name_list_free(KDNameList *list)
+{
+ size_t i;
+
+ for (i = 0U; i < list->count; i++) {
+ free(list->names[i]);
+ }
+ free(list->names);
+ memset(list, 0, sizeof(*list));
+}
+
+static int kd_name_list_add(KDNameList *list, const char *name)
+{
+ char **replacement;
+ char *copy;
+ size_t next_capacity;
+
+ if (list->count == list->capacity) {
+ next_capacity = list->capacity == 0U ? 8U : list->capacity * 2U;
+ replacement = (char **)realloc(list->names,
+ next_capacity * sizeof(char *));
+ if (replacement == NULL) {
+ return 0;
+ }
+ list->names = replacement;
+ list->capacity = next_capacity;
+ }
+ copy = kd_strdup(name);
+ if (copy == NULL) {
+ return 0;
+ }
+ list->names[list->count++] = copy;
+ return 1;
+}
+
+static int kd_collect_ids(KDDeck *deck, const char *directory,
+ KDNameList *ids)
+{
+ DIR *dir;
+ struct dirent *entry;
+ char path[KD_PATH_MAX];
+
+ dir = opendir(directory);
+ if (dir == NULL) {
+ kd_add_message(deck, 1, "Cannot read directory '%s': %s",
+ directory, strerror(errno));
+ return 0;
+ }
+ while ((entry = readdir(dir)) != NULL) {
+ if (strcmp(entry->d_name, ".") == 0 ||
+ strcmp(entry->d_name, "..") == 0) {
+ continue;
+ }
+ if (!kd_path(path, sizeof(path), directory, entry->d_name)) {
+ kd_add_message(deck, 1, "Path is too long under '%s'", directory);
+ continue;
+ }
+ if (!kd_numeric_id(entry->d_name) || !kd_is_directory(path)) {
+ kd_add_message(deck, 1,
+ "Wrong entry in '%s': '%s' (kanji directories must be numeric IDs)",
+ directory, entry->d_name);
+ continue;
+ }
+ if (!kd_name_list_add(ids, entry->d_name)) {
+ (void)closedir(dir);
+ return 0;
+ }
+ }
+ (void)closedir(dir);
+ qsort(ids->names, ids->count, sizeof(char *), kd_name_compare);
+ return 1;
+}
+
+static int kd_append_card(KDDeck *deck, const char *id, const char *path,
+ size_t *index_out)
+{
+ KDCard *card;
+
+ if (!kd_reserve((void **)&deck->cards, &deck->card_capacity,
+ deck->card_count + 1U, sizeof(KDCard))) {
+ return 0;
+ }
+ card = &deck->cards[deck->card_count];
+ memset(card, 0, sizeof(*card));
+ (void)snprintf(card->id, sizeof(card->id), "%s", id);
+ (void)snprintf(card->dir_path, sizeof(card->dir_path), "%s", path);
+ if (!kd_path(card->image_path, sizeof(card->image_path), path, "kanji.ppm") ||
+ !kd_path(card->answer_image_path, sizeof(card->answer_image_path), path, "kanji_ans.ppm") ||
+ !kd_path(card->url_path, sizeof(card->url_path), path, "URL.txt")) {
+ kd_add_message(deck, 1, "Paths are too long for card '%s'", id);
+ return 0;
+ }
+ card->has_image = kd_is_regular_file(card->image_path);
+ card->has_answer_image = kd_is_regular_file(card->answer_image_path);
+ card->has_url = kd_is_regular_file(card->url_path);
+ card->url[0] = '\0';
+ if (card->has_url) {
+ FILE *url_file;
+ url_file = fopen(card->url_path, "r");
+ if (url_file != NULL) {
+ if (fgets(card->url, sizeof(card->url), url_file) != NULL) {
+ {
+ size_t url_length;
+ while (card->url[0] != '\0' && isspace((unsigned char)card->url[0])) {
+ (void)memmove(card->url, card->url + 1, strlen(card->url));
+ }
+ url_length = strlen(card->url);
+ while (url_length > 0U && isspace((unsigned char)card->url[url_length - 1U])) {
+ card->url[--url_length] = '\0';
+ }
+ }
+ }
+ if (ferror(url_file)) {
+ kd_add_message(deck, 1, "Card %s: error reading URL.txt", id);
+ card->url[0] = '\0';
+ }
+ (void)fclose(url_file);
+ } else {
+ kd_add_message(deck, 1, "Card %s: cannot read URL.txt: %s", id, strerror(errno));
+ card->url[0] = '\0';
+ }
+ }
+ *index_out = deck->card_count;
+ deck->card_count++;
+ return 1;
+}
+
+const char *kd_card_url(const KDCard *card)
+{
+ if (card == NULL || !card->has_url || card->url[0] == '\0') {
+ return NULL;
+ }
+ return card->url;
+}
+
+static int kd_jukugo_compare(const void *left, const void *right)
+{
+ const KDJukugoName *a;
+ const KDJukugoName *b;
+
+ a = (const KDJukugoName *)left;
+ b = (const KDJukugoName *)right;
+ return strcmp(a->id, b->id);
+}
+
+static KDJukugoName *kd_jukugo_name_get(KDJukugoNameList *list,
+ const char *id)
+{
+ size_t i;
+ KDJukugoName *replacement;
+ size_t next_capacity;
+
+ for (i = 0U; i < list->count; i++) {
+ if (strcmp(list->names[i].id, id) == 0) {
+ return &list->names[i];
+ }
+ }
+ if (list->count == list->capacity) {
+ next_capacity = list->capacity == 0U ? 8U : list->capacity * 2U;
+ replacement = (KDJukugoName *)realloc(list->names,
+ next_capacity * sizeof(KDJukugoName));
+ if (replacement == NULL) {
+ return NULL;
+ }
+ list->names = replacement;
+ list->capacity = next_capacity;
+ }
+ memset(&list->names[list->count], 0, sizeof(KDJukugoName));
+ (void)snprintf(list->names[list->count].id,
+ sizeof(list->names[list->count].id), "%s", id);
+ list->count++;
+ return &list->names[list->count - 1U];
+}
+
+static int kd_jukugo_filename(const char *name, char *id, size_t id_size,
+ int *is_answer)
+{
+ size_t length;
+ size_t prefix_length;
+ size_t i;
+ const char *suffix;
+
+ length = strlen(name);
+ if (length > 8U && strcmp(name + length - 8U, "_ans.ppm") == 0) {
+ suffix = "_ans.ppm";
+ *is_answer = 1;
+ } else if (length > 4U && strcmp(name + length - 4U, ".ppm") == 0) {
+ suffix = ".ppm";
+ *is_answer = 0;
+ } else {
+ return 0;
+ }
+ prefix_length = length - strlen(suffix);
+ if (prefix_length == 0U || prefix_length >= id_size) {
+ return 0;
+ }
+ for (i = 0U; i < prefix_length; i++) {
+ if (!isdigit((unsigned char)name[i])) {
+ return 0;
+ }
+ }
+ memcpy(id, name, prefix_length);
+ id[prefix_length] = '\0';
+ return 1;
+}
+
+static int kd_append_jukugo(KDCard *card, const char *id,
+ const char *directory)
+{
+ KDJukugo *jukugo;
+ char filename[96];
+ int written;
+
+ if (!kd_reserve((void **)&card->jukugo, &card->jukugo_capacity,
+ card->jukugo_count + 1U, sizeof(KDJukugo))) {
+ return 0;
+ }
+ jukugo = &card->jukugo[card->jukugo_count];
+ memset(jukugo, 0, sizeof(*jukugo));
+ (void)snprintf(jukugo->id, sizeof(jukugo->id), "%s", id);
+ written = snprintf(filename, sizeof(filename), "%s.ppm", id);
+ if (written < 0 || (size_t)written >= sizeof(filename) ||
+ !kd_path(jukugo->front_path, sizeof(jukugo->front_path), directory, filename)) {
+ return 0;
+ }
+ written = snprintf(filename, sizeof(filename), "%s_ans.ppm", id);
+ if (written < 0 || (size_t)written >= sizeof(filename) ||
+ !kd_path(jukugo->answer_path, sizeof(jukugo->answer_path), directory, filename)) {
+ return 0;
+ }
+ card->jukugo_count++;
+ return 1;
+}
+
+static int kd_scan_jukugo(KDDeck *deck, KDCard *card, size_t card_index,
+ const char *directory)
+{
+ DIR *dir;
+ struct dirent *entry;
+ KDJukugoNameList found;
+ KDJukugoName *item;
+ char path[KD_PATH_MAX];
+ char id[64];
+ int is_answer;
+ size_t i;
+
+ memset(&found, 0, sizeof(found));
+ dir = opendir(directory);
+ if (dir == NULL) {
+ kd_add_message(deck, 1, "Card %s: cannot read jukugo directory '%s': %s",
+ card->id, directory, strerror(errno));
+ return 0;
+ }
+ while ((entry = readdir(dir)) != NULL) {
+ if (strcmp(entry->d_name, ".") == 0 ||
+ strcmp(entry->d_name, "..") == 0) {
+ continue;
+ }
+ if (!kd_path(path, sizeof(path), directory, entry->d_name)) {
+ kd_add_message(deck, 1, "Card %s: jukugo path is too long", card->id);
+ continue;
+ }
+ if (!kd_jukugo_filename(entry->d_name, id, sizeof(id), &is_answer) ||
+ !kd_is_regular_file(path)) {
+ kd_add_message(deck, 1,
+ "Card %s: wrong jukugo entry '%s'; expected N.ppm and N_ans.ppm",
+ card->id, entry->d_name);
+ continue;
+ }
+ item = kd_jukugo_name_get(&found, id);
+ if (item == NULL) {
+ (void)closedir(dir);
+ free(found.names);
+ return 0;
+ }
+ if (is_answer) {
+ item->has_answer = 1;
+ } else {
+ item->has_front = 1;
+ }
+ }
+ (void)closedir(dir);
+ qsort(found.names, found.count, sizeof(KDJukugoName), kd_jukugo_compare);
+
+ for (i = 0U; i < found.count; i++) {
+ item = &found.names[i];
+ if (item->has_front && !item->has_answer) {
+ kd_add_message(deck, 1,
+ "Card %s jukugo %s: missing '%s_ans.ppm' answer image",
+ card->id, item->id, item->id);
+ } else if (!item->has_front && item->has_answer) {
+ kd_add_message(deck, 1,
+ "Card %s jukugo %s: answer image has no '%s.ppm' front image",
+ card->id, item->id, item->id);
+ } else if (item->has_front && item->has_answer) {
+ if (!kd_append_jukugo(card, item->id, directory)) {
+ free(found.names);
+ return 0;
+ }
+ if (!kd_reserve((void **)&deck->items, &deck->item_capacity,
+ deck->item_count + 1U, sizeof(KDStudyItem))) {
+ free(found.names);
+ return 0;
+ }
+ memset(&deck->items[deck->item_count], 0, sizeof(KDStudyItem));
+ (void)snprintf(deck->items[deck->item_count].key,
+ sizeof(deck->items[deck->item_count].key), "J:%s:%s", card->id, item->id);
+ deck->items[deck->item_count].card_index = card_index;
+ deck->items[deck->item_count].jukugo_index = (int)(card->jukugo_count - 1U);
+ deck->item_count++;
+ }
+ }
+ free(found.names);
+ return 1;
+}
+
+static int kd_scan_card_children(KDDeck *deck, KDCard *card)
+{
+ DIR *dir;
+ struct dirent *entry;
+ char path[KD_PATH_MAX];
+ int saw_jukugo;
+
+ saw_jukugo = 0;
+ dir = opendir(card->dir_path);
+ if (dir == NULL) {
+ kd_add_message(deck, 1, "Cannot read card directory '%s': %s",
+ card->dir_path, strerror(errno));
+ return 0;
+ }
+ while ((entry = readdir(dir)) != NULL) {
+ if (strcmp(entry->d_name, ".") == 0 ||
+ strcmp(entry->d_name, "..") == 0) {
+ continue;
+ }
+ if (strcmp(entry->d_name, "kanji.ppm") == 0 ||
+ strcmp(entry->d_name, "kanji_ans.ppm") == 0 ||
+ strcmp(entry->d_name, "URL.txt") == 0) {
+ continue;
+ }
+ if (strcmp(entry->d_name, "jukugo") == 0) {
+ saw_jukugo = 1;
+ if (!kd_path(path, sizeof(path), card->dir_path, "jukugo")) {
+ kd_add_message(deck, 1, "Card %s: jukugo path is too long", card->id);
+ } else if (!kd_is_directory(path)) {
+ kd_add_message(deck, 1,
+ "Card %s: 'jukugo' exists but is not a directory", card->id);
+ }
+ continue;
+ }
+ kd_add_message(deck, 1,
+ "Card %s: unknown entry '%s' (expected kanji.ppm, kanji_ans.ppm, URL.txt, jukugo/)",
+ card->id, entry->d_name);
+ }
+ (void)closedir(dir);
+
+ if (!card->has_image) {
+ kd_add_message(deck, 1, "Card %s: missing required kanji.ppm image", card->id);
+ }
+ if (!card->has_answer_image) {
+ kd_add_message(deck, 1, "Card %s: missing required kanji_ans.ppm answer image", card->id);
+ }
+ if (!card->has_url) {
+ kd_add_message(deck, 1, "Card %s: missing URL.txt", card->id);
+ } else if (card->url[0] == '\0') {
+ kd_add_message(deck, 1, "Card %s: URL.txt is empty", card->id);
+ }
+ if (saw_jukugo) {
+ if (kd_path(path, sizeof(path), card->dir_path, "jukugo") &&
+ kd_is_directory(path)) {
+ (void)kd_scan_jukugo(deck, card, (size_t)(card - deck->cards), path);
+ }
+ }
+ return 1;
+}
+
+static int kd_append_main_item(KDDeck *deck, size_t card_index)
+{
+ KDStudyItem *item;
+ KDCard *card;
+
+ if (!kd_reserve((void **)&deck->items, &deck->item_capacity,
+ deck->item_count + 1U, sizeof(KDStudyItem))) {
+ return 0;
+ }
+ card = &deck->cards[card_index];
+ item = &deck->items[deck->item_count];
+ memset(item, 0, sizeof(*item));
+ (void)snprintf(item->key, sizeof(item->key), "K:%s", card->id);
+ item->card_index = card_index;
+ item->jukugo_index = KD_NO_JUKUGO;
+ deck->item_count++;
+ return 1;
+}
+
+static int kd_review_index(const KDDeck *deck, const char *key)
+{
+ size_t i;
+
+ for (i = 0U; i < deck->review_count; i++) {
+ if (strcmp(deck->reviews[i].key, key) == 0) {
+ return (int)i;
+ }
+ }
+ return -1;
+}
+
+static int kd_review_key_valid(const char *key)
+{
+ const unsigned char *cursor;
+
+ if (key[0] == 'K' && key[1] == ':') {
+ cursor = (const unsigned char *)key + 2;
+ } else if (key[0] == 'J' && key[1] == ':') {
+ cursor = (const unsigned char *)key + 2;
+ } else {
+ return 0;
+ }
+ if (*cursor == '\0') {
+ return 0;
+ }
+ for (; *cursor != '\0'; cursor++) {
+ if (!isdigit(*cursor) && *cursor != ':') {
+ return 0;
+ }
+ }
+ return 1;
+}
+
+static int kd_append_review(KDDeck *deck, const KDReview *review)
+{
+ if (!kd_reserve((void **)&deck->reviews, &deck->review_capacity,
+ deck->review_count + 1U, sizeof(KDReview))) {
+ return 0;
+ }
+ deck->reviews[deck->review_count++] = *review;
+ return 1;
+}
+
+static int kd_load_state(KDDeck *deck)
+{
+ FILE *file;
+ char line[KD_LINE_MAX];
+ int line_number;
+ KDReview review;
+ char key[KD_KEY_MAX];
+ int converted;
+ int existing;
+
+ file = fopen(deck->state_path, "r");
+ if (file == NULL) {
+ if (errno != ENOENT) {
+ kd_add_message(deck, 1, "Cannot read progress file '%s': %s",
+ deck->state_path, strerror(errno));
+ }
+ return 1;
+ }
+ line_number = 0;
+ while (fgets(line, sizeof(line), file) != NULL) {
+ line_number++;
+ if (line[0] == '\n' || line[0] == '\0' || line[0] == '#') {
+ continue;
+ }
+ review.relearning = 0;
+ converted = sscanf(line, "%159s %ld %ld %ld %ld %ld %ld %ld %d",
+ key, &review.interval_days, &review.ease_milli,
+ &review.repetitions, &review.lapses, &review.due_epoch,
+ &review.last_epoch, &review.introduced_day, &review.relearning);
+ if ((converted != 8 && converted != 9) || !kd_review_key_valid(key) ||
+ review.interval_days < 0L || review.ease_milli < 1000L ||
+ review.ease_milli > 5000L || review.repetitions < 0L ||
+ review.lapses < 0L || review.relearning < 0 ||
+ review.relearning > 1) {
+ kd_add_message(deck, 1,
+ "Progress file '%s': malformed line %d; ignored",
+ deck->state_path, line_number);
+ continue;
+ }
+ (void)snprintf(review.key, sizeof(review.key), "%s", key);
+ existing = kd_review_index(deck, review.key);
+ if (existing >= 0) {
+ deck->reviews[existing] = review;
+ kd_add_message(deck, 1,
+ "Progress file '%s': duplicate key '%s'; later row used",
+ deck->state_path, key);
+ } else if (!kd_append_review(deck, &review)) {
+ (void)fclose(file);
+ return 0;
+ }
+ }
+ if (ferror(file)) {
+ kd_add_message(deck, 1, "Error reading progress file '%s'", deck->state_path);
+ }
+ (void)fclose(file);
+ return 1;
+}
+
+static int kd_reconcile_reviews(KDDeck *deck)
+{
+ size_t i;
+ int index;
+ KDReview review;
+ KDStudyItem *item;
+ KDCard *card;
+ const char *description;
+
+ for (i = 0U; i < deck->item_count; i++) {
+ item = &deck->items[i];
+ index = kd_review_index(deck, item->key);
+ if (index < 0) {
+ memset(&review, 0, sizeof(review));
+ (void)snprintf(review.key, sizeof(review.key), "%s", item->key);
+ review.interval_days = 0L;
+ review.ease_milli = 2500L;
+ review.due_epoch = (long)time(NULL);
+ review.last_epoch = 0L;
+ review.introduced_day = -1L;
+ if (!kd_append_review(deck, &review)) {
+ return 0;
+ }
+ index = (int)(deck->review_count - 1U);
+ card = &deck->cards[item->card_index];
+ if (item->jukugo_index == KD_NO_JUKUGO) {
+ description = card->image_path;
+ kd_add_message(deck, 0, "New kanji prompt detected: %s (%s)",
+ item->key, description);
+ } else {
+ description = card->jukugo[item->jukugo_index].front_path;
+ kd_add_message(deck, 0, "New jukugo prompt detected: %s (%s)",
+ item->key, description);
+ }
+ }
+ item->review_index = (size_t)index;
+ }
+ return 1;
+}
+
+int kd_deck_save(KDDeck *deck)
+{
+ FILE *file;
+ char temporary[KD_PATH_MAX];
+ size_t i;
+ int written;
+ int failed;
+
+ written = snprintf(temporary, sizeof(temporary), "%s%s",
+ deck->state_path, KD_STATE_TMP_SUFFIX);
+ if (written < 0 || (size_t)written >= sizeof(temporary)) {
+ kd_add_message(deck, 1, "Progress path is too long; cannot save");
+ return 0;
+ }
+ file = fopen(temporary, "w");
+ if (file == NULL) {
+ kd_add_message(deck, 1, "Cannot write '%s': %s", temporary, strerror(errno));
+ return 0;
+ }
+ failed = 0;
+ if (fprintf(file, "# key interval_days ease_milli reps lapses due_epoch last_epoch introduced_day relearning\n") < 0) {
+ failed = 1;
+ }
+ for (i = 0U; i < deck->review_count && !failed; i++) {
+ if (fprintf(file, "%s %ld %ld %ld %ld %ld %ld %ld %d\n",
+ deck->reviews[i].key,
+ deck->reviews[i].interval_days,
+ deck->reviews[i].ease_milli,
+ deck->reviews[i].repetitions,
+ deck->reviews[i].lapses,
+ deck->reviews[i].due_epoch,
+ deck->reviews[i].last_epoch,
+ deck->reviews[i].introduced_day,
+ deck->reviews[i].relearning) < 0) {
+ failed = 1;
+ }
+ }
+ if (fflush(file) != 0) {
+ failed = 1;
+ }
+ if (fclose(file) != 0) {
+ failed = 1;
+ }
+ if (failed) {
+ (void)remove(temporary);
+ kd_add_message(deck, 1, "Failed writing progress file '%s'", temporary);
+ return 0;
+ }
+ if (rename(temporary, deck->state_path) != 0) {
+ kd_add_message(deck, 1, "Cannot replace '%s': %s",
+ deck->state_path, strerror(errno));
+ (void)remove(temporary);
+ return 0;
+ }
+ return 1;
+}
+
+int kd_deck_scan(KDDeck *deck, const char *root_path, const char *state_path)
+{
+ KDNameList ids;
+ size_t i;
+ size_t card_index;
+ char path[KD_PATH_MAX];
+ struct stat info;
+
+ if (deck == NULL || root_path == NULL || state_path == NULL) {
+ return 0;
+ }
+ kd_deck_free(deck);
+ kd_deck_init(deck);
+ if (snprintf(deck->root_path, sizeof(deck->root_path), "%s", root_path) < 0 ||
+ strlen(root_path) >= sizeof(deck->root_path) ||
+ snprintf(deck->state_path, sizeof(deck->state_path), "%s", state_path) < 0 ||
+ strlen(state_path) >= sizeof(deck->state_path)) {
+ kd_add_message(deck, 1, "Cards/progress path is too long");
+ return 0;
+ }
+
+ if (stat(deck->root_path, &info) != 0 && errno == ENOENT) {
+ if (mkdir(deck->root_path, 0755) == 0) {
+ kd_add_message(deck, 0, "Created missing cards directory '%s'",
+ deck->root_path);
+ } else {
+ kd_add_message(deck, 1, "Cannot create cards directory '%s': %s",
+ deck->root_path, strerror(errno));
+ return 0;
+ }
+ }
+ if (!kd_is_directory(deck->root_path)) {
+ kd_add_message(deck, 1, "Cards path '%s' is not a directory", deck->root_path);
+ return 0;
+ }
+
+ memset(&ids, 0, sizeof(ids));
+ if (!kd_collect_ids(deck, deck->root_path, &ids)) {
+ kd_name_list_free(&ids);
+ return 0;
+ }
+ for (i = 0U; i < ids.count; i++) {
+ if (!kd_path(path, sizeof(path), deck->root_path, ids.names[i])) {
+ kd_add_message(deck, 1, "Card path is too long for '%s'", ids.names[i]);
+ continue;
+ }
+ if (!kd_append_card(deck, ids.names[i], path, &card_index)) {
+ kd_name_list_free(&ids);
+ kd_add_message(deck, 1, "Out of memory while scanning cards");
+ return 0;
+ }
+ if (deck->cards[card_index].has_image) {
+ if (!kd_append_main_item(deck, card_index)) {
+ kd_name_list_free(&ids);
+ return 0;
+ }
+ }
+ (void)kd_scan_card_children(deck, &deck->cards[card_index]);
+ }
+ kd_name_list_free(&ids);
+
+ if (!kd_load_state(deck) || !kd_reconcile_reviews(deck)) {
+ kd_add_message(deck, 1, "Unable to initialize review state (out of memory)");
+ return 0;
+ }
+ (void)kd_deck_save(deck);
+ return 1;
+}
+
+long kd_today_key(time_t now)
+{
+ struct tm *local;
+
+ local = localtime(&now);
+ if (local == NULL) {
+ return (long)(now / (time_t)86400);
+ }
+ return (long)(local->tm_year + 1900) * 1000L + (long)local->tm_yday;
+}
+
+size_t kd_new_today(const KDDeck *deck, time_t now)
+{
+ size_t i;
+ size_t count;
+ long today;
+ const KDReview *review;
+
+ today = kd_today_key(now);
+ count = 0U;
+ for (i = 0U; i < deck->item_count; i++) {
+ review = &deck->reviews[deck->items[i].review_index];
+ if (deck->items[i].jukugo_index == KD_NO_JUKUGO &&
+ review->introduced_day == today) {
+ count++;
+ }
+ }
+ return count;
+}
+
+size_t kd_count_due(const KDDeck *deck, time_t now)
+{
+ size_t i;
+ size_t count;
+ const KDReview *review;
+
+ count = 0U;
+ for (i = 0U; i < deck->item_count; i++) {
+ review = &deck->reviews[deck->items[i].review_index];
+ if (review->introduced_day >= 0L &&
+ review->due_epoch <= (long)now) {
+ count++;
+ }
+ }
+ return count;
+}
+
+size_t kd_count_unintroduced(const KDDeck *deck)
+{
+ size_t i;
+ size_t count;
+ const KDReview *review;
+
+ count = 0U;
+ for (i = 0U; i < deck->item_count; i++) {
+ review = &deck->reviews[deck->items[i].review_index];
+ if (deck->items[i].jukugo_index == KD_NO_JUKUGO &&
+ review->introduced_day < 0L) {
+ count++;
+ }
+ }
+ return count;
+}
+
+int
+kd_find_kanji_item_by_number(const KDDeck *deck, const char *number,
+ size_t *item_index)
+{
+ const KDStudyItem *item;
+ const KDCard *card;
+ char *endptr;
+ unsigned long wanted;
+ unsigned long card_number;
+ size_t i;
+ const char *digit;
+
+ if (deck == NULL || number == NULL || item_index == NULL ||
+ number[0] == '\0') {
+ return 0;
+ }
+ for (digit = number; *digit != '\0'; digit++) {
+ if (!isdigit((unsigned char)*digit)) {
+ return 0;
+ }
+ }
+ errno = 0;
+ wanted = strtoul(number, &endptr, 10);
+ if (errno != 0 || endptr == number || *endptr != '\0' || wanted == 0UL) {
+ return 0;
+ }
+ for (i = 0U; i < deck->item_count; i++) {
+ item = &deck->items[i];
+ if (item->jukugo_index != KD_NO_JUKUGO ||
+ item->card_index >= deck->card_count) {
+ continue;
+ }
+ card = &deck->cards[item->card_index];
+ errno = 0;
+ card_number = strtoul(card->id, &endptr, 10);
+ if (errno == 0 && endptr != card->id && *endptr == '\0' &&
+ card_number == wanted) {
+ *item_index = i;
+ return 1;
+ }
+ }
+ return 0;
+}
+
+static unsigned long kd_parse_numeric_id(const char *text, unsigned long fallback)
+{
+ char *endptr;
+ unsigned long value;
+
+ errno = 0;
+ value = strtoul(text, &endptr, 10);
+ if (errno != 0 || endptr == text || *endptr != '\0') {
+ return fallback;
+ }
+ return value;
+}
+
+int kd_select_next(KDDeck *deck, time_t now, size_t *item_index)
+{
+ size_t i;
+ size_t chosen;
+ unsigned long chosen_card;
+ unsigned long candidate_card;
+ unsigned long chosen_jukugo;
+ unsigned long candidate_jukugo;
+ long earliest;
+ long today;
+ int chosen_kind;
+ int candidate_kind;
+ KDReview *review;
+ const KDStudyItem *item;
+ const KDCard *card;
+ const char *jukugo_id;
+
+ if (deck == NULL || item_index == NULL) {
+ return 0;
+ }
+ today = kd_today_key(now);
+
+ chosen = (size_t)-1;
+ earliest = 0L;
+ chosen_card = 0UL;
+ for (i = 0U; i < deck->item_count; i++) {
+ item = &deck->items[i];
+ if (item->jukugo_index != KD_NO_JUKUGO) {
+ continue;
+ }
+ review = &deck->reviews[item->review_index];
+ if (review->introduced_day < 0L || review->due_epoch > (long)now) {
+ continue;
+ }
+ card = &deck->cards[item->card_index];
+ candidate_card = kd_parse_numeric_id(card->id, (unsigned long)item->card_index);
+ if (chosen == (size_t)-1 || review->due_epoch < earliest ||
+ (review->due_epoch == earliest && candidate_card < chosen_card)) {
+ chosen = i;
+ earliest = review->due_epoch;
+ chosen_card = candidate_card;
+ }
+ }
+ if (chosen != (size_t)-1) {
+ *item_index = chosen;
+ return 1;
+ }
+
+ chosen = (size_t)-1;
+ earliest = 0L;
+ chosen_card = 0UL;
+ chosen_jukugo = 0UL;
+ for (i = 0U; i < deck->item_count; i++) {
+ item = &deck->items[i];
+ if (item->jukugo_index == KD_NO_JUKUGO) {
+ continue;
+ }
+ review = &deck->reviews[item->review_index];
+ if (review->introduced_day < 0L || review->due_epoch > (long)now) {
+ continue;
+ }
+ card = &deck->cards[item->card_index];
+ candidate_card = kd_parse_numeric_id(card->id, (unsigned long)item->card_index);
+ jukugo_id = card->jukugo[(size_t)item->jukugo_index].id;
+ candidate_jukugo = kd_parse_numeric_id(jukugo_id,
+ (unsigned long)item->jukugo_index);
+ if (chosen == (size_t)-1 || review->due_epoch < earliest ||
+ (review->due_epoch == earliest &&
+ (candidate_card < chosen_card ||
+ (candidate_card == chosen_card && candidate_jukugo < chosen_jukugo)))) {
+ chosen = i;
+ earliest = review->due_epoch;
+ chosen_card = candidate_card;
+ chosen_jukugo = candidate_jukugo;
+ }
+ }
+ if (chosen != (size_t)-1) {
+ *item_index = chosen;
+ return 1;
+ }
+
+ chosen = (size_t)-1;
+ chosen_card = 0UL;
+ chosen_jukugo = 0UL;
+ chosen_kind = 0;
+ for (i = 0U; i < deck->item_count; i++) {
+ item = &deck->items[i];
+ review = &deck->reviews[item->review_index];
+ if (review->introduced_day >= 0L) {
+ continue;
+ }
+ card = &deck->cards[item->card_index];
+ candidate_card = kd_parse_numeric_id(card->id, (unsigned long)item->card_index);
+ candidate_kind = item->jukugo_index == KD_NO_JUKUGO ? 0 : 1;
+ candidate_jukugo = 0UL;
+ if (candidate_kind != 0) {
+ jukugo_id = card->jukugo[(size_t)item->jukugo_index].id;
+ candidate_jukugo = kd_parse_numeric_id(jukugo_id,
+ (unsigned long)item->jukugo_index);
+ }
+ if (chosen == (size_t)-1 || candidate_card < chosen_card ||
+ (candidate_card == chosen_card && candidate_kind < chosen_kind) ||
+ (candidate_card == chosen_card && candidate_kind == chosen_kind &&
+ candidate_jukugo < chosen_jukugo)) {
+ chosen = i;
+ chosen_card = candidate_card;
+ chosen_kind = candidate_kind;
+ chosen_jukugo = candidate_jukugo;
+ }
+ }
+ if (chosen != (size_t)-1) {
+ review = &deck->reviews[deck->items[chosen].review_index];
+ review->introduced_day = today;
+ review->due_epoch = (long)now;
+ *item_index = chosen;
+ (void)kd_deck_save(deck);
+ return 1;
+ }
+ return 0;
+}
+
+void kd_grade(KDDeck *deck, size_t item_index, int remembered, time_t now)
+{
+ KDReview *review;
+ long next_interval;
+
+ if (item_index >= deck->item_count) {
+ return;
+ }
+ review = &deck->reviews[deck->items[item_index].review_index];
+ review->last_epoch = (long)now;
+ if (remembered) {
+ if (review->relearning) {
+ review->relearning = 0;
+ review->due_epoch = (long)now + review->interval_days * 86400L;
+ review->repetitions++;
+ (void)kd_deck_save(deck);
+ return;
+ }
+ if (review->repetitions <= 0L) {
+ next_interval = 1L;
+ } else if (review->repetitions == 1L) {
+ next_interval = 3L;
+ } else {
+ next_interval = (review->interval_days * review->ease_milli) / 1000L;
+ if (next_interval < 1L) {
+ next_interval = 1L;
+ }
+ }
+ if (next_interval > KD_MAX_INTERVAL_DAYS) {
+ next_interval = KD_MAX_INTERVAL_DAYS;
+ }
+ review->interval_days = next_interval;
+ review->repetitions++;
+ review->ease_milli += 50L;
+ if (review->ease_milli > 3000L) {
+ review->ease_milli = 3000L;
+ }
+ review->due_epoch = (long)now + next_interval * 86400L;
+ } else {
+ if (review->interval_days < 1L) {
+ review->interval_days = 1L;
+ }
+ review->interval_days = 1L;
+ if (review->repetitions < 2L) {
+ review->repetitions = 2L;
+ }
+ review->lapses++;
+ review->ease_milli -= 200L;
+ if (review->ease_milli < 1300L) {
+ review->ease_milli = 1300L;
+ }
+ review->relearning = 1;
+ review->due_epoch = (long)now + 600L;
+ }
+ (void)kd_deck_save(deck);
+}
+
+static void kd_reset_review_for_relearning(KDReview *review, time_t now)
+{
+ review->interval_days = 1L;
+ if (review->repetitions < 2L) {
+ review->repetitions = 2L;
+ }
+ review->lapses++;
+ review->ease_milli -= 200L;
+ if (review->ease_milli < 1300L) {
+ review->ease_milli = 1300L;
+ }
+ review->last_epoch = (long)now;
+ review->relearning = 1;
+ review->due_epoch = (long)now + 600L;
+ if (review->introduced_day < 0L) {
+ review->introduced_day = kd_today_key(now);
+ }
+}
+
+int kd_relearn_prompt(KDDeck *deck, size_t item_index, time_t now)
+{
+ KDReview *review;
+
+ if (deck == NULL || item_index >= deck->item_count) {
+ return 0;
+ }
+ review = &deck->reviews[deck->items[item_index].review_index];
+ kd_reset_review_for_relearning(review, now);
+ kd_add_message(deck, 0, "Relearning prompt %s: post-lapse interval set to 1 day; due in 10 minutes",
+ deck->items[item_index].key);
+ return kd_deck_save(deck);
+}
+
+int kd_relearn_card(KDDeck *deck, size_t card_index, time_t now)
+{
+ size_t i;
+ size_t reset_count;
+
+ if (deck == NULL || card_index >= deck->card_count) {
+ return 0;
+ }
+ reset_count = 0U;
+ for (i = 0U; i < deck->item_count; i++) {
+ if (deck->items[i].card_index == card_index) {
+ kd_reset_review_for_relearning(
+ &deck->reviews[deck->items[i].review_index], now);
+ reset_count++;
+ }
+ }
+ if (reset_count == 0U) {
+ return 0;
+ }
+ kd_add_message(deck, 0,
+ "Relearning kanji %s and %lu associated prompt(s): 10-minute step, then 1-day interval",
+ deck->cards[card_index].id,
+ (unsigned long)(reset_count - 1U));
+ return kd_deck_save(deck);
+}
diff --git a/src/kd_image.c b/src/kd_image.c
new file mode 100644
index 0000000..12f8485
--- /dev/null
+++ b/src/kd_image.c
@@ -0,0 +1,334 @@
+#define _POSIX_C_SOURCE 200809L
+
+#include "kd_image.h"
+
+#include <ctype.h>
+#include <errno.h>
+#include <limits.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <X11/Xutil.h>
+
+#define KD_PPM_MAX_PIXELS 20000000UL
+#define KD_PPM_TOKEN_SIZE 64
+
+typedef struct KDXImageHandle {
+ XImage *ximage;
+ GC clip_mask_gc;
+ Pixmap clip_mask;
+} KDXImageHandle;
+
+typedef struct KDRGBImage {
+ unsigned int width;
+ unsigned int height;
+ unsigned char *pixels;
+} KDRGBImage;
+
+static int kd_ppm_token(FILE *file, char *token, size_t token_size)
+{
+ int c;
+ size_t used;
+ int overflow;
+
+ do {
+ c = fgetc(file);
+ if (c == EOF) {
+ return 0;
+ }
+ if (c == '#') {
+ do {
+ c = fgetc(file);
+ } while (c != EOF && c != '\n' && c != '\r');
+ continue;
+ }
+ } while (isspace((unsigned char)c));
+
+ used = 0U;
+ overflow = 0;
+ while (c != EOF && !isspace((unsigned char)c) && c != '#') {
+ if (used + 1U < token_size) {
+ token[used++] = (char)c;
+ } else {
+ overflow = 1;
+ }
+ c = fgetc(file);
+ }
+ if (c == '#') {
+ do {
+ c = fgetc(file);
+ } while (c != EOF && c != '\n' && c != '\r');
+ } else if (c == '\r') {
+ int next_character;
+ next_character = fgetc(file);
+ if (next_character != '\n' && next_character != EOF) {
+ (void)ungetc(next_character, file);
+ }
+ }
+ token[used] = '\0';
+ return overflow ? -1 : 1;
+}
+
+static int kd_parse_positive(const char *token, unsigned long *value)
+{
+ char *end;
+ unsigned long parsed;
+
+ errno = 0;
+ parsed = strtoul(token, &end, 10);
+ if (errno != 0 || end == token || *end != '\0' || parsed == 0UL) {
+ return 0;
+ }
+ *value = parsed;
+ return 1;
+}
+
+static void kd_rgb_free(KDRGBImage *image)
+{
+ if (image != NULL) {
+ free(image->pixels);
+ image->pixels = NULL;
+ image->width = 0U;
+ image->height = 0U;
+ }
+}
+
+static int kd_ppm_read(FILE *file, KDRGBImage *image)
+{
+ char token[KD_PPM_TOKEN_SIZE];
+ int token_result;
+ int binary;
+ unsigned long width;
+ unsigned long height;
+ unsigned long max_value;
+ unsigned long pixel_count;
+ unsigned long i;
+ unsigned long scaled;
+ unsigned char *pixels;
+ size_t bytes;
+ size_t offset;
+ unsigned int channel;
+ char *end;
+
+ memset(image, 0, sizeof(*image));
+ token_result = kd_ppm_token(file, token, sizeof(token));
+ if (token_result != 1 || (strcmp(token, "P6") != 0 && strcmp(token, "P3") != 0)) {
+ return 0;
+ }
+ binary = strcmp(token, "P6") == 0;
+
+ if (kd_ppm_token(file, token, sizeof(token)) != 1 ||
+ !kd_parse_positive(token, &width)) {
+ return 0;
+ }
+ if (kd_ppm_token(file, token, sizeof(token)) != 1 ||
+ !kd_parse_positive(token, &height)) {
+ return 0;
+ }
+ if (kd_ppm_token(file, token, sizeof(token)) != 1 ||
+ !kd_parse_positive(token, &max_value) || max_value > 255UL) {
+ return 0;
+ }
+ pixel_count = width * height;
+ if (width > 8192UL || height > 8192UL ||
+ pixel_count > KD_PPM_MAX_PIXELS ||
+ pixel_count > (unsigned long)(((size_t)-1) / 3U)) {
+ return 0;
+ }
+ bytes = (size_t)pixel_count * 3U;
+ pixels = (unsigned char *)malloc(bytes);
+ if (pixels == NULL) {
+ return 0;
+ }
+
+ if (binary) {
+ if (fread(pixels, 1U, bytes, file) != bytes) {
+ free(pixels);
+ return 0;
+ }
+ if (max_value != 255UL) {
+ for (i = 0UL; i < (unsigned long)bytes; i++) {
+ scaled = ((unsigned long)pixels[i] * 255UL + max_value / 2UL) /
+ max_value;
+ pixels[i] = (unsigned char)scaled;
+ }
+ }
+ } else {
+ offset = 0U;
+ for (i = 0UL; i < pixel_count; i++) {
+ for (channel = 0U; channel < 3U; channel++) {
+ if (kd_ppm_token(file, token, sizeof(token)) != 1) {
+ free(pixels);
+ return 0;
+ }
+ errno = 0;
+ scaled = strtoul(token, &end, 10);
+ if (errno != 0 || end == token || *end != '\0' ||
+ scaled > max_value) {
+ free(pixels);
+ return 0;
+ }
+ pixels[offset++] = (unsigned char)
+ ((scaled * 255UL + max_value / 2UL) / max_value);
+ }
+ }
+ }
+ image->width = (unsigned int)width;
+ image->height = (unsigned int)height;
+ image->pixels = pixels;
+ return 1;
+}
+
+static unsigned long kd_component_pixel(unsigned char value, unsigned long mask)
+{
+ unsigned int shift;
+ unsigned long reduced_mask;
+ unsigned long scaled;
+
+ if (mask == 0UL) {
+ return 0UL;
+ }
+ shift = 0U;
+ reduced_mask = mask;
+ while ((reduced_mask & 1UL) == 0UL) {
+ reduced_mask >>= 1;
+ shift++;
+ }
+ scaled = ((unsigned long)value * reduced_mask + 127UL) / 255UL;
+ return (scaled << shift) & mask;
+}
+
+static void kd_choose_size(unsigned int source_width,
+ unsigned int source_height,
+ unsigned int max_width,
+ unsigned int max_height,
+ unsigned int *result_width,
+ unsigned int *result_height)
+{
+ double scale;
+
+ scale = 1.0;
+ if (max_width == 0U) {
+ max_width = source_width;
+ }
+ if (max_height == 0U) {
+ max_height = source_height;
+ }
+ if (source_width > max_width) {
+ scale = (double)max_width / (double)source_width;
+ }
+ if ((double)source_height * scale > (double)max_height) {
+ scale = (double)max_height / (double)source_height;
+ }
+ *result_width = (unsigned int)((double)source_width * scale);
+ *result_height = (unsigned int)((double)source_height * scale);
+ if (*result_width == 0U) {
+ *result_width = 1U;
+ }
+ if (*result_height == 0U) {
+ *result_height = 1U;
+ }
+}
+
+struct nk_image kd_ppm_load(Display *display, int screen,
+ const char *path,
+ unsigned int max_width,
+ unsigned int max_height)
+{
+ struct nk_image result;
+ FILE *file;
+ KDRGBImage source;
+ KDXImageHandle *handle;
+ XImage *ximage;
+ Visual *visual;
+ unsigned int out_width;
+ unsigned int out_height;
+ unsigned int x;
+ unsigned int y;
+ unsigned int source_x;
+ unsigned int source_y;
+ size_t source_offset;
+ unsigned char red;
+ unsigned char green;
+ unsigned char blue;
+ unsigned long pixel;
+
+ memset(&result, 0, sizeof(result));
+ memset(&source, 0, sizeof(source));
+ file = fopen(path, "rb");
+ if (file == NULL) {
+ return result;
+ }
+ if (!kd_ppm_read(file, &source)) {
+ (void)fclose(file);
+ return result;
+ }
+ (void)fclose(file);
+ kd_choose_size(source.width, source.height, max_width, max_height,
+ &out_width, &out_height);
+
+ visual = DefaultVisual(display, screen);
+ ximage = XCreateImage(display, visual,
+ (unsigned int)DefaultDepth(display, screen),
+ ZPixmap, 0, NULL, out_width, out_height,
+ BitmapPad(display), 0);
+ if (ximage == NULL) {
+ kd_rgb_free(&source);
+ return result;
+ }
+ ximage->data = (char *)calloc((size_t)ximage->bytes_per_line,
+ (size_t)out_height);
+ if (ximage->data == NULL) {
+ XDestroyImage(ximage);
+ kd_rgb_free(&source);
+ return result;
+ }
+
+ for (y = 0U; y < out_height; y++) {
+ source_y = (unsigned int)(((unsigned long)y * source.height) / out_height);
+ for (x = 0U; x < out_width; x++) {
+ source_x = (unsigned int)(((unsigned long)x * source.width) / out_width);
+ source_offset = ((size_t)source_y * source.width + source_x) * 3U;
+ red = source.pixels[source_offset];
+ green = source.pixels[source_offset + 1U];
+ blue = source.pixels[source_offset + 2U];
+ pixel = kd_component_pixel(red, visual->red_mask) |
+ kd_component_pixel(green, visual->green_mask) |
+ kd_component_pixel(blue, visual->blue_mask);
+ (void)XPutPixel(ximage, (int)x, (int)y, pixel);
+ }
+ }
+ kd_rgb_free(&source);
+
+ handle = (KDXImageHandle *)calloc(1U, sizeof(KDXImageHandle));
+ if (handle == NULL) {
+ XDestroyImage(ximage);
+ return result;
+ }
+ handle->ximage = ximage;
+ handle->clip_mask_gc = (GC)0;
+ handle->clip_mask = (Pixmap)0;
+ result.handle.ptr = (void *)handle;
+ result.w = (unsigned short)out_width;
+ result.h = (unsigned short)out_height;
+ result.region[0] = 0;
+ result.region[1] = 0;
+ result.region[2] = (unsigned short)out_width;
+ result.region[3] = (unsigned short)out_height;
+ return result;
+}
+
+void kd_ppm_free(struct nk_image *image)
+{
+ KDXImageHandle *handle;
+
+ if (image == NULL || image->handle.ptr == NULL) {
+ return;
+ }
+ handle = (KDXImageHandle *)image->handle.ptr;
+ if (handle->ximage != NULL) {
+ XDestroyImage(handle->ximage);
+ }
+ free(handle);
+ memset(image, 0, sizeof(*image));
+}
diff --git a/src/main.c b/src/main.c
new file mode 100644
index 0000000..d14e25d
--- /dev/null
+++ b/src/main.c
@@ -0,0 +1,976 @@
+#define _POSIX_C_SOURCE 200809L
+
+#include "kd_deck.h"
+#include "kd_image.h"
+
+#include <X11/Xlib.h>
+#include <X11/Xutil.h>
+#include <ctype.h>
+#include <errno.h>
+#include <fcntl.h>
+#include <locale.h>
+#include <signal.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <sys/types.h>
+#include <sys/stat.h>
+#include <sys/wait.h>
+#include <unistd.h>
+
+#include <nuklear.h>
+#include <nuklear_xlib.h>
+
+#define KD_WINDOW_WIDTH 1000
+#define KD_WINDOW_HEIGHT 760
+#define KD_IMAGE_MAX_WIDTH 760U
+#define KD_IMAGE_MAX_HEIGHT 340U
+#define KD_UI_TEXT_MAX 8400
+#define KD_KANJI_NUMBER_SIZE 32
+
+static int kd_running = 1;
+static int kd_revealed = 0;
+static size_t kd_current_item = (size_t)-1;
+static struct nk_context *kd_context = NULL;
+static Display *kd_display = NULL;
+static int kd_screen = 0;
+static Window kd_window = (Window)0;
+static Atom kd_delete_atom;
+static XFont *kd_font = NULL;
+static KDDeck kd_deck;
+static struct nk_image kd_current_image;
+static int kd_current_image_valid = 0;
+static int kd_pending_grade = 0;
+static size_t kd_pending_item = (size_t)-1;
+static size_t kd_session_card = (size_t)-1;
+static size_t kd_session_jukugo = 0U;
+static char kd_status[2048];
+static char kd_kanji_number[KD_KANJI_NUMBER_SIZE] = "1";
+static int kd_kanji_number_length = 1;
+static int kd_status_is_warning = 0;
+static char kd_cards_root[KD_PATH_MAX];
+static char kd_state_path[KD_PATH_MAX];
+static char kd_data_root[KD_PATH_MAX];
+static char kd_state_root[KD_PATH_MAX];
+
+static void kd_log_deck_summary(void);
+static void kd_log_current_prompt(void);
+static void kd_start_relearn_number(void);
+
+static int kd_mkdir_p(const char *path)
+{
+ char copy[KD_PATH_MAX];
+ size_t length;
+ size_t i;
+
+ if (path == NULL || path[0] == '\0') {
+ return 0;
+ }
+ length = strlen(path);
+ if (length >= sizeof(copy)) {
+ return 0;
+ }
+ memcpy(copy, path, length + 1U);
+ for (i = 1U; i < length; i++) {
+ if (copy[i] == '/') {
+ copy[i] = '\0';
+ if (copy[0] != '\0') {
+ struct stat info;
+ if (mkdir(copy, (mode_t)0700) != 0 && errno != EEXIST) {
+ return 0;
+ }
+ if (stat(copy, &info) != 0) {
+ return 0;
+ }
+ if (!S_ISDIR(info.st_mode)) {
+ errno = ENOTDIR;
+ return 0;
+ }
+ }
+ copy[i] = '/';
+ }
+ }
+ {
+ struct stat info;
+ if (mkdir(copy, (mode_t)0700) != 0 && errno != EEXIST) {
+ return 0;
+ }
+ if (stat(copy, &info) != 0) {
+ return 0;
+ }
+ if (!S_ISDIR(info.st_mode)) {
+ errno = ENOTDIR;
+ return 0;
+ }
+ }
+ return 1;
+}
+
+static int kd_copy_file_if_missing(const char *source, const char *destination)
+{
+ FILE *input;
+ FILE *output;
+ int descriptor;
+ unsigned char buffer[4096];
+ size_t count;
+ int failed;
+
+ if (strcmp(source, destination) == 0) {
+ return 1;
+ }
+ if (access(source, F_OK) != 0) {
+ if (errno == ENOENT) {
+ return 1;
+ }
+ (void)fprintf(stderr, "WARNING: cannot inspect legacy progress '%s': %s\n",
+ source, strerror(errno));
+ return 0;
+ }
+ if (access(destination, F_OK) == 0) {
+ (void)fprintf(stderr,
+ "WARNING: both legacy and current progress files exist. Using '%s'; keeping '%s' untouched.\n",
+ destination, source);
+ return 1;
+ }
+ input = fopen(source, "rb");
+ if (input == NULL) {
+ (void)fprintf(stderr, "WARNING: cannot read legacy progress '%s': %s\n",
+ source, strerror(errno));
+ return 0;
+ }
+ descriptor = open(destination, O_WRONLY | O_CREAT | O_EXCL, (mode_t)0600);
+ if (descriptor < 0) {
+ (void)fclose(input);
+ if (errno == EEXIST) {
+ return 1;
+ }
+ (void)fprintf(stderr, "WARNING: cannot create progress file '%s': %s\n",
+ destination, strerror(errno));
+ return 0;
+ }
+ output = fdopen(descriptor, "wb");
+ if (output == NULL) {
+ (void)close(descriptor);
+ (void)remove(destination);
+ (void)fclose(input);
+ return 0;
+ }
+ failed = 0;
+ while ((count = fread(buffer, 1U, sizeof(buffer), input)) > 0U) {
+ if (fwrite(buffer, 1U, count, output) != count) {
+ failed = 1;
+ break;
+ }
+ }
+ if (ferror(input) || fflush(output) != 0) {
+ failed = 1;
+ }
+ if (fclose(input) != 0) {
+ failed = 1;
+ }
+ if (fclose(output) != 0) {
+ failed = 1;
+ }
+ if (failed) {
+ (void)remove(destination);
+ (void)fprintf(stderr, "WARNING: failed migrating legacy progress '%s'.\n",
+ source);
+ return 0;
+ }
+ (void)fprintf(stderr, "INFO: copied legacy progress to '%s' (original kept).\n",
+ destination);
+ return 1;
+}
+
+static int kd_prepare_data_paths(void)
+{
+ const char *base;
+ const char *home;
+ char legacy_progress[KD_PATH_MAX];
+ int written;
+
+ base = getenv("XDG_DATA_HOME");
+ if (base == NULL || base[0] != '/') {
+ home = getenv("HOME");
+ if (home == NULL || home[0] != '/') {
+ (void)fprintf(stderr, "HOME is unset; pass explicit cards and progress paths.\n");
+ return 0;
+ }
+ written = snprintf(kd_data_root, sizeof(kd_data_root),
+ "%s/.local/share/kanji-deck", home);
+ } else {
+ written = snprintf(kd_data_root, sizeof(kd_data_root),
+ "%s/kanji-deck", base);
+ }
+ if (written < 0 || (size_t)written >= sizeof(kd_data_root)) {
+ (void)fprintf(stderr, "User data path is too long.\n");
+ return 0;
+ }
+ if (!kd_mkdir_p(kd_data_root)) {
+ (void)fprintf(stderr, "Cannot create user data directory '%s': %s\n",
+ kd_data_root, strerror(errno));
+ return 0;
+ }
+ written = snprintf(kd_cards_root, sizeof(kd_cards_root), "%s/cards", kd_data_root);
+ if (written < 0 || (size_t)written >= sizeof(kd_cards_root)) {
+ return 0;
+ }
+ if (!kd_mkdir_p(kd_cards_root)) {
+ (void)fprintf(stderr, "Cannot create cards directory '%s': %s\n",
+ kd_cards_root, strerror(errno));
+ return 0;
+ }
+
+ base = getenv("XDG_STATE_HOME");
+ if (base == NULL || base[0] != '/') {
+ home = getenv("HOME");
+ if (home == NULL || home[0] != '/') {
+ (void)fprintf(stderr, "HOME is unset; cannot determine review-state directory.\n");
+ return 0;
+ }
+ written = snprintf(kd_state_root, sizeof(kd_state_root),
+ "%s/.local/state/kanji-deck", home);
+ } else {
+ written = snprintf(kd_state_root, sizeof(kd_state_root),
+ "%s/kanji-deck", base);
+ }
+ if (written < 0 || (size_t)written >= sizeof(kd_state_root)) {
+ (void)fprintf(stderr, "User state path is too long.\n");
+ return 0;
+ }
+ if (!kd_mkdir_p(kd_state_root)) {
+ (void)fprintf(stderr, "Cannot create user state directory '%s': %s\n",
+ kd_state_root, strerror(errno));
+ return 0;
+ }
+ written = snprintf(kd_state_path, sizeof(kd_state_path), "%s/progress.txt",
+ kd_state_root);
+ if (written < 0 || (size_t)written >= sizeof(kd_state_path)) {
+ return 0;
+ }
+ written = snprintf(legacy_progress, sizeof(legacy_progress), "%s/progress.txt",
+ kd_data_root);
+ if (written < 0 || (size_t)written >= sizeof(legacy_progress)) {
+ return 0;
+ }
+ if (!kd_copy_file_if_missing(legacy_progress, kd_state_path)) {
+ return 0;
+ }
+ (void)fprintf(stderr, "Card data directory: %s\n", kd_data_root);
+ (void)fprintf(stderr, "Review state file: %s\n", kd_state_path);
+ return 1;
+}
+
+static void kd_set_status(int warning, const char *text)
+{
+ (void)snprintf(kd_status, sizeof(kd_status), "%s", text);
+ kd_status_is_warning = warning;
+}
+
+static int kd_locale_name_is_utf8(const char *name)
+{
+ char lower[128];
+ size_t i;
+
+ if (name == NULL) {
+ return 0;
+ }
+ for (i = 0U; i + 1U < sizeof(lower) && name[i] != '\0'; i++) {
+ lower[i] = (char)tolower((unsigned char)name[i]);
+ }
+ lower[i] = '\0';
+ return strstr(lower, "utf-8") != NULL || strstr(lower, "utf8") != NULL;
+}
+
+static int kd_prepare_utf8_locale(void)
+{
+ static const char *const candidates[] = {
+ "C.UTF-8", "C.utf8", "en_US.UTF-8", "ja_JP.UTF-8"
+ };
+ const char *locale_name;
+ size_t i;
+
+ locale_name = setlocale(LC_CTYPE, "");
+ if (kd_locale_name_is_utf8(locale_name)) {
+ return 1;
+ }
+ for (i = 0U; i < sizeof(candidates) / sizeof(candidates[0]); i++) {
+ locale_name = setlocale(LC_CTYPE, candidates[i]);
+ if (kd_locale_name_is_utf8(locale_name)) {
+ if (setenv("LC_ALL", locale_name, 1) != 0 ||
+ setlocale(LC_ALL, locale_name) == NULL) {
+ return 0;
+ }
+ return 1;
+ }
+ }
+ return 0;
+}
+
+static int kd_url_is_http(const char *url)
+{
+ if (url == NULL) {
+ return 0;
+ }
+ return strncmp(url, "http://", 7U) == 0 ||
+ strncmp(url, "https://", 8U) == 0;
+}
+
+static int kd_open_url(const char *url)
+{
+ pid_t child;
+ pid_t grandchild;
+ int wait_status;
+
+ if (!kd_url_is_http(url)) {
+ kd_set_status(1, "No usable http:// or https:// URL is configured in URL.txt.");
+ return 0;
+ }
+ child = fork();
+ if (child < (pid_t)0) {
+ kd_set_status(1, "Could not start xdg-open.");
+ return 0;
+ }
+ if (child == (pid_t)0) {
+ grandchild = fork();
+ if (grandchild < (pid_t)0) {
+ _exit(127);
+ }
+ if (grandchild > (pid_t)0) {
+ _exit(0);
+ }
+ (void)setsid();
+ execlp("xdg-open", "xdg-open", url, (char *)0);
+ _exit(127);
+ }
+ while (waitpid(child, &wait_status, 0) < (pid_t)0 && errno == EINTR) {
+ }
+ kd_set_status(0, "Asked xdg-open to open the relevant URL.");
+ return 1;
+}
+
+static void kd_free_current_image(void)
+{
+ if (kd_current_image_valid) {
+ kd_ppm_free(&kd_current_image);
+ kd_current_image_valid = 0;
+ }
+}
+
+static const char *kd_image_path_for_current(void)
+{
+ const KDStudyItem *item;
+ const KDCard *card;
+ const KDJukugo *jukugo;
+
+ if (kd_current_item == (size_t)-1 || kd_current_item >= kd_deck.item_count) {
+ return NULL;
+ }
+ item = &kd_deck.items[kd_current_item];
+ card = &kd_deck.cards[item->card_index];
+ if (item->jukugo_index == KD_NO_JUKUGO) {
+ return kd_revealed ? card->answer_image_path : card->image_path;
+ }
+ jukugo = &card->jukugo[(size_t)item->jukugo_index];
+ return kd_revealed ? jukugo->answer_path : jukugo->front_path;
+}
+
+static void kd_load_current_image(void)
+{
+ const char *path;
+
+ kd_free_current_image();
+ path = kd_image_path_for_current();
+ if (path == NULL) {
+ return;
+ }
+ kd_current_image = kd_ppm_load(kd_display, kd_screen, path,
+ KD_IMAGE_MAX_WIDTH, KD_IMAGE_MAX_HEIGHT);
+ if (kd_current_image.handle.ptr != NULL) {
+ kd_current_image_valid = 1;
+ (void)fprintf(stderr, "IMAGE: loaded '%s' (%u x %u pixels).\n",
+ path, (unsigned int)kd_current_image.w,
+ (unsigned int)kd_current_image.h);
+ } else {
+ (void)snprintf(kd_status, sizeof(kd_status),
+ "Could not load '%s'. Use P3 or P6 PPM with max value 255.", path);
+ kd_status_is_warning = 1;
+ (void)fprintf(stderr,
+ "IMAGE ERROR: could not load '%s'; verify that the file exists and is P3/P6 PPM with max value 255.\n",
+ path);
+ }
+}
+
+static void kd_log_deck_summary(void)
+{
+ size_t i;
+ const KDCard *card;
+
+ (void)fprintf(stderr,
+ "DECK: %lu kanji directories, %lu study prompts, %lu diagnostics\n",
+ (unsigned long)kd_deck.card_count,
+ (unsigned long)kd_deck.item_count,
+ (unsigned long)kd_deck.message_count);
+ for (i = 0U; i < kd_deck.card_count; i++) {
+ card = &kd_deck.cards[i];
+ (void)fprintf(stderr,
+ "CARD %s: kanji image=%s, answer image=%s, URL.txt=%s, jukugo=%lu\n",
+ card->id, card->has_image ? "present" : "MISSING",
+ card->has_answer_image ? "present" : "MISSING",
+ card->has_url ? "present" : "MISSING",
+ (unsigned long)card->jukugo_count);
+ if (card->url[0] != '\0') {
+ (void)fprintf(stderr, " URL: %s\n", card->url);
+ }
+ }
+}
+
+static void kd_log_current_prompt(void)
+{
+ const KDStudyItem *item;
+ const KDCard *card;
+ const KDJukugo *jukugo;
+
+ if (kd_current_item == (size_t)-1 || kd_current_item >= kd_deck.item_count) {
+ (void)fprintf(stderr, "CURRENT: no prompt is currently available\n");
+ return;
+ }
+ item = &kd_deck.items[kd_current_item];
+ card = &kd_deck.cards[item->card_index];
+ if (item->jukugo_index == KD_NO_JUKUGO) {
+ (void)fprintf(stderr,
+ "CURRENT: %s is a kanji prompt; kanji_ans.ppm will show when revealed.\n",
+ item->key);
+ } else {
+ jukugo = &card->jukugo[(size_t)item->jukugo_index];
+ (void)fprintf(stderr,
+ "CURRENT: %s is a jukugo prompt (card %s, jukugo %s); its answer is the paired image.\n"
+ " Kanji answer is kanji_ans.ppm; source link comes from URL.txt.\n",
+ item->key, card->id, jukugo->id);
+ }
+}
+
+static void kd_choose_next(void)
+{
+ time_t now;
+ size_t i;
+ size_t wanted;
+
+ now = time(NULL);
+ kd_revealed = 0;
+ kd_current_item = (size_t)-1;
+ if (kd_session_card != (size_t)-1 &&
+ kd_session_card < kd_deck.card_count) {
+ if (kd_session_jukugo < kd_deck.cards[kd_session_card].jukugo_count) {
+ wanted = kd_session_jukugo;
+ for (i = 0U; i < kd_deck.item_count; i++) {
+ if (kd_deck.items[i].card_index == kd_session_card &&
+ kd_deck.items[i].jukugo_index == (int)wanted) {
+ kd_current_item = i;
+ break;
+ }
+ }
+ if (kd_current_item != (size_t)-1) {
+ (void)fprintf(stderr, "SESSION: next jukugo %lu for kanji %s.\n",
+ (unsigned long)(wanted + 1U), kd_deck.cards[kd_session_card].id);
+ } else {
+ (void)fprintf(stderr, "WARNING: queued jukugo item is missing.\n");
+ }
+ } else {
+ (void)fprintf(stderr, "SESSION: finished jukugo for kanji %s.\n",
+ kd_deck.cards[kd_session_card].id);
+ kd_session_card = (size_t)-1;
+ kd_session_jukugo = 0U;
+ }
+ }
+ if (kd_current_item == (size_t)-1 &&
+ !kd_select_next(&kd_deck, now, &kd_current_item)) {
+ kd_current_item = (size_t)-1;
+ }
+ if (kd_current_item != (size_t)-1) {
+ kd_load_current_image();
+ } else {
+ kd_free_current_image();
+ }
+ kd_log_current_prompt();
+}
+
+static void kd_start_relearn_card(size_t card_index)
+{
+ size_t i;
+ size_t main_item;
+
+ if (card_index >= kd_deck.card_count) {
+ kd_set_status(1, "Selected kanji card no longer exists.");
+ return;
+ }
+ main_item = (size_t)-1;
+ for (i = 0U; i < kd_deck.item_count; i++) {
+ if (kd_deck.items[i].card_index == card_index &&
+ kd_deck.items[i].jukugo_index == KD_NO_JUKUGO) {
+ main_item = i;
+ break;
+ }
+ }
+ if (main_item == (size_t)-1) {
+ kd_set_status(1, "Could not find the main kanji prompt for this card.");
+ return;
+ }
+ if (!kd_relearn_card(&kd_deck, card_index, time(NULL))) {
+ kd_set_status(1, "Could not save the relearning schedule; see terminal output.");
+ return;
+ }
+ kd_session_card = card_index;
+ kd_session_jukugo = 0U;
+ kd_current_item = main_item;
+ kd_revealed = 0;
+ kd_load_current_image();
+ (void)fprintf(stderr,
+ "RELEARN GROUP: kanji %s and all %lu jukugo reset to scheduled for a 10-minute learning step; reviewing the whole set.\n",
+ kd_deck.cards[card_index].id,
+ (unsigned long)kd_deck.cards[card_index].jukugo_count);
+ kd_log_current_prompt();
+ (void)snprintf(kd_status, sizeof(kd_status),
+ "Relearning kanji %s and its jukugo. Each prompt has a 10-minute step and a 1-day post-lapse interval; grade each independently.",
+ kd_deck.cards[card_index].id);
+ kd_status_is_warning = 0;
+}
+
+static void kd_start_relearn_number(void)
+{
+ size_t item_index;
+ size_t card_index;
+
+ if (!kd_find_kanji_item_by_number(&kd_deck, kd_kanji_number,
+ &item_index)) {
+ (void)snprintf(kd_status, sizeof(kd_status),
+ "Kanji number '%s' was not found. Check the discovered card numbers.",
+ kd_kanji_number);
+ kd_status_is_warning = 1;
+ (void)fprintf(stderr,
+ "NAVIGATION ERROR: no main kanji prompt matches number '%s'.\n",
+ kd_kanji_number);
+ return;
+ }
+ card_index = kd_deck.items[item_index].card_index;
+ kd_start_relearn_card(card_index);
+}
+
+static void kd_refresh_deck(void)
+{
+ kd_free_current_image();
+ kd_current_item = (size_t)-1;
+ kd_revealed = 0;
+ if (kd_deck_scan(&kd_deck, kd_cards_root, kd_state_path)) {
+ kd_set_status(0, "Rescanned cards. New prompt files are listed below and in the terminal.");
+ kd_log_deck_summary();
+ kd_choose_next();
+ } else {
+ kd_set_status(1, "Rescan failed; inspect the terminal warnings and cards directory.");
+ }
+}
+
+static void kd_draw_message_panel(void)
+{
+ size_t i;
+ char title[128];
+
+ (void)snprintf(title, sizeof(title), "Diagnostics / discovered files (%lu)",
+ (unsigned long)kd_deck.message_count);
+ nk_layout_row_dynamic(kd_context, 28.0f, 1);
+ nk_label_wrap(kd_context,
+ "File warnings are intentionally repeated on every scan. Fix the naming or metadata, or leave the warning visible.");
+ nk_layout_row_dynamic(kd_context, 84.0f, 1);
+ if (nk_group_begin(kd_context, title, NK_WINDOW_BORDER)) {
+ for (i = 0U; i < kd_deck.message_count; i++) {
+ nk_layout_row_dynamic(kd_context, 24.0f, 1);
+ nk_label_wrap(kd_context, kd_deck.messages[i].text);
+ }
+ nk_group_end(kd_context);
+ }
+}
+
+static void kd_draw_ui(void)
+{
+ XWindowAttributes attributes;
+ struct nk_rect bounds;
+ time_t now;
+ size_t due_count;
+ size_t unintroduced;
+ size_t new_today;
+ char summary[512];
+ const KDStudyItem *item;
+ const KDCard *card;
+ const KDJukugo *jukugo;
+ const char *url;
+ int is_jukugo;
+ long interval;
+ char status_display[2200];
+
+ if (XGetWindowAttributes(kd_display, kd_window, &attributes) == 0) {
+ return;
+ }
+ now = time(NULL);
+ due_count = kd_count_due(&kd_deck, now);
+ unintroduced = kd_count_unintroduced(&kd_deck);
+ new_today = kd_new_today(&kd_deck, now);
+ bounds = nk_rect(0.0f, 0.0f, (float)attributes.width,
+ (float)attributes.height);
+
+ if (nk_begin(kd_context, "Kanji Deck", bounds,
+ NK_WINDOW_BORDER | NK_WINDOW_TITLE | NK_WINDOW_MOVABLE |
+ NK_WINDOW_SCALABLE)) {
+ nk_layout_row_dynamic(kd_context, 26.0f, 1);
+ (void)snprintf(summary, sizeof(summary),
+ "Due now: %lu | New today: %lu/%d | Unintroduced prompts: %lu",
+ (unsigned long)due_count, (unsigned long)new_today,
+ KD_NEW_PER_DAY, (unsigned long)unintroduced);
+ nk_label(kd_context, summary, NK_TEXT_LEFT);
+
+ nk_layout_row_dynamic(kd_context, 30.0f, 2);
+ if (nk_button_label(kd_context, "Rescan files")) {
+ kd_refresh_deck();
+ now = time(NULL);
+ due_count = kd_count_due(&kd_deck, now);
+ unintroduced = kd_count_unintroduced(&kd_deck);
+ new_today = kd_new_today(&kd_deck, now);
+ }
+ if (nk_button_label(kd_context, "Save progress")) {
+ if (kd_deck_save(&kd_deck)) {
+ kd_set_status(0, "Progress saved.");
+ } else {
+ kd_set_status(1, "Could not save progress; see terminal output.");
+ }
+ }
+
+ nk_layout_row_dynamic(kd_context, 24.0f, 1);
+ nk_label_wrap(kd_context,
+ "Jump to a kanji number and relearn that kanji plus its jukugo (resets their schedules):");
+ nk_layout_row_dynamic(kd_context, 32.0f, 2);
+ (void)nk_edit_string(kd_context, NK_EDIT_FIELD, kd_kanji_number,
+ &kd_kanji_number_length, KD_KANJI_NUMBER_SIZE, nk_filter_decimal);
+ if (kd_kanji_number_length < 0) {
+ kd_kanji_number_length = 0;
+ }
+ if (kd_kanji_number_length >= KD_KANJI_NUMBER_SIZE) {
+ kd_kanji_number_length = KD_KANJI_NUMBER_SIZE - 1;
+ }
+ kd_kanji_number[kd_kanji_number_length] = '\0';
+ if (nk_button_label(kd_context, "Relearn number + jukugo")) {
+ kd_start_relearn_number();
+ }
+
+ nk_layout_row_dynamic(kd_context, 26.0f, 1);
+ if (kd_current_item == (size_t)-1 || kd_current_item >= kd_deck.item_count) {
+ if (due_count == 0U && unintroduced == 0U) {
+ nk_label_wrap(kd_context,
+ "Nothing is due now. Come back when the next scheduled review is due.");
+ } else {
+ nk_label_wrap(kd_context,
+ "No prompt is currently available. Press Rescan files after adding or fixing card files.");
+ }
+ } else {
+ item = &kd_deck.items[kd_current_item];
+ card = &kd_deck.cards[item->card_index];
+ is_jukugo = item->jukugo_index != KD_NO_JUKUGO;
+ jukugo = is_jukugo ? &card->jukugo[(size_t)item->jukugo_index] : NULL;
+ url = kd_card_url(card);
+ (void)snprintf(summary, sizeof(summary), "%s | %s%s",
+ item->key, is_jukugo ? "Jukugo prompt " : "Kanji prompt ",
+ is_jukugo ? jukugo->id : card->id);
+ nk_label(kd_context, summary, NK_TEXT_LEFT);
+ if (is_jukugo) {
+ nk_layout_row_dynamic(kd_context, 28.0f, 1);
+ nk_label_wrap(kd_context,
+ "Jukugo answers are paired images. The main kanji answer is kanji_ans.ppm.");
+ }
+
+ nk_layout_row_dynamic(kd_context, 32.0f, 2);
+ if (nk_button_label(kd_context, kd_revealed ? "Hide answer" : "Show answer")) {
+ kd_revealed = !kd_revealed;
+ if (is_jukugo) {
+ (void)fprintf(stderr, "%s jukugo answer image for %s (%s).\n",
+ kd_revealed ? "REVEAL:" : "HIDE:", item->key,
+ jukugo->answer_path);
+ } else if (kd_revealed) {
+ (void)fprintf(stderr,
+ "REVEAL: answer image for %s (%s).\n",
+ item->key, card->answer_image_path);
+ } else {
+ (void)fprintf(stderr, "HIDE: metadata rows for %s.\n", item->key);
+ }
+ kd_load_current_image();
+ }
+ if (nk_button_label(kd_context, kd_url_is_http(url) ? "Open URL" : "No valid URL")) {
+ if (kd_url_is_http(url)) {
+ (void)kd_open_url(url);
+ } else {
+ kd_set_status(1, "Put the source URL on the first line of URL.txt.");
+ }
+ }
+
+ if (kd_revealed) {
+ nk_layout_row_dynamic(kd_context, 24.0f, 1);
+ nk_label_wrap(kd_context, is_jukugo ?
+ "Jukugo answer image:" : "Kanji answer image:");
+ }
+
+ if (kd_current_image_valid) {
+ nk_layout_row_dynamic(kd_context,
+ (float)kd_current_image.h + 12.0f, 1);
+ nk_image(kd_context, kd_current_image);
+ } else {
+ nk_layout_row_dynamic(kd_context, 36.0f, 1);
+ nk_label_wrap(kd_context, "Image unavailable; check PPM format and the warnings.");
+ }
+ if (kd_revealed) {
+ nk_layout_row_dynamic(kd_context, 32.0f, 2);
+ if (nk_button_label(kd_context, "Yes / remembered")) {
+ (void)fprintf(stderr, "GRADE: YES / remembered selected for %s.\n",
+ item->key);
+ kd_pending_grade = 1;
+ kd_pending_item = kd_current_item;
+ }
+ if (nk_button_label(kd_context, "No / forgot")) {
+ (void)fprintf(stderr, "GRADE: NO / forgot selected for %s.\n",
+ item->key);
+ kd_pending_grade = -1;
+ kd_pending_item = kd_current_item;
+ }
+ }
+ /* Retry is schedule-neutral; group relearning changes schedules. */
+ nk_layout_row_dynamic(kd_context, 30.0f, 2);
+ if (nk_button_label(kd_context, "Try again (no grade)")) {
+ kd_revealed = 0;
+ kd_load_current_image();
+ (void)fprintf(stderr,
+ "PRACTICE: retrying %s without changing review schedule.\n",
+ item->key);
+ kd_set_status(0,
+ "Prompt repeated. Review scheduling was not changed.");
+ }
+ if (nk_button_label(kd_context, "Relearn whole kanji + jukugo")) {
+ kd_start_relearn_card(item->card_index);
+ }
+
+ }
+
+ if (kd_status[0] != '\0') {
+ (void)snprintf(status_display, sizeof(status_display), "%s%s",
+ kd_status_is_warning ? "WARNING: " : "Status: ", kd_status);
+ nk_layout_row_dynamic(kd_context, 30.0f, 1);
+ nk_label_wrap(kd_context, status_display);
+ }
+ kd_draw_message_panel();
+ nk_layout_row_dynamic(kd_context, 22.0f, 1);
+ nk_label_wrap(kd_context,
+ "Daily target: suggestion only; counts new main kanji, never jukugo.");
+ }
+ nk_end(kd_context);
+ nk_xlib_render(kd_window, nk_rgb(245, 245, 245));
+ if (kd_pending_grade != 0 && kd_pending_item < kd_deck.item_count) {
+ if (kd_pending_grade > 0) {
+ kd_grade(&kd_deck, kd_pending_item, 1, now);
+ interval = kd_deck.reviews[
+ kd_deck.items[kd_pending_item].review_index].interval_days;
+ (void)snprintf(kd_status, sizeof(kd_status),
+ "Remembered. Scheduled again in %ld day(s).", interval);
+ kd_status_is_warning = 0;
+ } else {
+ kd_grade(&kd_deck, kd_pending_item, 0, now);
+ kd_set_status(0,
+ "Forgotten. This prompt becomes due again in about 10 minutes.");
+ }
+ if (kd_session_card != (size_t)-1 &&
+ kd_deck.items[kd_pending_item].card_index == kd_session_card) {
+ if (kd_deck.items[kd_pending_item].jukugo_index == KD_NO_JUKUGO) {
+ kd_session_jukugo = 0U;
+ } else {
+ kd_session_jukugo =
+ (size_t)kd_deck.items[kd_pending_item].jukugo_index + 1U;
+ }
+ }
+ kd_pending_grade = 0;
+ kd_pending_item = (size_t)-1;
+ kd_choose_next();
+ XClearArea(kd_display, kd_window, 0, 0, 0U, 0U, True);
+ }
+}
+
+static void kd_cleanup(void)
+{
+ kd_free_current_image();
+ if (kd_context != NULL) {
+ nk_xlib_shutdown();
+ kd_context = NULL;
+ }
+ if (kd_font != NULL) {
+ nk_xfont_del(kd_display, kd_font);
+ kd_font = NULL;
+ }
+ if (kd_display != NULL) {
+ if (kd_window != (Window)0) {
+ XDestroyWindow(kd_display, kd_window);
+ }
+ XCloseDisplay(kd_display);
+ kd_display = NULL;
+ }
+ kd_deck_free(&kd_deck);
+}
+
+int main(int argc, char **argv)
+{
+ XSetWindowAttributes attributes;
+ XEvent event;
+ XSizeHints size_hints;
+ int screen;
+ int flags;
+ const char *font_pattern;
+
+ kd_deck_init(&kd_deck);
+ font_pattern = NULL;
+ memset(&kd_current_image, 0, sizeof(kd_current_image));
+ kd_status[0] = '\0';
+ if (argc > 3) {
+ (void)fprintf(stderr, "Usage: %s [cards-directory [progress-file]]\n", argv[0]);
+ return EXIT_FAILURE;
+ }
+ if (argc == 1 && !kd_prepare_data_paths()) {
+ return EXIT_FAILURE;
+ }
+ if (argc >= 2) {
+ (void)snprintf(kd_state_path, sizeof(kd_state_path), "progress.txt");
+ if (strlen(argv[1]) >= sizeof(kd_cards_root)) {
+ (void)fprintf(stderr, "Cards path is too long.\n");
+ return EXIT_FAILURE;
+ }
+ (void)snprintf(kd_cards_root, sizeof(kd_cards_root), "%s", argv[1]);
+ }
+ if (argc >= 3) {
+ if (strlen(argv[2]) >= sizeof(kd_state_path)) {
+ (void)fprintf(stderr, "Progress path is too long.\n");
+ return EXIT_FAILURE;
+ }
+ (void)snprintf(kd_state_path, sizeof(kd_state_path), "%s", argv[2]);
+ }
+
+ if (!kd_prepare_utf8_locale()) {
+ (void)fprintf(stderr,
+ "WARNING: No UTF-8 locale is available; Japanese text may be garbled.\n"
+ "Install/enable a UTF-8 locale, or try: LC_ALL=C.UTF-8 ./kanji-deck\n");
+ kd_set_status(1,
+ "No UTF-8 locale found. Enable one and restart; Japanese text may be garbled.");
+ } else {
+ (void)fprintf(stderr, "Using text locale: %s\n",
+ setlocale(LC_CTYPE, NULL));
+ }
+ (void)XSetLocaleModifiers("");
+ kd_display = XOpenDisplay(NULL);
+ if (kd_display == NULL) {
+ (void)fprintf(stderr, "Could not open the X display. Is X11/XWayland available?\n");
+ kd_deck_free(&kd_deck);
+ return EXIT_FAILURE;
+ }
+ screen = DefaultScreen(kd_display);
+ kd_screen = screen;
+#ifdef NK_XLIB_USE_XFT
+ font_pattern = "monospace:lang=ja:size=14";
+ kd_font = nk_xfont_create(kd_display, font_pattern);
+ if (kd_font == NULL) {
+ font_pattern = "sans-serif:lang=ja:size=14";
+ kd_font = nk_xfont_create(kd_display, font_pattern);
+ }
+ if (kd_font == NULL) {
+ font_pattern = "monospace:size=14";
+ kd_font = nk_xfont_create(kd_display, font_pattern);
+ }
+#else
+ font_pattern = "-*-*-medium-r-normal--14-*-*-*-*-*-*-*";
+ kd_font = nk_xfont_create(kd_display, font_pattern);
+ if (kd_font == NULL) {
+ font_pattern = "fixed";
+ kd_font = nk_xfont_create(kd_display, font_pattern);
+ }
+#endif
+ if (kd_font == NULL) {
+ (void)fprintf(stderr, "Could not create an X11 font set.\n");
+ kd_cleanup();
+ return EXIT_FAILURE;
+ }
+#ifdef NK_XLIB_USE_XFT
+ (void)fprintf(stderr,
+ "TEXT: Xft UTF-8 renderer; requested Fontconfig pattern: %s\n",
+ font_pattern != NULL ? font_pattern : "(unknown)");
+#else
+ (void)fprintf(stderr,
+ "TEXT: Xlib multibyte font-set renderer; requested pattern: %s\n"
+ "WARNING: legacy X core fonts often lack Japanese glyphs; install Xft and a CJK font for reliable kana.\n",
+ font_pattern != NULL ? font_pattern : "(unknown)");
+#endif
+
+ attributes.background_pixel = WhitePixel(kd_display, screen);
+ attributes.event_mask = ExposureMask | StructureNotifyMask | KeyPressMask |
+ KeyReleaseMask | ButtonPressMask | ButtonReleaseMask |
+ PointerMotionMask | ButtonMotionMask | LeaveWindowMask;
+ kd_window = XCreateWindow(kd_display, RootWindow(kd_display, screen),
+ 0, 0, KD_WINDOW_WIDTH, KD_WINDOW_HEIGHT, 0,
+ CopyFromParent, InputOutput, CopyFromParent,
+ CWBackPixel | CWEventMask, &attributes);
+ if (kd_window == (Window)0) {
+ (void)fprintf(stderr, "Could not create the X11 window.\n");
+ kd_cleanup();
+ return EXIT_FAILURE;
+ }
+ XStoreName(kd_display, kd_window, "Kanji Deck");
+ size_hints.flags = PMinSize;
+ size_hints.min_width = 640;
+ size_hints.min_height = 520;
+ XSetWMNormalHints(kd_display, kd_window, &size_hints);
+ kd_delete_atom = XInternAtom(kd_display, "WM_DELETE_WINDOW", False);
+ (void)XSetWMProtocols(kd_display, kd_window, &kd_delete_atom, 1);
+
+#ifdef NK_XLIB_USE_XFT
+ kd_context = nk_xlib_init(kd_font, kd_display, screen,
+ RootWindow(kd_display, screen), DefaultVisual(kd_display, screen),
+ DefaultColormap(kd_display, screen), KD_WINDOW_WIDTH, KD_WINDOW_HEIGHT);
+#else
+ kd_context = nk_xlib_init(kd_font, kd_display, screen,
+ RootWindow(kd_display, screen), KD_WINDOW_WIDTH, KD_WINDOW_HEIGHT);
+#endif
+ if (kd_context == NULL) {
+ (void)fprintf(stderr, "Could not initialize Nuklear's Xlib backend.\n");
+ kd_cleanup();
+ return EXIT_FAILURE;
+ }
+ nk_style_default(kd_context);
+ XMapWindow(kd_display, kd_window);
+
+ if (!kd_deck_scan(&kd_deck, kd_cards_root, kd_state_path)) {
+ kd_set_status(1, "Initial scan was incomplete; fix the warnings and use Rescan files.");
+ }
+ kd_log_deck_summary();
+ kd_choose_next();
+
+ while (kd_running) {
+ nk_input_begin(kd_context);
+ do {
+ XNextEvent(kd_display, &event);
+ if (event.type == ClientMessage &&
+ (Atom)event.xclient.data.l[0] == kd_delete_atom) {
+ kd_running = 0;
+ } else if (event.type == DestroyNotify) {
+ kd_running = 0;
+ } else {
+ flags = nk_xlib_handle_event(kd_display, screen, kd_window, &event);
+ (void)flags;
+ }
+ } while (kd_running && XPending(kd_display) > 0);
+ nk_input_end(kd_context);
+ if (kd_running) {
+ kd_draw_ui();
+ }
+ }
+
+ kd_cleanup();
+ return EXIT_SUCCESS;
+}
diff --git a/src/nuklear_impl.c b/src/nuklear_impl.c
new file mode 100644
index 0000000..5d67089
--- /dev/null
+++ b/src/nuklear_impl.c
@@ -0,0 +1,5 @@
+#define NK_IMPLEMENTATION
+#include <nuklear.h>
+
+#define NK_XLIB_IMPLEMENTATION
+#include <nuklear_xlib.h>