diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/kd_deck.c | 1228 | ||||
| -rw-r--r-- | src/kd_image.c | 334 | ||||
| -rw-r--r-- | src/main.c | 976 | ||||
| -rw-r--r-- | src/nuklear_impl.c | 5 |
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> |
