diff options
| author | Doan Luu Duc Tai <doan@luuduct.ai> | 2026-10-11 19:21:34 -0500 |
|---|---|---|
| committer | Doan Luu Duc Tai <doan@luuduct.ai> | 2026-10-11 19:21:34 -0500 |
| commit | f1ae8c36c94a26e8aff2339e241542f430c96913 (patch) | |
| tree | 998bc5dceffa7ddd691953717e966502dc4ff622 /src/main.c | |
Diffstat (limited to 'src/main.c')
| -rw-r--r-- | src/main.c | 976 |
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; +} |
