// Copyright 2023 Daniel Martins // License GPL-3.0-or-later // // This program is free software: you can redistribute it and/or modify it under // the terms of the GNU General Public License as published by the Free Software // Foundation, either version 3 of the License, or (at your option) any later // version. // // This program is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS // FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. // // You should have received a copy of the GNU General Public License along with // this program. If not, see . // Compilation commands: // - release : jai ttt.jai -import_dir . -quiet -x64 -release // - debug : jai ttt.jai -import_dir . -quiet -x64 #import "Basic"()(MEMORY_DEBUGGER=true); #import "System"; #import "Math"; #import "POSIX"; #import "File"; #import "File_Utilities"; #import "String"; #import "curses"; VERSION :: "2.0"; // Use only 3 chars (to fit layouts). YEAR :: "2023"; TASK_NAME_LENGTH :: 57; TASK_NAME_BYTES :: #run TASK_NAME_LENGTH+1; // TODO Get rid of this! FIRST_DAY_OF_WEEK :: 1; // (0-6, Sunday = 0). NUM_WEEK_DAYS :: 7; // Just to be more clear about what we're looping about. APP_FOLDER_NAME :: ".task_time_tracker_v2"; // TODO Using _v2 to avoid erasing my work data. DB_FILE_NAME :: "database.bin"; AR_FILE_NAME :: "archive.csv"; DB_FILE_SIGN_STR :: "TTT:B:02"; SECONDS_IN_MINUTE :: cast(s64)60; SECONDS_IN_HOUR :: cast(s64)60*SECONDS_IN_MINUTE; SECONDS_IN_DAY :: cast(s64)24*SECONDS_IN_HOUR; SECONDS_IN_YEAR :: cast(s64)365*SECONDS_IN_DAY; MAX_DATABASE_TASKS :: S64_MAX; Task :: struct { times : [NUM_WEEK_DAYS] s64; name : [72] u8; //name_data : [70] u8; //name.data = cast(*~s8 u8) (0x80 ^ 0x01); // *name_data[0]; } Database :: struct { modified_on : Apollo_Time; active_idx : s64; selected_idx : s64; total_times : [NUM_WEEK_DAYS] s64; tasks : [] Task; capacity : s64; } // const char DB_FILE_SIGN[] = DB_FILE_SIGN_STR; // const size_t DB_FILE_SIGN_LENGTH = sizeof(DB_FILE_SIGN_STR)-1; // const size_t SIZEOF_TASK_ST = sizeof(task_st); // const size_t SIZEOF_DATABASE_ST = sizeof(database_st); // const size_t SIZEOF_CHAR = sizeof(char); // const size_t SIZEOF_INT64 = sizeof(int64_t); // // // database : Database; archive : Database; is_autosave_enabled := true; countdown_to_autosave := -1; app_directory : string; db_file_path : string; ar_file_path : string; // char *string_buffer = NULL; // A temporary buffer for localized actions. Please avoid data leaks and out-of-bounds errors. // size_t string_buffer_size = 0; size_x : s32; size_y : s32; pos_x : s32; pos_y : s32; Styles :: enum s16 { SELECTED :: 1; SELECTED_INVERTED; ACTIVE; ACTIVE_SELECTED; ERROR; } Layouts :: enum u8 { NORMAL; COMPACT; } error_window : *WINDOW = null; error_time_limit := Apollo_Time.{0, 0}; print_error :: (format :string, args : .. Any) { // NOTE Implement me please. // if stdscr == null || isendwin() == true { // Not in ncurses mode? print(format, ..args); print("\n"); // } // else { // int w_size_x = size_x > 120 ? 120 : size_x - 2; // int w_size_y = 4; // if (error_window == NULL) { // error_window = newwin(w_size_y, w_size_x, (size_y - w_size_y) / 2, (size_x - w_size_x) / 2); // wattron(error_window, COLOR_PAIR(ERROR)); // wborder(error_window, ' ', ' ', 0, 0, ACS_HLINE, ACS_HLINE, ACS_HLINE, ACS_HLINE); // mvwprintw(error_window, 0, 1, " Error "); // wmove(error_window, 1, 0); // } // else { // waddch(error_window, ' '); // } // vw_printw(error_window, format, args); // error_time_limit = time(NULL) + 5; // NOTE Instead of time use the current_time_monotonic() // } } draw_error_window :: () { /* NOTE Implement me please. if (error_window == NULL) { return; } // Hide error window after time-limit or if terminal is shrank. int w_size_x, w_size_y; getmaxyx(error_window, w_size_y, w_size_x); if (time(NULL) >= error_time_limit || size_x - w_size_x < 2 || size_y - w_size_y < 2 ) { delwin(error_window); error_window = NULL; return; } // Adjust error window position. int pos_x = (size_x - w_size_x) / 2; int pos_y = (size_y - w_size_y) / 2; mvwin(error_window, pos_y, pos_x); // Avoid being overwritten by main window content. refresh(); touchwin(error_window); wrefresh(error_window); */ } trigger_autosave :: () { countdown_to_autosave = 13375; // ms } show_processing :: () { mvaddch(0, 0, ACS_DIAMOND); refresh(); } // Returns true if string to_compare is equal to any of the other passed strings, false otherwise. is_equal_to_any :: (to_compare :string, test_a :string, test_b :string) -> bool { return to_compare == test_a || to_compare == test_b; } // Given an UTF8 encoded string, truncate it to length bytes without breaking any UTF8 character. // The string should have capacity for at least length + 1. // The terminating null byte ('\0') is not included in length. // Returns the truncated string length. Text_Encoding :: enum u8 #specified { ASCII :: 1; UTF8 :: 2; } // WIP TODO Ues compiler time code to see the auto bake being used... just for fun, once! :D truncate_string :: (str: string, length: s64, $encoding: Text_Encoding = .UTF8) -> length: s64 #no_abc { // TODO Should I use #no_abc ? assert(str.data != null); assert(str.count >= length); data := str.data; count := str.count; #if encoding == .UTF8 { // Find index of first continuation byte. idx := length; while (idx > 0 && ((data[idx - 1] & 0xC0) == 0x80)) { idx -= 1; } continuation_bytes := length - idx; // If string starts with continuation bytes, it's an invalid UTF8 string. if (idx == 0 && continuation_bytes > 0) { length = 0; } // If length truncates some continuation bytes, remove incomplete UTF8 character. else if (idx > 0 // string is not empty // continuation bytes are not complete && !(continuation_bytes == 0 && (data[idx - 1] & 0x80) == 0x00) && !(continuation_bytes == 1 && (data[idx - 1] & 0xE0) == 0xC0) && !(continuation_bytes == 2 && (data[idx - 1] & 0xF0) == 0xE0) && !(continuation_bytes == 3 && (data[idx - 1] & 0xF8) == 0xF0) ) { length -= (continuation_bytes + 1); // Remove '+ 1' start byte. } } memset(data + length, 0, count - length); return length; } // Returns true when the string is empty or consists of space characters. is_empty_string :: (str: string) -> bool { for 0..str.count-1 { if str[it] == { case #char "\0"; #through; case #char "\t"; #through; // horizontal tab case #char "\n"; #through; // line feed case #char "\x0B"; #through; // vertical tabulation case #char "\x0C"; #through; // form feed case #char "\r"; #through; // carriage return case #char " "; continue; case; return false; } } return true; } replace_char :: (str: string, find: u8, replace: u8) -> string { assert(false, "Use modules/String/module.jai:replace_chars"); // TODO Use modules/String/module.jai:replace_chars return ""; } // Prints, on row y and column x, the time using 5 characters centered on space. // Returns the result of a call to mvprintw. mvprintw_time :: (y: s32, x: s32, time: s64, space: s32) -> int { TIME_CHARS :: 5; assert(space >= TIME_CHARS); left_padding := (space - TIME_CHARS) / 2; right_padding := space - TIME_CHARS - left_padding; if (time < 0) { return mvprintw(y, x, "%*s - %*s", left_padding, "", right_padding, ""); } else if (time == 0) { return mvprintw(y, x, "%*s 0 %*s", left_padding, "", right_padding, ""); } else if (time < SECONDS_IN_MINUTE) { return mvprintw(y, x, "%*s%3jds %*s", left_padding, "", time, right_padding, ""); } else if (time < 100 * SECONDS_IN_HOUR) { hours := time / SECONDS_IN_HOUR; minutes := (time - (hours * SECONDS_IN_HOUR) ) / SECONDS_IN_MINUTE; return mvprintw(y, x, "%*s%02jd:%02jd%*s", left_padding, "", hours, minutes, right_padding, ""); } else if (time < xx (9999.5 * SECONDS_IN_DAY)) { value := cast(float64) time / SECONDS_IN_DAY; decimals := ifx time >= xx 99.95 * SECONDS_IN_DAY then 0 else ifx time >= xx 9.995 * SECONDS_IN_DAY then 1 else 2; return mvprintw(y, x, "%*s%4.*fd%*s", left_padding, "", decimals, value, right_padding, ""); } else if (time < xx (9999.5 * SECONDS_IN_YEAR)) { value := cast(float64) time / SECONDS_IN_YEAR; decimals := ifx time >= xx 99.95 * SECONDS_IN_YEAR then 0 else ifx time >= xx 9.995 * SECONDS_IN_YEAR then 1 else 2; return mvprintw(y, x, "%*s%4.*fy%*s", left_padding, "", decimals, value, right_padding, ""); } else { return mvprintw(y, x, "%*s ∞ %*s", left_padding, "", right_padding, ""); } } add_int64 :: (x :s64, y: s64) -> s64 { return ifx (y > 0 && x > S64_MAX - y) then S64_MAX else ifx (y < 0 && x < S64_MIN - y) then S64_MIN else x + y; } sub_int64 :: (x :s64, y :s64) -> s64 { return ifx (y < 0 && x > S64_MAX + y) then S64_MAX else ifx (y > 0 && x < S64_MIN + y) then S64_MIN else x - y; } // Returns active task or NULL if none applies. get_active_task :: inline (db: Database) -> *Task { task: *Task = null; if (db.active_idx >= 0) { task = *db.tasks[db.active_idx]; } return task; } // Returns selected task or NULL if none applies. get_selected_task :: inline (db: Database) -> *Task { task: *Task = null; if (db.selected_idx >= 0) { task = *db.tasks[db.selected_idx]; } return task; } // Adds a task to the database. // If necessary, expands database capacity. // Returns success. add_task :: (db: *Database, task: Task) -> success: bool { assert(db != null, "Parameter 'db' is null."); // idx := db.tasks.count; // maybe_grow(*db.tasks); // TODO Check for errors? // db.tasks.count += 1; // db.tasks[idx] = task; if (db.tasks.count == S64_MAX) { print_error("Database reached maximum capacity."); return false; } // If necessary, expand database capacity. current_capacity := db.capacity; if ((db.tasks.count + 1) > current_capacity) { new_capacity := ifx current_capacity == 0 then 8 else ifx current_capacity > (S64_MAX >> 1) then S64_MAX else current_capacity << 1; print("expanding from % to %\n", current_capacity, new_capacity); new_tasks := realloc(db.tasks.data, new_capacity * size_of(Task), current_capacity * size_of(Task)); if (new_tasks == null) { print_error("Failed to expand database."); return false; } db.tasks.data = new_tasks; db.capacity = new_capacity; } db.tasks.count += 1; db.tasks[db.tasks.count-1] = task; // Adjust selected task. if (db.selected_idx < 0) { db.selected_idx = db.tasks.count-1; } return true; } /* // Creates new task stored at location given by task pointer. // If necessary, expands database capacity. // Returns success. bool create_task(database_st *db, task_st **task) { assert(db != NULL); assert(task != NULL); if (db->count >= MAX_DATABASE_TASKS) { print_error("Database reached maximum capacity."); return false; } // If necessary, expand database capacity. size_t current_capacity = db->capacity; if ((db->count + 1) > current_capacity) { size_t new_capacity = current_capacity == 0 ? 2 : current_capacity > (MAX_DATABASE_TASKS >> 1) ? MAX_DATABASE_TASKS : current_capacity << 1; task_st *new_tasks = realloc(db->tasks, new_capacity * SIZEOF_TASK_ST); if (new_tasks == NULL) { print_error("Failed to expand database."); return false; } db->capacity = new_capacity; db->tasks = new_tasks; } // Prepare new task. *task = &db->tasks[db->count]; memset(*task, 0, SIZEOF_TASK_ST); db->count++; // Adjust selected task. if (db->selected_task < 0) { db->selected_task = db->count-1; } return true; } // Duplicates the given task. Duplicated task is appended to the database. // Returns success. bool duplicate_task(database_st *db, task_st *task) { assert(db != NULL); assert(task != NULL); // Create new task and keep task_idx (relative pointer) of original task). ptrdiff_t task_idx = task - db->tasks; task_st *new_task; if (create_task(db, &new_task) == false) { return false; } // If original task belonged to database, fix its pointer. if (0 <= task_idx && task_idx < db->count - 1) { // Compensate '- 1' for the new task. task = db->tasks + task_idx; } memcpy(new_task, task, SIZEOF_TASK_ST); // Add task time values to total times. for (int idx = 0; idx < NUM_WEEK_DAYS; idx++) { db->total_times[idx] = add_int64(db->total_times[idx], new_task->times[idx]); } return true; } // Deletes task from database. // If possible, shrinks the database capacity. // Returns success. bool delete_task(database_st *db, task_st *task) { assert(db != NULL); assert(task != NULL); assert(task >= db->tasks && task - db->tasks < db->count); // Remove task timer values from total timers. for (int idx = 0; idx < NUM_WEEK_DAYS; idx++) { db->total_times[idx] = sub_int64(db->total_times[idx], task->times[idx]); } // Move tasks after the index position to their new positions. ptrdiff_t index = task - db->tasks; memmove(task, task + 1, (db->count - index - 1) * SIZEOF_TASK_ST); db->count--; // Adjust selected task. if (db->selected_task >= db->count) { db->selected_task--; } // Adjust active task. if (db->active_task > index) { db->active_task--; } else if (db->active_task == index) { db->active_task = -1; } // If possible, shrink database capacity. size_t current_capacity = db->capacity; if (db->count <= (current_capacity >> 2)) { size_t new_capacity = current_capacity >> 1; task_st *new_tasks = realloc(db->tasks, new_capacity * SIZEOF_TASK_ST); if (new_tasks == NULL && new_capacity > 0) { print_error("Failed to shrink database."); return false; } db->capacity = new_capacity; db->tasks = new_tasks; } return true; } // Moves task to index. // Index gets clamped to [0, db->count[. void move_task_to_index(database_st *db, task_st *task, ptrdiff_t index) { assert(db != NULL); assert(task != NULL); assert(task >= db->tasks && task - db->tasks < db->count); ptrdiff_t target_index = index < 0 ? 0 : index >= db->count ? db->count - 1 : index; task_st *target_task = db->tasks + target_index; if (target_task == task) { return; } // Move task to new location. task_st temp_task; memcpy(&temp_task, task, SIZEOF_TASK_ST); if (target_task > task) { memmove(task, task + 1, (target_task - task) * SIZEOF_TASK_ST); } else { memmove(target_task + 1, target_task, (task - target_task) * SIZEOF_TASK_ST); } memcpy(target_task, &temp_task, SIZEOF_TASK_ST); // Adjust active and selected tasks. ptrdiff_t source_index = task - db->tasks; if (db->active_task == source_index) { db->active_task = target_index; } else if (source_index < db->active_task && db->active_task <= target_index) { db->active_task--; } else if (target_index <= db->active_task && db->active_task < source_index) { db->active_task++; } db->selected_task = target_index; } */ // Updates the times on the active task (and adjusts database totals). update_times :: (db: *Database) { assert(db != null); return; /* // Get current UTC time. time_t stop_time = time(NULL); // Get last modified on UTC time. time_t start_time = db->modified_on; // Keep track of this update. db->modified_on = stop_time; if (db->active_task < 0) { return; } task_st *active_task = db->tasks + db->active_task; uint8_t start_week_day; while (start_time < stop_time) { start_week_day = localtime(&start_time)->tm_wday; // Get next day in local time. struct tm *start_of_day_tm = localtime(&start_time); start_of_day_tm->tm_sec = 0; start_of_day_tm->tm_min = 0; start_of_day_tm->tm_hour = 0; time_t start_of_day = mktime(start_of_day_tm); time_t next_day = start_of_day + SECONDS_IN_DAY; time_t next_start = next_day < stop_time ? next_day : stop_time; time_t elapsed_time = next_start - start_time; active_task->times[start_week_day] += elapsed_time; db->total_times[start_week_day] += elapsed_time; start_time = next_start; } */ } /* // Recalculates database totals. void update_total_times(database_st *db) { assert(db != NULL); int64_t *totals = db->total_times; memset(totals, 0, NUM_WEEK_DAYS * SIZEOF_INT64); for (size_t idx = 0; idx < db->count; idx++) { int64_t *times = db->tasks[idx].times; totals[0] = add_int64(totals[0], times[0]); totals[1] = add_int64(totals[1], times[1]); totals[2] = add_int64(totals[2], times[2]); totals[3] = add_int64(totals[3], times[3]); totals[4] = add_int64(totals[4], times[4]); totals[5] = add_int64(totals[5], times[5]); totals[6] = add_int64(totals[6], times[6]); } } // Resets the times of the provided task (and adjusts database totals). void reset_task_times(database_st *db, task_st *task) { assert(db != NULL); assert(task != NULL); assert(task >= db->tasks && task - db->tasks < db->count); // Make sure we sync before applying the changes. update_times(db); for (int idx = 0; idx < NUM_WEEK_DAYS; idx++) { int64_t *timer = &task->times[idx]; int64_t *total = &db->total_times[idx]; *total = sub_int64(*total, *timer); *timer = 0; } } // Sets the time on the day and task provided (and adjusts database totals). void set_task_time(database_st *db, task_st *task, int day, int64_t time) { assert(db != NULL); assert(task != NULL); assert(task >= db->tasks && task - db->tasks < db->count); // Make sure we sync before applying the changes. update_times(db); int64_t *timer = &task->times[day]; int64_t *total = &db->total_times[day]; *total = sub_int64(*total, *timer); *timer = time; *total = add_int64(*total, *timer); } // Adds the time on the day and task provided (and adjusts database totals). void add_task_time(database_st *db, task_st *task, int day, int64_t time) { assert(db != NULL); assert(task != NULL); assert(task >= db->tasks && task - db->tasks < db->count); // Make sure we sync before applying the changes. update_times(db); task->times[day] = add_int64(task->times[day], time); db->total_times[day] = add_int64(db->total_times[day], time); } */ // Resets database to the initial state and deallocates all memory taken by tasks. reset_database :: (db: *Database) { assert(db != null); free(db.tasks.data); < success: bool { assert(xx path, "Parameter 'path' is empty."); // Open file. file, open_success := file_open(path, for_writing = true); // log_errors: bool = true if open_success == false { print_error("Failed to open file '%' while storing database: ERROR_FROM_LOG", path); // TODO Get error from logger ?! return false; } defer file_close(*file); file_write(*file, DB_FILE_SIGN_STR); file_write(*file, *db, size_of(Database)); file_write(*file, db.tasks.data, size_of(Task) * db.tasks.count); return true; } // Loads data from binary file into database. // Returns success. load_database :: (db: *Database, path: string) -> success: bool { assert(db != null, "Parameter 'db' is null."); assert(xx path, "Parameter 'path' is empty."); // Open file. file, open_success := file_open(path); // log_errors: bool = true if open_success == false { print_error("Failed to open file '%' while loading database: ERROR_FROM_LOG", path); // TODO Get error from logger ?! return false; } defer file_close(*file); // Validate file signature. file_signature: [DB_FILE_SIGN_STR.count] u8; read_success := file_read(file, *file_signature, DB_FILE_SIGN_STR.count); if read_success == false print_error("Failed to read file signature from '%'.", path); if cast(string)file_signature != DB_FILE_SIGN_STR { print_error("Invalid file signature."); return false; } // Read database structure. read_success = file_read(file, db, size_of(Database)); // TODO Use print_error or assert? if read_success == false { print_error("Failed to read database info from '%'.", path); return false; } assert(read_success == true, "Failed to read database info from '%'.", path); // Reserve database capacity for tasks. db.tasks.data = alloc(db.tasks.count * size_of(Task)); db.capacity = db.tasks.count; // Read database tasks. file_read(file, db.tasks.data, size_of(Task) * db.tasks.count); // Make sure we are reading all the file. buffer: u8; success, bytes := file_read(file, *buffer, 1); assert(bytes == 0, "Unexpected content found at the end of file '%'.", path); return true; } // Exports data into CSV file. // Returns success. export_to_csv :: (db: Database, path: string) -> success: bool { assert(xx path, "Parameter 'path' is empty."); // TODO Make sure (IN ALL PROCEDURES) we're not receiving an empty path. builder: String_Builder; defer reset(*builder); CSV_HEADER :: string.[ "task", "sunday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday" ]; print_to_builder(*builder, "%\n", join(..CSV_HEADER, separator = ",")); buffer: [Task.name.count] u8; name: string = xx buffer; for db.tasks { name.count = c_style_strlen(it.name.data); memcpy(name.data, it.name.data, name.count); replace_chars(name, ",", #char " "); print_to_builder(*builder, "%,%,%,%,%,%,%,%\n", name, it.times[0], it.times[1], it.times[2], it.times[3], it.times[4], it.times[5], it.times[6]); } write_entire_file(path, *builder); return true; } // Imports CSV file into database. // Returns success. import_from_csv :: (db: *Database, path: string) -> bool { // TODO WIP assert(db != null, "Parameter 'db' is null."); assert(xx path, "Parameter 'path' is empty."); error_code: s64; // Check file size file_info: stat_t; error_code = sys_stat(path, *file_info); // TODO Check for error. size := file_info.st_size; success: bool; map: Map_File_Info; data: string; is_using_map := false; if size >= 1<<30 { print("big file with % MB\n", size>>20); // TODO map, success = map_entire_file_start(path); data = map.data; is_using_map = true; } else { print("small file with % B\n", size); // TODO data, success = read_entire_file(path); } defer if is_using_map then map_entire_file_end(*map); else free(data.data); csv := data; if success == false { print_error("Failed to read file '%' while loading database: ERROR_FROM_LOG", path); // TODO Get error from logger ?! return false; } // TODO Helper function. consume_next_line :: (sp: *string) -> string, bool { // To find the end of the line, we look for a linefeed character. // We will trim a carriage return off the end if there is one there also. // Thus this works on both 'dos' and 'unix'-style files. s := << sp; found, result, right := split_from_left(s, 10); if !found { // This is the last line; there may not have been a linefeed after that, // but we still want to handle that data, so we return true if there was // a nonzero amount of stuff there. << sp = ""; return s, (s.count > 0); } // Chop the characters we are going to return from 'sp', // which holds the remaining file data. advance(sp, result.count + 1); if result { if result[result.count-1] == 13 result.count -= 1; // If there's a carriage return at the end, remove it by decrementing the string's length. } return result, true; } //Skip header line. consume_next_line(*csv); // TODO Helper function. advance :: inline (array: *[] $T, amount: int = 1) { assert(amount >= 0); assert(array.count >= amount); array.count -= amount; array.data += amount; } next_line :: inline (csv: *string) -> line: string, success: bool { for 0..csv.count { if csv.data[it] == #char "\n" { line: string = < (100<<20) { // print("temp: %\n", context.temporary_storage.total_bytes_occupied >> 20); // reset_temporary_storage(); // } } print("temp: %\n", context.temporary_storage.total_bytes_occupied >> 20); reset_temporary_storage(); return true; } /* // Appends task to the end of the CSV file. // Returns success. bool append_to_csv(task_st *task, const char *path) { assert(task != NULL); assert(path != NULL); FILE *file = fopen(path, "a+"); if (file == NULL) { print_error("Failed to open file '%s' while appending to CSV: %s.", path, strerror(errno)); return false; } char last_char; fseek(file, -1, SEEK_END); fread(&last_char, SIZEOF_CHAR, 1, file); if (last_char != '\n') { fprintf(file, "\n"); } char name[TASK_NAME_BYTES]; memcpy(name, task->name, TASK_NAME_BYTES); replace_char(name, ',', ' '); fprintf(file, "%s,%" PRId64 ",%" PRId64 ",%" PRId64 ",%" PRId64 ",%" PRId64 ",%" PRId64 ",%" PRId64 "\n", name, task->times[0], task->times[1], task->times[2], task->times[3], task->times[4], task->times[5], task->times[6] ); fclose(file); return true; } */ // Selects task by index. // Index gets clamped to [0, db->count[. select_task_by_index :: (db: *Database, index: s64) { assert(db != null); db.selected_idx = ifx db.tasks.count == 0 then -1 else ifx index < 0 then 0 else ifx index >= db.tasks.count then db.tasks.count - 1 else index; } // Selects task by delta relative to currently selected task. select_task_by_delta :: (db: *Database, delta: s64) { assert(db != null); // TODO I bet there's a better way to do this... maybe use a clamp or range or something. idx := ifx (delta > 0 && db.selected_idx > S64_MAX - delta) then S64_MAX else ifx (delta < 0 && db.selected_idx < S64_MIN - delta) then S64_MIN else db.selected_idx + delta; select_task_by_index(db, idx); } /* // Selects task. select_task :: (db: *Database, task: *Task) { assert(db != null); assert(task != null); assert(task >= db.tasks.data && task - db.tasks.data < db.tasks.count); db.selected_idx = task - db.tasks.data; } */ // Set active task. // Passing task as NULL de-activates any previously active task. set_active_task :: (db: *Database, task: *Task) { assert(db != null); assert(task == null || (task >= db.tasks.data && task <= *db.tasks[db.tasks.count-1])); // TODO Improve this check. update_times(db); db.active_idx = ifx (task == null) then -1 else task - db.tasks.data; } /* // Returns true when database is full. bool is_database_full(database_st *db) { assert(db != NULL); return db->count >= MAX_DATABASE_TASKS; } */ INPUT_TIMEOUT_MS :: 1000; INPUT_AWAIT_INF :: -1; NUM_HEADER_ROWS :: 1; NUM_FOOTER_ROWS :: 1; NUM_COLUMNS :: 9; L_TITLE_IDX :: 0; L_DAYS_IDX :: 1; L_TOTAL_IDX :: 8; Column :: struct { header : string; width : int; alignment_offset : int; alignment : u8; } Layout :: struct { columns : [NUM_COLUMNS] Column; archive_title : string; } layouts : [#run type_info(Layouts).values.count] Layout; layout_tasks_rows : int; is_terminal_too_small := true; initialize_tui :: () { // Normal layout. layouts[Layouts.NORMAL] = .{ archive_title = " Archive ", columns = .[ .{ header = #run join(" Task Time Tracker v", VERSION, " "), width = -1, alignment = #char "L" }, .{ header = " Sun ", width = 7, alignment = #char "C" }, .{ header = " Mon ", width = 7, alignment = #char "C" }, .{ header = " Tue ", width = 7, alignment = #char "C" }, .{ header = " Wed ", width = 7, alignment = #char "C" }, .{ header = " Thu ", width = 7, alignment = #char "C" }, .{ header = " Fri ", width = 7, alignment = #char "C" }, .{ header = " Sat ", width = 7, alignment = #char "C" }, .{ header = " Total ", width = 9, alignment = #char "C" }, ] }; // Compact layout. layouts[Layouts.COMPACT] = .{ archive_title = " Archive ", columns = .[ .{ header = #run join(" TTT ", VERSION, " "), width = -1, alignment = #char "L" }, .{ header = " S ", width = 5, alignment = #char "C" }, .{ header = " M ", width = 5, alignment = #char "C" }, .{ header = " T ", width = 5, alignment = #char "C" }, .{ header = " W ", width = 5, alignment = #char "C" }, .{ header = " T ", width = 5, alignment = #char "C" }, .{ header = " F ", width = 5, alignment = #char "C" }, .{ header = " S ", width = 5, alignment = #char "C" }, .{ header = " # ", width = 5, alignment = #char "C" }, ] }; // Calculate alignment_offsets. for * layout: layouts { for * col: layout.columns { offset: int; if col.alignment == { case #char "L"; offset = 0; case #char "C"; offset = ((col.width - col.header.count) / 2); case #char "R"; offset = (col.width - col.header.count); } col.alignment_offset = offset; } } // TODO //setlocale(LC_ALL, "C.UTF-8"); // Sets locale for C library functions; Allows usage of UTF-8. stdscr = initscr(); // Start curses mode. cbreak(); // Line buffering disabled; pass on everty thing to me. keypad(stdscr, true); // I need those nifty F1..F12. curs_set(0); // Set cursor invisible. noecho(); // Disable echoing input characters. // Initialize pairs of colors. start_color(); use_default_colors(); // Using default (-1) instead of COLOR_BLACK. init_pair(xx Styles.SELECTED, COLOR_BLACK, COLOR_CYAN); init_pair(xx Styles.SELECTED_INVERTED, COLOR_CYAN, -1); init_pair(xx Styles.ACTIVE, COLOR_BLUE, -1); init_pair(xx Styles.ACTIVE_SELECTED, COLOR_WHITE, COLOR_BLUE); init_pair(xx Styles.ERROR, COLOR_RED, -1); } update_layout :: () { // Calculate number of available rows to display tasks. layout_tasks_rows = (size_y - NUM_HEADER_ROWS - NUM_FOOTER_ROWS); // Calculate first column width: expands to fill the remaining space dynamically. for * layout: layouts { layout.columns[0].width = size_x - (NUM_COLUMNS - 1) - 2; for 1..layout.columns.count-1 { layout.columns[0].width -= layout.columns[it].width; } } } draw_tui :: (db: *Database, layout: *Layout) { adjust_first_day_of_week := int.[ (0 + FIRST_DAY_OF_WEEK) % NUM_WEEK_DAYS, (1 + FIRST_DAY_OF_WEEK) % NUM_WEEK_DAYS, (2 + FIRST_DAY_OF_WEEK) % NUM_WEEK_DAYS, (3 + FIRST_DAY_OF_WEEK) % NUM_WEEK_DAYS, (4 + FIRST_DAY_OF_WEEK) % NUM_WEEK_DAYS, (5 + FIRST_DAY_OF_WEEK) % NUM_WEEK_DAYS, (6 + FIRST_DAY_OF_WEEK) % NUM_WEEK_DAYS, ]; x: int; y: int; col: *Column; // Get context information. active_task := get_active_task(db); selected_task := get_selected_task(db); now_utc := current_time_consensus(); now_week_day := to_calendar(now_utc).day_of_week_starting_at_0; // Reset theme and clear screen. attrset(A_NORMAL); erase(); // Draw outer border. box(stdscr, 0, 0); // Draw table grids. // TODO Maybe this could be simplified? y = 0; x = 0; for 0..layout.columns.count-2 { column := layout.columns[it]; x += 1 + column.width; mvaddch(xx y, xx x, ACS_TTEE); for row: 1..size_y-1 { mvaddch(xx row, xx x, ACS_VLINE); } mvaddch(size_y-1, xx x, ACS_BTEE); } /////////////////////////////////////////////////////////////////////////// // Draw headers. y = 0; x = 0; // Headers : title x += 1; col = *layout.columns[L_TITLE_IDX]; mvaddstr(xx y, xx (x + col.alignment_offset), ifx db == *archive then layout.archive_title.data else col.header.data); x += col.width; // Headers : days for 0..NUM_WEEK_DAYS-1 { //for (int raw_idx = 0; raw_idx < NUM_WEEK_DAYS; raw_idx++) { idx := adjust_first_day_of_week[it]; x += 1; // Apply theme. if (idx == now_week_day && active_task != null) { attron(COLOR_PAIR(xx Styles.ACTIVE) | A_BOLD); } else if (idx == now_week_day) { attron(COLOR_PAIR(xx Styles.SELECTED_INVERTED) | A_BOLD); } col = *layout.columns[L_DAYS_IDX + idx]; mvaddstr(xx y, xx (x + col.alignment_offset), col.header.data); x += col.width; // Reset theme. attrset(A_NORMAL); } // Headers : total x += 1; col = *layout.columns[L_TOTAL_IDX]; mvaddstr(xx y, xx (x + col.alignment_offset), col.header.data); /////////////////////////////////////////////////////////////////////////// // Draw tasks. total_time := 0; column_width: int; y = 0; // Pagination based on currently selected task (show page where selected task is). idx_start := (db.selected_idx / layout_tasks_rows) * layout_tasks_rows; // Display up to rows allowed by the layout, or less if reached end of database. idx_stop := idx_start + (ifx layout_tasks_rows > db.tasks.count - idx_start then db.tasks.count - idx_start else layout_tasks_rows); for task_idx: idx_start..idx_stop-1 { //for (size_t idx = idx_start; idx < idx_stop; idx++) { task := *db.tasks[task_idx]; y += 1; x = 0; // Apply theme. if (task == active_task && task == selected_task) { attron(COLOR_PAIR(xx Styles.ACTIVE_SELECTED) | A_BOLD); } else if (task == selected_task) { attron(COLOR_PAIR(xx Styles.SELECTED)); } else if (task == active_task) { attron(COLOR_PAIR(xx Styles.ACTIVE) | A_BOLD); } // Task title. x += 1; column_width = layout.columns[L_TITLE_IDX].width; mvprintw(xx y, xx x, "%-*.*s", column_width, column_width, task.name); x += column_width; // Task times. total_time = 0; for 0..NUM_WEEK_DAYS-1 { x += 1; day_idx := (it + FIRST_DAY_OF_WEEK) % NUM_WEEK_DAYS; column_width = layout.columns[L_DAYS_IDX + day_idx].width; task_time := task.times[day_idx]; total_time = add_int64(total_time, task_time); mvprintw_time(xx y, xx x, task_time, xx column_width); x += column_width; } // Task total. x += 1; mvprintw_time(xx y, xx x, total_time, xx layout.columns[L_TOTAL_IDX].width); // Reset theme. attrset(A_NORMAL); } return; /* /////////////////////////////////////////////////////////////////////////// // Draw selected/total tasks. int size = snprintf(NULL, 0, " %td/%zd ", db->selected_task + 1, db->count); if (size <= layout->columns[L_TITLE_IDX].width) { mvprintw(size_y - 1, 1, " %td/%zd ", db->selected_task + 1, db->count); } else { mvprintw(size_y - 1, 1, "%td", db->selected_task + 1); } /////////////////////////////////////////////////////////////////////////// // Draw daily totals. y = size_y - 1; x = 0 + 1 + layout->columns[L_TITLE_IDX].width; total_time = 0; for (int raw_idx = 0; raw_idx < NUM_WEEK_DAYS; raw_idx++) { int idx = adjust_first_day_of_week[raw_idx]; int64_t daily_total = db->total_times[idx]; x++; // Apply theme. if (idx == now_week_day && active_task != NULL) { attron(COLOR_PAIR(STYLE_ACTIVE) | A_BOLD); } else if (idx == now_week_day) { attron(COLOR_PAIR(SELECTED_INVERTED) | A_BOLD); } column_width = layout->columns[L_DAYS_IDX + idx].width; total_time = add_int64(total_time, daily_total); mvprintw_time(y, x, daily_total, column_width); x += column_width; // Reset theme. attrset(A_NORMAL); } x++; mvprintw_time(y, x, total_time, layout->columns[L_TOTAL_IDX].width); */ } /* void *mem_alloc(size_t mem_size, const char *error_tag) { void *mem_pointer = malloc(mem_size); if (mem_pointer == NULL && mem_size > 0) { print_error("Failed to allocate memory (%s): %s.", (error_tag == NULL ? "undefined" : error_tag), strerror(errno)); exit(EXIT_FAILURE); } return mem_pointer; } */ free_memory :: () { reset_database(*database); reset_database(*archive); //free(string_buffer); string_buffer = NULL; free(app_directory); free(db_file_path); free(ar_file_path); //reset_temporary_storage(); } /* void exit_gracefully(int signal) { flushinp(); ungetch('q'); } void read_input_to_string_buffer_with_space(int row, int column, int style, int length, int space) { assert(length < string_buffer_size); assert(space < string_buffer_size); attron(style | A_UNDERLINE); mvprintw(row, column, "%*s", space, ""); echo(); curs_set(1); memset(string_buffer, 0, string_buffer_size); mvgetnstr(row, column, string_buffer, length); truncate_string_utf8(string_buffer, length); noecho(); curs_set(0); attrset(A_NORMAL); } void read_input_to_string_buffer(int row, int column, int style, int length) { read_input_to_string_buffer_with_space(row, column, style, length, length); } // Returns success. bool read_input_to_int(int row, int style, const char *message, intmax_t *result) { assert(message != NULL); assert(result != NULL); attron(style); move(row, 1); addch(ACS_CKBOARD); addstr(message); attrset(A_NORMAL); // Get line number. int input_pos_x = getcurx(stdscr); int input_width = size_x - input_pos_x - 1; read_input_to_string_buffer(row, input_pos_x, style, input_width); char *parser; errno = 0; *result = strtoimax(string_buffer, &parser, 10); bool success = (errno == 0 || errno == ERANGE) // No error OR value was clamped to limits (acceptable). && parser != string_buffer; // If no digits are found, parser will return the address of the input string. return success; } // Retuns true if user presses enter, false otherwise. bool read_enter_confirmation(int row, int style, const char *message) { assert(message != NULL); attron(style); move(row, 1); for (int idx = 0; idx < size_x - 2; idx++) { addch(ACS_CKBOARD); } mvaddstr(row, 2, message); attrset(A_NORMAL); return getch() == '\n'; } */ main :: () { defer report_memory_leaks(); // TODO DEBUG defer free_memory(); { // Initialize app directory. home_dir, success_dir := get_home_directory(); // Returns system owned memory. if success_dir == false { home_dir = "."; } home_path, success_path := get_absolute_path(home_dir); // Returns temporary memory. TODO LEAK if success_path == false { print_error("Failed to find home directory '%'.", home_dir); exit(1); } app_directory = join(home_path, "/", APP_FOLDER_NAME); db_file_path = join(app_directory, "/", DB_FILE_NAME); ar_file_path = join(app_directory, "/", AR_FILE_NAME); make_directory_if_it_does_not_exist(app_directory, recursive = true); } { // Initialize database and archive files if needed. if (file_exists(db_file_path) == false) { if (store_database(database, db_file_path) == false) { print_error("Failed to initialize database."); exit(1); } } if (file_exists(ar_file_path) == false) { if (export_to_csv(archive, ar_file_path) == false) { print_error("Failed to initialize archive."); exit(1); } } } args := get_command_line_arguments(); defer array_reset(*args); if args.count > 1 { is_exit_requested := false; for 1..args.count-1 { if is_equal_to_any(args[it], "--help", "-h") { write_strings( "Usage: ttt [OPTION]... [FILE]...\n", " -i, --import-csv [FILE] Import CSV file to database (discard first row).\n", " -e, --export-csv [FILE] Export database to CSV file.\n", " -n, --no-autosave Disable autosave feature (only save on exit).\n", " -h, --help Display this help and exit.\n", " -v, --version Output version information and exit.\n", "\n", "In app commands\n", " a, A Archive selected task (except if active).\n", " r, R Restore selected task from archive.\n", " t, T Select currently active task (if any).\n", " d, D Duplicate selected task.\n", " n, N Create new task.\n", " m, M Move selected task to position.\n", " g, G Select task by position.\n", " q, Q Save changes and exit.\n", " F2 Rename selected task.\n", " F5 Recalculate total times.\n", " TAB Toggle archive view.\n", " BACKSPACE Reset times for selected task.\n", " DELETE Delete selected task (except if active).\n", " SPACE, ENTER Toggle selected task as active/inactive.\n", " 1, 2, 3, 4, 5, 6, 7 Edit selected task time for the Nth day of week:\n", " =# sets # seconds;\n", " -# subtracts # seconds;\n", " # adds # seconds;\n", " #m specifies # as minutes;\n", " #h specifies # as hours;\n", " #d specifies # as days;\n", " #y specifies # as years.\n", " UP Select task above.\n", " DOWN Select task below.\n", " PAGE-UP Select task 1 page above.\n", " PAGE-DOWN Select task 1 page below.\n", " HOME Select first/top task.\n", " END Select last/bottom task.\n", "\n", "Notes\n"); print("- All data files are stored in '%'.\n", app_directory); print(" If the home directory is undefined, './%' will be used.\n", APP_FOLDER_NAME); write_strings( " The database entries are stored in binary format on the 'database.bin' file.\n", " The archived entries are stored in CSV format on the 'archive.csv' file.\n", "- During intensive tasks such as saving to file or recalculating totals times,\n", " a diamond symbol is shown on the top left corner.\n" ); exit(0); } if is_equal_to_any(args[it], "--version", "-v") { print("Task Time Tracker version % \nCopyright % Daniel Martins\nLicense GPL-3.0-or-later\n", VERSION, YEAR); exit(0); } if is_equal_to_any(args[it], "--import-csv", "-i") { it += 1; if it >= args.count { print_error("Missing CSV file path to import."); exit(1); } if (load_database(*database, db_file_path) == false) { print_error("Failed to load database."); exit(1); } if (import_from_csv(*database, args[it]) == false) { print_error("Failed to import CSV file."); exit(1); } if (store_database(*database, db_file_path) == false) { print_error("Failed to store database."); exit(1); } reset_database(*database); is_exit_requested = true; continue; } if is_equal_to_any(args[it], "--export-csv", "-e") { it += 1; if it >= args.count { print_error("Missing CSV file path to export."); exit(1); } if (load_database(*database, db_file_path) == false) { print_error("Failed to load database."); exit(1); } if (export_to_csv(*database, args[it]) == false) { print_error("Failed to export CSV file."); exit(1); } reset_database(*database); is_exit_requested = true; continue; } if is_equal_to_any(args[it], "--no-autosave", "-n") { is_autosave_enabled = false; continue; } print_error("%: invalid option '%'.\nTry '% --help' for more information.", args[0], args[it], args[0]); exit(1); } if is_exit_requested { exit(0); } } if (load_database(*database, db_file_path) == false) { print_error("Failed to load database."); exit(1); } initialize_tui(); // TODO Remove this?! //signal(SIGTERM, exit_gracefully); //signal(SIGINT, exit_gracefully); //signal(SIGQUIT, exit_gracefully); //signal(SIGHUP, exit_gracefully); layout := *layouts[Layouts.COMPACT]; db := *database; flushinp(); ungetch(KEY_RESIZE); while (true) { key := getch(); if key == #char "q" || key == #char "Q" break; active_task := get_active_task(db); selected_task := get_selected_task(db); action_style := A_BOLD | COLOR_PAIR(xx ifx selected_task == active_task && selected_task != null then Styles.ACTIVE else Styles.SELECTED_INVERTED); error_style := A_BOLD | COLOR_PAIR(xx Styles.ERROR); selected_task_row : int = ifx is_terminal_too_small then 0 else ifx (db.selected_idx < 0) then 1 else (db.selected_idx % layout_tasks_rows) + NUM_HEADER_ROWS; timeout(INPUT_AWAIT_INF); update_times(*database); if key == { // When getch() times out. case ERR; if (is_autosave_enabled && countdown_to_autosave > 0) { countdown_to_autosave -= INPUT_TIMEOUT_MS; if (countdown_to_autosave <= 0) { show_processing(); if (db == *archive) { export_to_csv(*archive, ar_file_path); } store_database(database, db_file_path); } } // When terminal is resized. case KEY_RESIZE; clear(); getmaxyx(stdscr, *size_y, *size_x); is_terminal_too_small = size_x < 60 || size_y < 3; new_size := 2047 | TASK_NAME_BYTES | (size_x + 1); //if (string_buffer_size < new_size) { //string_buffer_size = new_size; //string_buffer = realloc(string_buffer, string_buffer_size); //if (string_buffer == NULL && string_buffer_size > 0) { //print_error("Failed to allocate memory for string buffer: %s.", strerror(errno)); //flushinp(); //ungetch(#char "q"); //break; //} //} update_layout(); layout = *layouts[ifx size_x > 100 then Layouts.NORMAL else Layouts.COMPACT]; /* case 'n': case 'N':{ if (is_database_full(db)) { read_enter_confirmation(selected_task_row, error_style, " Unable to create entry: database is full. "); break; } // Create new task. task_st *new_task; if (create_task(db, &new_task) == false) { break; } // Set new task name. time_t now_utc = time(NULL); struct tm *now_local = localtime(&now_utc); strftime(new_task->name, TASK_NAME_BYTES, "%Y-%m-%d %H:%M:%S", now_local); // Select new task. select_task(db, new_task); selected_task = get_selected_task(db); trigger_autosave(); // Force rename action. flushinp(); ungetch(KEY_F(2)); break; } case KEY_F(2): { if (selected_task == NULL) { break; } read_input_to_string_buffer_with_space(selected_task_row, 1, action_style, TASK_NAME_LENGTH, size_x - 2); if (is_empty_string(string_buffer) == false) { replace_char(string_buffer, '\t', ' '); replace_char(string_buffer, '\v', ' '); replace_char(string_buffer, '\f', ' '); replace_char(string_buffer, '\r', ' '); memcpy(selected_task->name, string_buffer, TASK_NAME_BYTES); trigger_autosave(); } break; } case KEY_BACKSPACE: { if (selected_task == NULL) { break; } if (read_enter_confirmation(selected_task_row, action_style, " Press enter to reset task. ") == true) { reset_task_times(db, selected_task); trigger_autosave(); } break; } case KEY_DC: { // Delete if (selected_task == NULL || selected_task == active_task) { break; } if (read_enter_confirmation(selected_task_row, action_style, " Press enter to delete task. ") == true) { delete_task(db, selected_task); trigger_autosave(); } break; } case '1': case '2': case '3': case '4': case '5': case '6': case '7': { if (selected_task == NULL) { break; } // Prepare position to input time operation. int selected_day = key - '1'; int input_width = layout->columns[L_DAYS_IDX + selected_day].width; int input_pos_x = 1 + layout->columns[L_TITLE_IDX].width; for (int col = 0; col < selected_day; col++) { input_pos_x += 1 + layout->columns[L_DAYS_IDX + col].width; } input_pos_x++; // Get input string. read_input_to_string_buffer(selected_task_row, input_pos_x, action_style, input_width); char *input = string_buffer; // Abort if input if empty. if (is_empty_string(input) == true) { break; } // Search for assign '=' operator and discard everything before (if found). char *assign_str = strchr(input, '='); bool is_assign = assign_str != NULL; if (is_assign == true) { input = assign_str + 1; } // Try to parse a number and abort if it fails. char *parser; long double input_float = strtold(input, &parser); if (parser == input) { break; } input = parser; // Try to parse a character representing the time multiplier. long double multiplier = 1.0; for (int i=0; i < strlen(input); i++) { char ch = input[i]; if (ch == 'm' || ch == 'M') { multiplier = SECONDS_IN_MINUTE; break; } else if (ch == 'h' || ch == 'H') { multiplier = SECONDS_IN_HOUR; break; } else if (ch == 'd' || ch == 'D') { multiplier = SECONDS_IN_DAY; break; } else if (ch == 'y' || ch == 'Y') { multiplier = SECONDS_IN_YEAR; break; } } // Process input and check if it's valid. long double input_time = input_float * multiplier; bool is_result_valid = (input_time >= (long double)INT64_MIN && input_time <= (long double)INT64_MAX); if (is_result_valid == false) { break; } // Apply changes. int64_t time = input_time; int day = (selected_day + FIRST_DAY_OF_WEEK) % NUM_WEEK_DAYS; if (is_assign == true) { set_task_time(db, selected_task, day, time); } else { add_task_time(db, selected_task, day, time); } trigger_autosave(); break; } case 'm': case 'M': { if (selected_task == NULL) { break; } intmax_t value; if (read_input_to_int(selected_task_row, action_style, " Move to: ", &value) == false) { break; } ptrdiff_t target_index = (value < 1 ? 1 : value > MAX_DATABASE_TASKS ? MAX_DATABASE_TASKS : value) - 1; move_task_to_index(db, selected_task, target_index); trigger_autosave(); break; } case 'g': case 'G': { if (selected_task == NULL) { break; } intmax_t value; if (read_input_to_int(selected_task_row, action_style, " Go to: ", &value) == false) { break; } ptrdiff_t target_index = (value < 1 ? 1 : value > MAX_DATABASE_TASKS ? MAX_DATABASE_TASKS : value) - 1; select_task_by_index(db, target_index); break; } case 'd': case 'D':{ if (selected_task == NULL) { break; } if (is_database_full(db)) { read_enter_confirmation(selected_task_row, error_style, " Unable to duplicate entry: database is full. "); break; } if (duplicate_task(db, selected_task) == false) { break; } trigger_autosave(); break; } case KEY_F(5): { update_total_times(db); trigger_autosave(); break; } case 't': case 'T': { if (active_task == NULL) { break; } select_task(db, active_task); break; } */ case #char "\n"; #through; case #char " "; if (db != *database || selected_task == null) break; set_active_task(db, ifx (active_task == selected_task) then null else selected_task); active_task = get_active_task(db); trigger_autosave(); /* case '\t': { if (db == &database) { if (import_from_csv(&archive, ar_file_path) == false) { reset_database(&archive); print_error("Failed to load archive."); break; } db = &archive; } else { if (export_to_csv(&archive, ar_file_path) == false) { print_error("Failed to store archive."); break; } reset_database(&archive); db = &database; } break; } case 'a': case 'A': { if (db != &database || selected_task == NULL || selected_task == active_task) { break; } if (append_to_csv(selected_task, ar_file_path) == false) { print_error("Failed to archive entry."); break; } delete_task(&database, selected_task); trigger_autosave(); break; } case 'r': case 'R': { if (db != &archive || selected_task == NULL) { break; } if (is_database_full(&database)) { read_enter_confirmation(selected_task_row, error_style, " Unable to restore entry: database is full. "); break; } if (duplicate_task(&database, selected_task) == false) { print_error("Failed to restore entry."); break; } delete_task(&archive, selected_task); trigger_autosave(); break; } */ case KEY_HOME; select_task_by_index(db, 0); case KEY_UP; select_task_by_delta(db, -1); case KEY_PPAGE; select_task_by_delta(db, -layout_tasks_rows); case KEY_END; select_task_by_index(db, db.tasks.count-1); case KEY_DOWN; select_task_by_delta(db, 1); case KEY_NPAGE; select_task_by_delta(db, layout_tasks_rows); } if (is_terminal_too_small) { INVALID_WINDOW_MESSAGE :: "Terminal is too small: minimum 60x3."; mvaddstr(size_y / 2, (size_x - xx INVALID_WINDOW_MESSAGE.count) / 2, INVALID_WINDOW_MESSAGE); } else { draw_tui(db, layout); draw_error_window(); } timeout(INPUT_TIMEOUT_MS); } // Save any unsaved changes. // show_processing(); error_saving := false; // if (db == &archive) { // if (export_to_csv(&archive, ar_file_path) == false) { // print_error("Failed to save archive."); // error_saving |= true; // } // } // if (countdown_to_autosave > 0 || is_autosave_enabled == false) { // if (store_database(&database, db_file_path) == false) { // print_error("Failed to save database."); // error_saving |= true; // } // } // if (error_saving) { // print_error("Press any key to close."); // draw_error_window(); // timeout(INPUT_AWAIT_INF); // getch(); // } endwin(); exit(xx ifx error_saving then 1 else 0); } /* main :: () { print("TNL %\n", TASK_NAME_LENGTH); print("TNB %\n", TASK_NAME_BYTES); home, success := get_home_directory(); print("home '%' | success '%'\n", home, success); print("Task Time Tracker version %\n", VERSION); print("Copyright 2022 Daniel Martins\n"); print("License GPL-3.0-or-later\n"); // TODO More binding examples here https://github.com/vrcamillo/jai-tracy // short : s16 // int : s32 // long : s64 (int) // single : float32 (float) // double : float64 stdscr := initscr(); curs_set(0); noecho(); box(stdscr, 0, 0); while true { key := getch(); if key == #char "q" break; mvaddstr(1, 1, "> wow alçapão <"); } endwin(); } */ // TODO DEBUG print_owner_allocator :: (tag: string, memory: *void) { owner := "unkown"; if true == xx context.allocator.proc(.IS_THIS_YOURS, 0, 0, memory, null) then owner = "default"; else if true == xx temp.proc(.IS_THIS_YOURS, 0, 0, memory, null) then owner = "temp"; print("'%' belongs to '%'\n", tag, owner); } // TODO DEBUG print_database :: (db: Database) { for db.tasks { print("% | % : % : % : % : % : % : %\n", cast(string)it.name, it.times[0], it.times[1], it.times[2], it.times[3], it.times[4], it.times[5], it.times[6] ); } }