summaryrefslogtreecommitdiff
path: root/src/main.c
diff options
context:
space:
mode:
authorDoan Luu Duc Tai <doan@luuduct.ai>2026-10-11 19:21:34 -0500
committerDoan Luu Duc Tai <doan@luuduct.ai>2026-10-11 19:21:34 -0500
commitf1ae8c36c94a26e8aff2339e241542f430c96913 (patch)
tree998bc5dceffa7ddd691953717e966502dc4ff622 /src/main.c
Initial commitHEADmain
Diffstat (limited to 'src/main.c')
-rw-r--r--src/main.c976
1 files changed, 976 insertions, 0 deletions
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;
+}