aboutsummaryrefslogtreecommitdiff
path: root/ttt.jai
diff options
context:
space:
mode:
authordam <dam@gudinoff>2024-05-29 12:50:48 +0100
committerdam <dam@gudinoff>2024-05-29 12:50:48 +0100
commit986c0ca11d45e83e97479fcfad5facd1e56b0beb (patch)
tree8576d455c6748a38e81787b308fb8cbbe1ad7b89 /ttt.jai
parent393e5a926cd105c4a2f902824a233cc41af91198 (diff)
parentec706533ca26d49670adb97617df0d565528e395 (diff)
downloadtask-time-tracker-986c0ca11d45e83e97479fcfad5facd1e56b0beb.tar.zst
task-time-tracker-986c0ca11d45e83e97479fcfad5facd1e56b0beb.zip
Merge with jai-prototypev2.0
Diffstat (limited to 'ttt.jai')
-rw-r--r--ttt.jai1806
1 files changed, 1806 insertions, 0 deletions
diff --git a/ttt.jai b/ttt.jai
new file mode 100644
index 0000000..67d6e18
--- /dev/null
+++ b/ttt.jai
@@ -0,0 +1,1806 @@
+// Copyright 2024 Daniel Almeida Martins
+// License GPL-3.0-or-later
+
+DEBUG :: false;
+
+#import "Basic"()(MEMORY_DEBUGGER=DEBUG); // Enabling memory debug adds ~30MB of RAM.
+#import "System";
+#import "Sort";
+#import "Math";
+#import "File";
+#import "File_Utilities";
+#import "String";
+#import "Saturation"(PREFER_BRANCH_FREE_CODE=true);
+#import "UTF8";
+TUI :: #import "TUI"(COLOR_MODE_BITS=4);
+
+VERSION :: "2.0"; // Use only 3 chars (to fit layouts).
+YEAR :: "2024";
+NUM_WEEK_DAYS :: 7;
+
+APP_FOLDER_NAME :: ".task_time_tracker";
+DB_FILE_NAME :: "database.bin";
+AR_FILE_NAME :: "archive.csv";
+DB_FILE_SIGN_STR :: "TTT:B:02";
+
+ASSERT_NOT_NULL :: "Parameter '%' is null.";
+ASSERT_NOT_EMPTY :: "Parameter '%' is empty.";
+ASSERT_INVALID_INDEX:: "Invalid index '%'.";
+
+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;
+}
+
+Database :: struct {
+ modified_on : Apollo_Time;
+ active_idx : s64 = -1;
+ selected_idx : s64 = -1;
+ total_times : [NUM_WEEK_DAYS] s64;
+ tasks : [..] Task;
+}
+
+database : Database;
+archive : Database;
+is_autosave_enabled := true;
+countdown_to_autosave := -1;
+first_day_of_week := 1; // (0-6, Sunday = 0, Monday = 1, ...)
+app_directory : string;
+db_file_path : string;
+ar_file_path : string;
+
+size_x : int;
+size_y : int;
+pos_x : int;
+pos_y : int;
+draw_string_builder : String_Builder;
+
+style_default := TUI.Style.{
+ background = TUI.Palette.BLACK,
+ foreground = TUI.Palette.WHITE,
+ use_default_background_color = true,
+ use_default_foreground_color = true,
+};
+
+style_selected := TUI.Style.{
+ background = TUI.Palette.CYAN,
+ foreground = TUI.Palette.BLACK,
+};
+
+style_selected_inverted := TUI.Style.{
+ background = TUI.Palette.BLACK,
+ foreground = TUI.Palette.CYAN,
+ bold = true,
+ use_default_background_color = true,
+};
+
+style_active := TUI.Style.{
+ background = TUI.Palette.BLACK,
+ foreground = TUI.Palette.BLUE,
+ bold = true,
+ use_default_background_color = true,
+};
+
+style_active_selected := TUI.Style.{
+ background = TUI.Palette.BLUE,
+ foreground = TUI.Palette.WHITE,
+ bold = true,
+};
+
+style_error := TUI.Style.{
+ background = TUI.Palette.BLACK,
+ foreground = TUI.Palette.RED,
+ bold = true,
+ use_default_background_color = true,
+};
+
+Layouts :: enum u8 {
+ NORMAL;
+ COMPACT;
+}
+
+
+error_string_builder: String_Builder;
+
+error_time_limit := Apollo_Time.{0, 0};
+
+print_error :: (message: string, data: *void, info: Log_Info) {
+
+ if TUI.is_active() == false {
+ write_strings(message, "\n", to_standard_error = true);
+ return;
+ }
+
+ if error_time_limit < current_time_monotonic() {
+ reset(*error_string_builder);
+ }
+ else {
+ append(*error_string_builder, " | ");
+ }
+ append(*error_string_builder, message);
+ error_time_limit = current_time_monotonic() + seconds_to_apollo(5);
+}
+
+draw_error_window :: () {
+ if error_time_limit < current_time_monotonic() return;
+
+ // Don't show error if main window is too small.
+ w_size_x: int = ifx size_x > 120 then 120 else size_x - 2;
+ w_size_y: int = 3;
+ if (current_time_monotonic() >= error_time_limit
+ || size_x - w_size_x < 2
+ || size_y - w_size_y < 2
+ ) {
+ return;
+ }
+
+ pos_x := 1 + (size_x - w_size_x) / 2;
+ pos_y := 1 + (size_y - w_size_y) / 2;
+
+ TUI.using_style(style_error);
+ TUI.draw_box(pos_x, pos_y, w_size_x, w_size_y, true);
+ TUI.set_cursor_position(pos_x + 1, pos_y);
+ write_string(" Error ");
+ TUI.set_cursor_position(pos_x + 1, pos_y + 1);
+ for 1..w_size_x-2 {
+ print_character(#char " ");
+ }
+ TUI.set_cursor_position(pos_x + 1, pos_y + 1);
+ write_builder(*error_string_builder, false);
+}
+
+trigger_autosave :: () {
+ countdown_to_autosave = 13375; // ms
+}
+
+show_processing :: () {
+ TUI.set_cursor_position(1, 1);
+ TUI.using_style(style_active);
+ write_strings(TUI.Commands.DrawingMode, TUI.Drawings.Diamond, TUI.Commands.TextMode);
+}
+
+hide_processing :: () {
+ TUI.set_cursor_position(1, 1);
+ TUI.using_style(style_default);
+ TUI.tui_write_string(TUI.Commands.DrawingMode);
+ TUI.tui_write_string(TUI.Drawings.CornerTL);
+ TUI.tui_write_string(TUI.Commands.TextMode);
+}
+
+// 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;
+}
+
+// Count digits required to represent number on base. Sign is discarded.
+count_digits :: (number: s64, base: s64 = 10) -> s64 {
+ assert(base > 1, "The smallest integer base for a number system is 2.");
+ digits := 0;
+ while number != 0 {
+ number /= base;
+ digits += 1;
+ }
+ return digits;
+}
+
+// Prints, on row y and column x, the time using 5 characters centered on space.
+// Returns the result of a call to mvprintw.
+print_time :: (y: int, x: int, time: s64, space: int) -> int {
+
+ // Use TUI stubs so that callers may choose to use the tui_builder buffer.
+ print :: TUI.tui_print;
+ write_string :: TUI.tui_write_string;
+
+ TIME_CHARS :: 5;
+ assert(space >= TIME_CHARS);
+
+ mul_f64_s64 :: inline (a: float64, b: s64) -> s64 {
+ return cast(s64)(a * cast(float64)b);
+ }
+
+ left_padding := (space - TIME_CHARS) / 2;
+ right_padding := space - TIME_CHARS - left_padding;
+
+ print_padding :: (size: int) {
+ assert(size >= 0, "Cannot print negative padding values. The procedure accepts signed values just for convenience.");
+ while size > 0 {
+ write_string(" ");
+ size -= 1;
+ }
+ }
+
+ TUI.set_cursor_position(x, y);
+
+ if time < 0 {
+ print_padding(left_padding);
+ write_string(" - ");
+ print_padding(right_padding);
+ return 0;
+ }
+ else if time == 0 {
+ print_padding(left_padding);
+ write_string(" 0 ");
+ print_padding(right_padding);
+ return 0;
+ }
+ else if time < SECONDS_IN_MINUTE {
+ print_padding(left_padding);
+ print("%s ", FormatInt.{value = time, minimum_digits=3, padding=#char " "});
+ print_padding(right_padding);
+ return 0;
+ }
+ else if time < #run mul_f64_s64(100, SECONDS_IN_HOUR) {
+ hours := time / SECONDS_IN_HOUR;
+ minutes := (time - (hours * SECONDS_IN_HOUR) ) / SECONDS_IN_MINUTE;
+ print_padding(left_padding);
+ print("%:%", FormatInt.{value = hours, minimum_digits=2}, FormatInt.{value = minutes, minimum_digits=2});
+ print_padding(right_padding);
+ return 0;
+ }
+ else if time < #run mul_f64_s64(9999.5, SECONDS_IN_DAY) {
+ value := cast(float64) time / SECONDS_IN_DAY;
+ decimals :=
+ ifx time >= #run mul_f64_s64(99.95, SECONDS_IN_DAY) then 0 else
+ ifx time >= #run mul_f64_s64(9.995, SECONDS_IN_DAY) then 1 else
+ 2;
+ print_padding(left_padding);
+ print("%d", FormatFloat.{value = value, trailing_width=decimals, width=4, zero_removal=.NO});
+ print_padding(right_padding);
+ return 0;
+ }
+ else if time < #run mul_f64_s64(9999.5, SECONDS_IN_YEAR) {
+ value := cast(float64) time / SECONDS_IN_YEAR;
+ decimals :=
+ ifx time >= #run mul_f64_s64(99.95, SECONDS_IN_YEAR) then 0 else
+ ifx time >= #run mul_f64_s64(9.995, SECONDS_IN_YEAR) then 1 else
+ 2;
+ print_padding(left_padding);
+ print("%y", FormatFloat.{value = value, trailing_width=decimals, width=4, zero_removal=.NO});
+ print_padding(right_padding);
+ return 0;
+ }
+ else {
+ print_padding(left_padding);
+ write_string(" ∞ ");
+ print_padding(right_padding);
+ return 0;
+ }
+}
+
+// Returns active task or NULL if none applies.
+get_active_task :: inline (db: Database) -> *Task {
+ return ifx db.active_idx >= 0 then *db.tasks[db.active_idx] else null;
+}
+
+// Returns selected task or NULL if none applies.
+get_selected_task :: inline (db: Database) -> *Task {
+ return ifx db.selected_idx >= 0 then *db.tasks[db.selected_idx] else null;
+}
+
+is_valid_index :: inline(db: Database, index: s64) -> bool { return index >= 0 && index < db.tasks.count; }
+
+// Adds a task to the database and returns it.
+// If necessary, expands database capacity.
+add_task :: (db: *Database, task: *Task = null) -> task: *Task, index: s64 {
+ assert(db != null, ASSERT_NOT_NULL, "db");
+
+ // If the task belongs to this database, calling array_add might invalidate the pointer
+ // because the memory may be reallocated, thus we always use a copy of the task.
+ new_task := ifx task == null then .{} else <<task;
+ array_add(*db.tasks, new_task);
+
+ for * db.total_times {
+ it.* = add(it.*, new_task.times[it_index]);
+ }
+
+ idx := db.tasks.count-1;
+ return *db.tasks[idx], idx;
+}
+
+// Deletes task from database.
+// If possible, shrinks the database capacity.
+// Returns success.
+delete_task :: (using db: *Database, index: s64) -> bool {
+ assert(db != null, ASSERT_NOT_NULL, "db");
+ assert(is_valid_index(db, index), ASSERT_INVALID_INDEX, index);
+
+ // Remove task timer values from total timers.
+ for tasks[index].times {
+ total_times[it_index] = sub(total_times[it_index], it);
+ }
+
+ // Move tasks after the index position to their new positions.
+ for index..tasks.count-2
+ tasks[it] = tasks[it+1];
+ tasks.count -= 1;
+
+ // Adjust selected task.
+ if (selected_idx >= tasks.count) {
+ selected_idx -= 1;
+ }
+
+ // Adjust active task.
+ if (active_idx > index) {
+ active_idx -= 1;
+ }
+ else if (active_idx == index) {
+ active_idx = -1;
+ }
+
+ // Try to shrink database capacity if using more than 2MB.
+ size_of_task := size_of(Task);
+ if (tasks.allocated >> 2) > tasks.count && tasks.allocated * size_of_task > 2_000_000 {
+ new_capacity := tasks.allocated >> 1;
+ new_tasks_data := realloc(tasks.data, new_capacity * size_of_task, tasks.allocated * size_of_task,, tasks.allocator);
+ if new_tasks_data != null {
+ tasks.data = new_tasks_data;
+ tasks.allocated = new_capacity;
+ }
+ }
+
+ return true;
+}
+
+// Moves task from source to target.
+// Source and target get clamped to database size.
+move_task :: (using db: *Database, source: s64, target: s64) {
+ assert(db != null, ASSERT_NOT_NULL, "db");
+
+ source = clamp(source, 0, tasks.count-1);
+ target = clamp(target, 0, tasks.count-1);
+
+ if (source == target) return;
+
+ // Move task to new location, but first, shift the others to allow some space.
+ temp_task := tasks[source];
+ move_size := abs(target - source);
+
+ if target > source {
+ for 0..move_size-1
+ tasks[source + it] = tasks[source + it + 1];
+ }
+ else {
+ for < move_size-1..0
+ tasks[target + it + 1] = tasks[target + it];
+ }
+
+ tasks[target] = temp_task;
+
+ // Adjust active and selected tasks.
+ if (active_idx == source) {
+ active_idx = target;
+ }
+ else if (source < active_idx && active_idx <= target) {
+ active_idx -= 1;
+ }
+ else if (target <= active_idx && active_idx < source) {
+ active_idx += 1;
+ }
+ selected_idx = target;
+}
+
+// Find similar task and return it's index, or -1 if not found.
+find_similar_task :: (db: *Database, task: Task, ignore_times := false) -> idx: s64 {
+ compare_array :: (a: [] $T, b: [] T) -> int {
+ for 0..min(a.count, b.count)-1 {
+ if a[it] > b[it] return 1;
+ if a[it] < b[it] return -1;
+ }
+ if a.count > b.count return 1;
+ if a.count < b.count return -1;
+ return 0;
+ }
+
+ for db.tasks {
+ if compare(xx task.name, xx it.name) == 0 && (ignore_times || compare_array(task.times, it.times) == 0) {
+ return it_index;
+ }
+ }
+ return -1;
+}
+
+// Updates the times on the active task (and adjusts database totals).
+update_times :: (db: *Database) {
+ assert(db != null, ASSERT_NOT_NULL, "db");
+
+ // Get time frame in UTC.
+ start_time := db.modified_on;
+ stop_time := seconds_to_apollo(to_seconds(current_time_consensus())); // HACK Discard sub-seconds information because Task.times only store seconds. To other workaround would be to use Task.times as Apollo_Time instead of s64 seconds.
+
+ // Keep track of this update.
+ db.modified_on = stop_time;
+
+ active_task := get_active_task(db);
+
+ if active_task == null return;
+
+ start_week_day: s8;
+ while (start_time < stop_time) {
+
+ start_week_day = to_calendar(start_time, .LOCAL).day_of_week_starting_at_0;
+
+ // Get next day in local time.
+ start_of_day_cal := to_calendar(start_time, .LOCAL);
+ start_of_day_cal.hour = 0;
+ start_of_day_cal.minute = 0;
+ start_of_day_cal.second = 0;
+ start_of_day_cal.millisecond = 0;
+ start_of_day := calendar_to_apollo(start_of_day_cal);
+
+ next_day := start_of_day + #run seconds_to_apollo(SECONDS_IN_DAY);
+ next_start := ifx next_day < stop_time then next_day else stop_time;
+ elapsed_time := to_seconds(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.
+update_total_times :: (db: *Database) {
+ assert(db != null, ASSERT_NOT_NULL, "db");
+
+ for *total: db.total_times { <<total = 0; }
+
+ for task: db.tasks {
+ for *total, index: db.total_times {
+ <<total = add(<<total, task.times[index]);
+ }
+ }
+}
+
+// Resets the times of the provided task (and adjusts database totals).
+reset_task_times :: (db: *Database, index: s64) {
+ assert(db != null, ASSERT_NOT_NULL, "db");
+ assert(is_valid_index(db, index), ASSERT_INVALID_INDEX, index);
+
+ // Make sure we sync before applying the changes.
+ update_times(db);
+
+ for * db.tasks[index].times {
+ db.total_times[it_index] = sub(db.total_times[it_index], <<it);
+ <<it = 0;
+ }
+}
+
+// Sets the time on the day and task provided (and adjusts database totals).
+set_task_time :: (db: *Database, index: s64, day: int, time: s64) {
+ assert(db != null, ASSERT_NOT_NULL, "db");
+ assert(is_valid_index(db, index), ASSERT_INVALID_INDEX, index);
+
+ // Make sure we sync before applying the changes.
+ update_times(db);
+
+ db.total_times[day] = add(db.total_times[day], time - db.tasks[index].times[day]);
+ db.tasks[index].times[day] = time;
+}
+
+// Adds the time on the day and task provided (and adjusts database totals).
+add_task_time :: (db: *Database, index: s64, day: int, time: s64) {
+ assert(db != null, ASSERT_NOT_NULL, "db");
+ assert(is_valid_index(db, index), ASSERT_INVALID_INDEX, index);
+
+ // Make sure we sync before applying the changes.
+ update_times(db);
+
+ db.total_times[day] = add(db.total_times[day], time);
+ db.tasks[index].times[day] = add(db.tasks[index].times[day], time);
+}
+
+// Adds the time on the day and task provided (and adjusts database totals).
+add_task_times :: (db: *Database, index: s64, times: [NUM_WEEK_DAYS] s64) {
+ assert(db != null, ASSERT_NOT_NULL, "db");
+ assert(is_valid_index(db, index), ASSERT_INVALID_INDEX, index);
+
+ // Make sure we sync before applying the changes.
+ update_times(db);
+
+ for times {
+ db.total_times[it_index] = add(db.total_times[it_index], it);
+ db.tasks[index].times[it_index] = add(db.tasks[index].times[it_index], it);
+ }
+}
+
+// Resets database to the initial state and deallocates all memory taken by tasks.
+reset_database :: (db: *Database) {
+ assert(db != null, ASSERT_NOT_NULL, "db");
+ array_reset(*db.tasks);
+ <<db = .{};
+}
+
+// Stores data from database into binary file.
+// Returns success.
+store_database :: (db: Database, path: string) -> success: bool #must {
+ assert(xx path, ASSERT_NOT_EMPTY, "path");
+
+ // Open file.
+ file, open_success := file_open(path, for_writing = true);
+ if open_success == false 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 #must {
+ assert(db != null, ASSERT_NOT_NULL, "db");
+ assert(xx path, ASSERT_NOT_EMPTY, "path");
+
+ // Open file.
+ file, open_success := file_open(path);
+ if open_success == false then 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 log_error("Failed to read file signature.");
+ if cast(string)file_signature != DB_FILE_SIGN_STR {
+ log_error("Invalid file signature while loading database.");
+ return false;
+ }
+
+ // Read database structure.
+ read_success = file_read(file, db, size_of(Database));
+ if read_success == false {
+ log_error("Failed to read database info.");
+ return false;
+ }
+
+ // Reserve database capacity for tasks.
+ tasks_count := db.tasks.count;
+ Initialize(*db.tasks); // Cleanup whatever was read from file.
+ array_reserve(*db.tasks, tasks_count);
+
+ // Read database tasks.
+ file_read(file, db.tasks.data, size_of(Task) * tasks_count);
+ db.tasks.count = tasks_count;
+
+ // Make sure we are reading all the file.
+ buffer: u8;
+ success, bytes := file_read(file, *buffer, 1);
+ if bytes > 0 {
+ log_error("Unexpected content found at the end of file '%'.", path);
+ return false;
+ }
+
+ return true;
+}
+
+// Exports data into CSV file.
+// Returns success.
+export_to_csv :: (db: Database, path: string) -> success: bool #must {
+ assert(xx path, ASSERT_NOT_EMPTY, "path");
+
+ auto_release_temp();
+
+ 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 = ",",, temporary_allocator));
+
+ 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]);
+ }
+
+ return write_entire_file(path, *builder);
+}
+
+// Imports CSV file into database.
+// Returns success.
+import_from_csv :: (db: *Database, path: string) -> bool #must {
+ assert(db != null, ASSERT_NOT_NULL, "db");
+ assert(xx path, ASSERT_NOT_EMPTY, "path");
+
+ advance :: inline (array: *[] $T, amount: int = 1) {
+ assert(amount >= 0);
+ assert(array.count >= amount);
+ array.count -= amount;
+ array.data += amount;
+ }
+
+ // Taken from Text_File_Handler module.
+ consume_next_line :: (sp: *string) -> string, bool {
+ s := << sp;
+ found, result, right := split_from_left(s, 10,, temporary_allocator);
+
+ if !found {
+ << sp = "";
+ return s, (s.count > 0);
+ }
+
+ advance(sp, result.count + 1);
+
+ if result {
+ // If there's a carriage return at the end, remove it by decrementing the string's length.
+ if result[result.count-1] == 13 result.count -= 1;
+ }
+
+ return result, true;
+ }
+
+ data, success := read_entire_file(path);
+ if success == false {
+ log_error("Failed to read file '%'.", path);
+ return false;
+ }
+ defer free(data);
+
+ // Work on a string struct copy, otherwise the free(data) will fail.
+ csv := data;
+
+ // Skip header line.
+ consume_next_line(*csv);
+
+ // Parse CSV lines.
+ while (true) {
+ auto_release_temp();
+
+ line, success := consume_next_line(*csv);
+ if success == false then break;
+
+ task: Task;
+ csv_values := split(line, ",",, temporary_allocator);
+
+ // Truncate and import task name.
+ task_name := truncate(csv_values[0], task.name.count);
+ memcpy(task.name.data, task_name.data, task_name.count);
+
+ advance(*csv_values);
+ for csv_values
+ task.times[it_index] = string_to_int(it);
+
+ add_task(db, *task);
+ }
+
+ // Adjust selected task.
+ if (db.selected_idx < 0 && db.tasks.count > 0) db.selected_idx = 0;
+
+ return true;
+}
+
+// Appends task to the end of the CSV file.
+// Returns success.
+append_to_csv :: (task: Task, path: string) -> success: bool #must {
+ assert(xx path, ASSERT_NOT_EMPTY, "path");
+
+ auto_release_temp();
+
+ file, file_success := file_open(path, true, true);
+ if file_success == false then return false;
+ defer file_close(*file);
+
+ file_size := file_length(file);
+ file_set_position(file, file_size-1);
+ last_char: u8;
+ file_read(file, *last_char, 1);
+ if (last_char != #char "\n") {
+ file_write(*file, "\n");
+ }
+
+ task_name := copy_temporary_string(xx task.name);
+ replace_chars(task_name, ",", #char " ");
+ csv_line := tprint("%,%,%,%,%,%,%,%\n",
+ task_name, task.times[0], task.times[1], task.times[2], task.times[3], task.times[4], task.times[5], task.times[6]);
+
+ return file_write(*file, csv_line);
+}
+
+// Selects task by index.
+// Index gets clamped to [0, db->count[.
+select_task :: (db: *Database, index: s64) {
+ assert(db != null, ASSERT_NOT_NULL, "db");
+ db.selected_idx = ifx db.tasks.count == 0 then -1 else clamp(index, 0, db.tasks.count-1);
+}
+
+// Selects task by delta relative to currently selected task.
+select_task_by_delta :: (db: *Database, delta: s64) {
+ assert(db != null, ASSERT_NOT_NULL, "db");
+ 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(db, idx);
+}
+
+// Set active task.
+// Passing -1 de-activates any previously active task.
+set_active_task :: (db: *Database, index: s64) {
+ assert(db != null, ASSERT_NOT_NULL, "db");
+ assert(index == -1 || is_valid_index(db, index), ASSERT_INVALID_INDEX, index);
+ update_times(db);
+ db.active_idx = index;
+}
+
+// Returns true when database is full.
+is_database_full :: inline (db: Database) -> bool {
+ return db.tasks.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_user_interface :: () {
+
+ // 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;
+ }
+ }
+
+ assert(TUI.setup_terminal(), "Failed to setup TUI.");
+}
+
+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;
+ }
+ }
+}
+
+// Pagination based on currently selected task (show page where selected task is).
+// Display up to rows allowed by the layout, or less if reached end of database.
+get_visible_tasks_indices :: (db: Database) -> first_visible_index: int, last_visible_index: int {
+ first_visible_index :=
+ (db.selected_idx / layout_tasks_rows) * layout_tasks_rows;
+
+ last_visible_index :=
+ first_visible_index +
+ (ifx layout_tasks_rows > db.tasks.count - first_visible_index
+ then db.tasks.count - first_visible_index
+ else layout_tasks_rows);
+
+ return first_visible_index, last_visible_index;
+}
+
+get_day_index_from_layout_index :: inline (layout_index: int) -> int {
+ return (layout_index + first_day_of_week) % NUM_WEEK_DAYS;
+}
+
+// Convert indices to allow using different days as the first-day-of-the-week.
+get_layout_index_from_day_index :: inline (day_index: int) -> int {
+ return (day_index - first_day_of_week + NUM_WEEK_DAYS) % NUM_WEEK_DAYS;
+}
+
+draw_user_interface :: (db: *Database, layout: *Layout, redraw_all: bool = true) {
+
+ auto_release_temp();
+
+ TUI.using_builder_as_output(*draw_string_builder);
+ print :: TUI.tui_print;
+ write_string :: TUI.tui_write_string;
+
+ // 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, .LOCAL).day_of_week_starting_at_0;
+
+ // Calculate indices of visible tasks.
+ start_idx, stop_idx := get_visible_tasks_indices(db);
+
+ // If not much is happening, we may just update the active task and it's times.
+ if redraw_all == false {
+
+ if active_task == null then return;
+
+ layout_idx := get_layout_index_from_day_index(now_week_day);
+
+ x_today_offset := 1 + 1 + layout.columns[L_TITLE_IDX].width + 1;
+ for 0..layout_idx-1 {
+ x_today_offset += 1 + layout.columns[L_DAYS_IDX + it].width;
+ }
+
+ x_total_offset := size_x - layout.columns[L_TOTAL_IDX].width;
+
+ // Calculate active task times.
+ task_time := active_task.times[now_week_day];
+ total_task_time := 0;
+ for 0..6 {
+ total_task_time = add(total_task_time, active_task.times[it]);
+ }
+
+ // Draw active task times.
+ if db.active_idx >= start_idx && db.active_idx <= stop_idx {
+ TUI.using_style(ifx db.active_idx == db.selected_idx then style_active_selected else style_active);
+ y := 1 + 1 + (db.active_idx - start_idx);
+ print_time(y, x_today_offset, task_time, layout.columns[L_DAYS_IDX + layout_idx].width);
+ print_time(y, x_total_offset, total_task_time, layout.columns[L_TOTAL_IDX].width);
+ }
+
+ // Calculate daily totals.
+ daily_time := db.total_times[now_week_day];
+ total_time := 0;
+ for 0..6 {
+ total_time = add(total_time, db.total_times[it]);
+ }
+
+ // Draw daily totals.
+ TUI.set_style(style_active);
+ print_time(size_y, x_today_offset, daily_time, layout.columns[L_DAYS_IDX + layout_idx].width);
+ TUI.set_style(style_default);
+ print_time(size_y, x_total_offset, total_time, layout.columns[L_TOTAL_IDX].width);
+
+ write_builder(*draw_string_builder);
+
+ return;
+ }
+
+ x: int;
+ y: int;
+ col: *Column;
+
+ // Reset theme and clear screen.
+ TUI.clear_terminal();
+
+ // Draw outer border.
+ TUI.draw_box(1, 1, size_x, size_y);
+
+ // Draw table grids.
+ y = 1;
+ x = 1;
+ write_string(TUI.Commands.DrawingMode);
+ for 0..layout.columns.count-2 {
+ column := layout.columns[it];
+ x += 1 + column.width;
+ TUI.set_cursor_position(x, y);
+ write_string(TUI.Drawings.TeeT);
+ for row: 2..size_y {
+ TUI.set_cursor_position(x, row);
+ write_string(TUI.Drawings.LineV);
+ }
+ TUI.set_cursor_position(x, size_y);
+ write_string(TUI.Drawings.TeeB);
+ }
+ write_string(TUI.Commands.TextMode);
+
+
+ ///////////////////////////////////////////////////////////////////////////
+ // Draw headers.
+ y = 1;
+ x = 1;
+
+ // Headers : title
+ x += 1;
+ col = *layout.columns[L_TITLE_IDX];
+ TUI.set_cursor_position(x + col.alignment_offset, y);
+ write_string(ifx db == *archive then layout.archive_title else col.header);
+ x += col.width;
+
+ // Headers : days
+ for 0..NUM_WEEK_DAYS-1 {
+ day_idx := get_day_index_from_layout_index(it);
+ x += 1;
+
+ // Apply theme.
+ if (day_idx == now_week_day && active_task != null) {
+ TUI.set_style(style_active);
+ }
+ else if (day_idx == now_week_day) {
+ TUI.set_style(style_selected_inverted);
+ }
+ else {
+ TUI.set_style(style_default);
+ }
+ col = *layout.columns[L_DAYS_IDX + day_idx];
+ TUI.set_cursor_position(x + col.alignment_offset, y);
+ write_string(col.header);
+ x += col.width;
+ }
+ TUI.set_style(style_default);
+
+ // Headers : total
+ x += 1;
+ col = *layout.columns[L_TOTAL_IDX];
+ TUI.set_cursor_position(x + col.alignment_offset, y);
+ write_string(col.header);
+
+
+ ///////////////////////////////////////////////////////////////////////////
+ // Draw tasks.
+
+ total_time := 0;
+ column_width: int;
+
+ empty_line := talloc_string(size_x);
+ memset(empty_line.data, #char " ", size_x);
+
+ y = 1;
+ for task_idx: start_idx..stop_idx-1 {
+ task := *db.tasks[task_idx];
+ y += 1;
+ x = 1;
+
+ // Apply theme.
+ if (task == active_task && task == selected_task) {
+ TUI.set_style(style_active_selected);
+ }
+ else if (task == selected_task) {
+ TUI.set_style(style_selected);
+ }
+ else if (task == active_task) {
+ TUI.set_style(style_active);
+ }
+ else {
+ TUI.set_style(style_default);
+ }
+
+ // Task title.
+ x += 1;
+ column_width = layout.columns[L_TITLE_IDX].width;
+ // Print title.
+ // When the column is wider than the name, we end up with the trailing zeros.
+ // Thankfully, the trailing zeros are not printed so, it's all good.
+ task_name := cast(string)task.name;
+ task_name = truncate(task_name, column_width);
+ TUI.set_cursor_position(x, y);
+ write_string(task_name);
+ // Paint the remaining column space.
+ task_name_char_count := count_characters(task_name, is_null_terminated = true);
+ paint_remaining := string.{ column_width - task_name_char_count, empty_line.data };
+ write_string(paint_remaining);
+
+ x += column_width;
+
+ // Task times.
+ total_time = 0;
+ for 0..NUM_WEEK_DAYS-1 {
+ x += 1;
+ day_idx := get_day_index_from_layout_index(it);
+ column_width = layout.columns[L_DAYS_IDX + day_idx].width;
+ task_time := task.times[day_idx];
+ total_time = add(total_time, task_time);
+ print_time(y, x, task_time, column_width);
+ x += column_width;
+ }
+
+ // Task total.
+ x += 1;
+ print_time(y, x, total_time, layout.columns[L_TOTAL_IDX].width);
+ }
+ TUI.set_style(style_default);
+
+
+ ///////////////////////////////////////////////////////////////////////////
+ // Draw selected/total tasks.
+ size := 1 + count_digits(db.selected_idx + 1) + 1 + count_digits(db.tasks.count) + 1; // " XXX/YYY "
+ TUI.set_cursor_position(2, size_y);
+ if (size <= layout.columns[L_TITLE_IDX].width) {
+ print(" %/% ", db.selected_idx + 1, db.tasks.count);
+ }
+ else {
+ print("%", db.selected_idx + 1);
+ }
+
+
+ ///////////////////////////////////////////////////////////////////////////
+ // Draw daily totals.
+ y = size_y;
+ x = 1 + 1 + layout.columns[L_TITLE_IDX].width;
+ total_time = 0;
+ for 0..NUM_WEEK_DAYS-1 {
+ day_idx := get_day_index_from_layout_index(it);
+ daily_total := db.total_times[day_idx];
+ x += 1;
+
+ // Apply theme.
+ if (day_idx == now_week_day && active_task != null) {
+ TUI.set_style(style_active);
+ }
+ else if (day_idx == now_week_day) {
+ TUI.set_style(style_selected_inverted);
+ }
+ else {
+ TUI.set_style(style_default);
+ }
+
+ column_width = layout.columns[L_DAYS_IDX + day_idx].width;
+ total_time = add(total_time, daily_total);
+ print_time(y, x, daily_total, column_width);
+ x += column_width;
+ }
+ TUI.set_style(style_default);
+ x += 1;
+ print_time(y, x, total_time, layout.columns[L_TOTAL_IDX].width);
+
+ write_builder(*draw_string_builder);
+}
+
+free_memory :: () {
+ reset_database(*database);
+ reset_database(*archive);
+
+ free(app_directory);
+ free(db_file_path);
+ free(ar_file_path);
+}
+
+read_input_string :: (x: int, y: int, input_limit: int, input_width: int = 0) -> value: string, success: bool {
+ TUI.set_cursor_position(x + input_limit, y);
+ write_string(TUI.Commands.DrawingMode);
+ for 1..input_width {
+ write_string(TUI.Drawings.Checkerboard);
+ }
+ write_string(TUI.Commands.TextMode);
+ TUI.set_cursor_position(x, y);
+
+ style_input := context.tui_style;
+ style_input.underline = true;
+ TUI.using_style(style_input);
+
+ value, key := TUI.read_input_line(input_limit);
+ return value, key == TUI.Keys.Enter;
+}
+
+// Returns success.
+read_input_int :: (y: int, message: string) -> value: int, success: bool {
+ x :: 3;
+
+ // Draw checkerboard.
+ TUI.set_cursor_position(2, y);
+ write_string(TUI.Commands.DrawingMode);
+ for 2..x {
+ print(TUI.Drawings.Checkerboard);
+ }
+ write_string(TUI.Commands.TextMode);
+
+ TUI.set_cursor_position(x, y);
+ write_strings(" ", message, " ");
+
+ input_pos_x := x + message.count + 2;
+ input_width := size_x - input_pos_x;
+
+ str := read_input_string(input_pos_x, y, input_width,, temporary_allocator);
+
+ value, success := parse_int(*str);
+ return value, success;
+}
+
+// Shows message to user and waits for user key press.
+prompt_user_key :: (y: int, message: string) -> TUI.Key {
+ x :: 3;
+
+ // Draw checkerboard.
+ TUI.set_cursor_position(2, y);
+ write_string(TUI.Commands.DrawingMode);
+ for 1..size_x-2 {
+ print(TUI.Drawings.Checkerboard);
+ }
+ write_string(TUI.Commands.TextMode);
+
+ TUI.set_cursor_position(x, y);
+ write_strings(" ", message, " ");
+ return TUI.get_key();
+}
+
+main :: () {
+
+ #if DEBUG { defer report_memory_leaks(); }
+
+ context.logger = print_error;
+
+ 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.
+
+ if success_path == false {
+ log_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) {
+ log_error("Failed to initialize database.");
+ exit(1);
+ }
+ }
+
+ if (file_exists(ar_file_path) == false) {
+ if (export_to_csv(archive, ar_file_path) == false) {
+ log_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",
+ " -s, --start-of-week [NUMBER] Set first day of week (0=Sunday, 1=Monday...).\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",
+ " w, W Archive duplicates and reset all tasks.\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",
+ " c, C Coalesce similar tasks.\n",
+ " n, N Create new task.\n",
+ " m, M Move selected task to position.\n",
+ " g, G Select task by position.\n",
+ " i, I Invert tasks order.\n",
+ " s, S Sort tasks by:\n",
+ " n name;\n",
+ " t total time;\n",
+ " 1..7 time of Nth day of week.\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 tasks 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 operations such as saving 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 {
+ log_error("Missing CSV file path to import.");
+ exit(1);
+ }
+ if (load_database(*database, db_file_path) == false) {
+ log_error("Failed to load database during import.");
+ exit(1);
+ }
+ if (import_from_csv(*database, args[it]) == false) {
+ log_error("Failed to import CSV file.");
+ exit(1);
+ }
+ if (store_database(*database, db_file_path) == false) {
+ log_error("Failed to store database during import.");
+ 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 {
+ log_error("Missing CSV file path to export.");
+ exit(1);
+ }
+ if (load_database(*database, db_file_path) == false) {
+ log_error("Failed to load database during export.");
+ exit(1);
+ }
+ if (export_to_csv(*database, args[it]) == false) {
+ log_error("Failed to export CSV file.");
+ exit(1);
+ }
+ reset_database(*database);
+ is_exit_requested = true;
+ continue;
+ }
+
+ if is_equal_to_any(args[it], "--start-of-week", "-s") {
+ it += 1;
+ if it >= args.count {
+ log_error("Missing number for starting day of week.");
+ exit(1);
+ }
+ first_day_of_week = parse_int(*args[it]);
+ continue;
+ }
+
+ if is_equal_to_any(args[it], "--no-autosave", "-n") {
+ is_autosave_enabled = false;
+ continue;
+ }
+
+ log_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) {
+ log_error("Failed to load database.");
+ exit(1);
+ }
+
+ initialize_user_interface();
+
+ db := *database;
+ layout := *layouts[Layouts.COMPACT];
+ redraw_all := true;
+ action_style : TUI.Style;
+
+ TUI.flush_input();
+ TUI.set_next_key(TUI.Keys.Resize);
+ while (true) {
+
+ reset_temporary_storage();
+
+ TUI.set_style(style_default);
+
+ if (is_terminal_too_small) {
+ INVALID_WINDOW_MESSAGE :: "Terminal is too small: minimum 60x3.";
+ TUI.set_cursor_position((size_x - INVALID_WINDOW_MESSAGE.count) / 2, size_y / 2);
+ write_strings(INVALID_WINDOW_MESSAGE);
+ }
+ else {
+ draw_user_interface(db, layout, redraw_all);
+ draw_error_window();
+ }
+
+ key := TUI.get_key(INPUT_TIMEOUT_MS);
+
+ if key == #char "q" || key == #char "Q" break;
+
+ redraw_all = key != TUI.Keys.None;
+
+ update_times(*database);
+
+ selected_task := get_selected_task(db);
+ active_task := get_active_task(db);
+ selected_task_row: int;
+ {
+ using db;
+ action_style = ifx selected_idx == active_idx && selected_idx != -1 then style_active else style_selected_inverted;
+
+ selected_task_row = ifx is_terminal_too_small then 0
+ else ifx (selected_idx < 0) then 2
+ else (selected_idx % layout_tasks_rows) + NUM_HEADER_ROWS + 1;
+ }
+
+ if key == {
+
+ // When input times out.
+ case TUI.Keys.None;
+ if (is_autosave_enabled && countdown_to_autosave > 0) {
+ countdown_to_autosave -= INPUT_TIMEOUT_MS;
+ if (countdown_to_autosave <= 0) {
+ show_processing();
+ if (db == *archive) {
+ if export_to_csv(*archive, ar_file_path) == false {
+ log_error("Failed to store archive during autosave.");
+ }
+ }
+ if store_database(database, db_file_path) == false {
+ log_error("Failed to store database during autosave.");
+ }
+ hide_processing();
+ }
+ }
+
+ // When terminal is resized.
+ case TUI.Keys.Resize;
+ TUI.clear_terminal();
+ size_x, size_y = TUI.get_terminal_size();
+ is_terminal_too_small = size_x < 60 || size_y < 3;
+ update_layout();
+ layout = *layouts[ifx size_x > 100 then Layouts.NORMAL else Layouts.COMPACT];
+
+ // Invert sort.
+ case #char "i"; #through;
+ case #char "I";
+ if (db.tasks.count <= 1) continue;
+
+ count := db.tasks.count-1;
+ task: Task;
+ for 0..count/2 {
+ task = db.tasks[it];
+ db.tasks[it] = db.tasks[count-it];
+ db.tasks[count-it] = task;
+ }
+ if db.active_idx >= 0
+ db.active_idx = count - db.active_idx;
+ trigger_autosave();
+
+ // New task.
+ case #char "n"; #through;
+ case #char "N";
+ if is_database_full(db) {
+ TUI.using_style(style_error);
+ prompt_user_key(selected_task_row, "Unable to create task: database is full.");
+ continue;
+ }
+
+ // Create new task.
+ now_utc := current_time_consensus();
+ now_local := to_calendar(now_utc, .LOCAL);
+ name := calendar_to_iso_string(now_local);
+ task, index := add_task(db);
+ memcpy(task.name.data, name.data, min(Task.name.count, name.count));
+
+ // Select new task.
+ select_task(db, index);
+ selected_task = get_selected_task(db);
+ trigger_autosave();
+
+ // Force rename action.
+ TUI.flush_input();
+ TUI.set_next_key(TUI.Keys.F2);
+
+ // Rename.
+ case TUI.Keys.F2;
+ if (selected_task == null) continue;
+
+ // Change task name.
+ TUI.using_style(action_style);
+ input := read_input_string(2, selected_task_row, Task.name.count, size_x - 2 - Task.name.count,, temporary_allocator);
+ if is_empty(input) == false {
+ replace_chars(input, "\t\x0B\x0C\r", #char " "); // Replace weird spaces with space.
+ memset(selected_task.name.data, 0, Task.name.count);
+ memcpy(selected_task.name.data, input.data, min(Task.name.count, input.count));
+ trigger_autosave();
+ }
+
+ // Reset task timers.
+ case TUI.Keys.Backspace;
+ if (selected_task == null) continue;
+
+ TUI.using_style(action_style);
+ if (prompt_user_key(selected_task_row, "Press enter to reset task.") == TUI.Keys.Enter) {
+ reset_task_times(db, db.selected_idx);
+ trigger_autosave();
+ }
+
+ case TUI.Keys.Delete;
+ if (selected_task == null || selected_task == active_task) continue;
+
+ TUI.using_style(action_style);
+ if (prompt_user_key(selected_task_row, "Press enter to delete task.") == TUI.Keys.Enter) {
+ delete_task(db, db.selected_idx);
+ trigger_autosave();
+ }
+
+ // Setup time.
+ case #char "1"; #through;
+ case #char "2"; #through;
+ case #char "3"; #through;
+ case #char "4"; #through;
+ case #char "5"; #through;
+ case #char "6"; #through;
+ case #char "7";
+ if (selected_task == null) continue;
+
+ // Prepare position to input time operation.
+ selected_day := cast(int)(key - #char "1");
+ input_width := layout.columns[L_DAYS_IDX + selected_day].width;
+ input_pos_x := 2 + layout.columns[L_TITLE_IDX].width;
+ for 0..selected_day-1 {
+ input_pos_x += 1 + layout.columns[L_DAYS_IDX + it].width;
+ }
+ input_pos_x += 1;
+
+ // Get input string.
+ TUI.using_style(action_style);
+ input := read_input_string(input_pos_x, selected_task_row, input_width,, temporary_allocator);
+
+ // Abort if input if empty.
+ if is_empty(input) continue;
+
+ // Search for assign '=' operator and discard everything before it.
+ assign_idx := find_index_from_left(input, "=");
+ is_assign := assign_idx >= 0;
+ if is_assign advance(*input, assign_idx + 1);
+
+ // Try to parse a number and abort if it fails.
+ input_float, parse_success := string_to_float64(input);
+ if parse_success == false continue;
+
+ // Try to parse a character representing the time multiplier.
+ multiplier: float64 = 1.0;
+ for 0..input.count-1 {
+ ch := to_lower(input[it]);
+ if ch == {
+ case #char "m"; multiplier = xx SECONDS_IN_MINUTE;
+ case #char "h"; multiplier = xx SECONDS_IN_HOUR;
+ case #char "d"; multiplier = xx SECONDS_IN_DAY;
+ case #char "y"; multiplier = xx SECONDS_IN_YEAR;
+ }
+ }
+
+ // Process input and check if it's valid.
+ input_time := input_float * multiplier;
+ if (input_time > xx S64_MAX || input_time < xx S64_MIN) continue;
+
+ // Apply changes.
+ time := cast(s64)input_time;
+ day := get_day_index_from_layout_index(selected_day);
+ if is_assign set_task_time(db, db.selected_idx, day, time);
+ else add_task_time(db, db.selected_idx, day, time);
+
+ trigger_autosave();
+
+ // Move to.
+ case #char "m"; #through;
+ case #char "M";
+ if selected_task == null continue;
+
+ TUI.using_style(action_style);
+ value, success := read_input_int(selected_task_row, "Move to:");
+ if success == false continue;
+ move_task(db, db.selected_idx, value-1); // -1 to adjust for zero based index
+ trigger_autosave();
+
+ // Go to.
+ case #char "g"; #through;
+ case #char "G";
+ if selected_task == null continue;
+
+ TUI.using_style(action_style);
+ value, success := read_input_int(selected_task_row, "Go to:");
+ if success == false continue;
+ target_index := clamp(value, 1, MAX_DATABASE_TASKS) - 1;
+ select_task(db, target_index);
+
+ // Duplicate.
+ case #char "d"; #through;
+ case #char "D";
+ if selected_task == null continue;
+
+ if is_database_full(db) {
+ TUI.using_style(style_error);
+ prompt_user_key(selected_task_row, "Unable to duplicate task: database is full.");
+ continue;
+ }
+
+ add_task(db, selected_task);
+ trigger_autosave();
+
+ // Refresh totals.
+ case TUI.Keys.F5;
+ update_total_times(db);
+ trigger_autosave();
+
+ // Go to active task.
+ case #char "t"; #through;
+ case #char "T";
+ if (active_task == null) continue;
+ select_task(db, db.active_idx);
+
+ // Start and stop.
+ case TUI.Keys.Enter; #through;
+ case TUI.Keys.Space;
+ if (db != *database || selected_task == null) continue;
+ set_active_task(db, ifx db.active_idx == db.selected_idx then -1 else db.selected_idx);
+ active_task = get_active_task(db);
+ trigger_autosave();
+
+ // Toggle archive.
+ case TUI.Keys.Tab;
+ if (db == *database) {
+ if (import_from_csv(*archive, ar_file_path) == false) {
+ reset_database(*archive);
+ log_error("Failed to load archive.");
+ continue;
+ }
+ db = *archive;
+ }
+ else {
+ if (export_to_csv(*archive, ar_file_path) == false) {
+ log_error("Failed to store archive.");
+ continue;
+ }
+ reset_database(*archive);
+ db = *database;
+ }
+
+ // Archive task.
+ case #char "a"; #through;
+ case #char "A";
+ if (db != *database || selected_task == null || selected_task == active_task) continue;
+
+ if (append_to_csv(selected_task, ar_file_path) == false) {
+ log_error("Failed to archive task.");
+ continue;
+ }
+ delete_task(db, db.selected_idx);
+ trigger_autosave();
+
+ // Restore task.
+ case #char "r"; #through;
+ case #char "R";
+ if (db != *archive || selected_task == null) continue;
+
+ if is_database_full(*database) {
+ TUI.using_style(style_error);
+ prompt_user_key(selected_task_row, "Unable to restore task: database is full.");
+ continue;
+ }
+
+ add_task(*database, selected_task);
+ delete_task(db, db.selected_idx);
+ trigger_autosave();
+
+ // Sort by.
+ case #char "s"; #through;
+ case #char "S";
+ TUI.using_style(action_style);
+ sort_by := prompt_user_key(selected_task_row, "Sort by (n) name, (1..7) day, or (t) total time.");
+ show_processing();
+
+ sort_procedure: (a: Task, b: Task) -> s64;
+ prev_active_task: Task = ifx db.active_idx >= 0 then db.tasks[db.active_idx] else .{};
+ if sort_by == {
+ case #char "n"; #through;
+ case #char "N";
+ sort_procedure = (x, y) => compare_strings(xx x.name, xx y.name);
+
+ case #char "t"; #through;
+ case #char "T";
+ sum_total :: inline (t: Task) -> s64 { total: s64; for t.times { total = add(total, it); } return total; }
+ sort_procedure = (x, y) => sum_total(x) - sum_total(y);
+
+ case #char "1"; #through;
+ case #char "2"; #through;
+ case #char "3"; #through;
+ case #char "4"; #through;
+ case #char "5"; #through;
+ case #char "6"; #through;
+ case #char "7";
+ sort_by_idx := cast(int)(sort_by - #char "1");
+ day := get_day_index_from_layout_index(sort_by_idx);
+ if day == {
+ case 0; sort_procedure = (x, y) => x.times[0] - y.times[0];
+ case 1; sort_procedure = (x, y) => x.times[1] - y.times[1];
+ case 2; sort_procedure = (x, y) => x.times[2] - y.times[2];
+ case 3; sort_procedure = (x, y) => x.times[3] - y.times[3];
+ case 4; sort_procedure = (x, y) => x.times[4] - y.times[4];
+ case 5; sort_procedure = (x, y) => x.times[5] - y.times[5];
+ case 6; sort_procedure = (x, y) => x.times[6] - y.times[6];
+ }
+
+ case;
+ continue;
+ }
+ quick_sort(db.tasks, sort_procedure);
+
+ if db.active_idx >= 0 {
+ db.active_idx = find_similar_task(db, prev_active_task);
+ }
+ trigger_autosave();
+
+ // Workspace cleanup.
+ case #char "w"; #through;
+ case #char "W";
+ if (db != *database || db.tasks.count <= 0) continue;
+ TUI.using_style(action_style);
+ if (prompt_user_key(selected_task_row, "Press enter to archive duplicates and reset all.") != TUI.Keys.Enter) continue;
+ show_processing();
+
+ failed_to_archive := false;
+ for db.tasks {
+ if append_to_csv(it, ar_file_path) {
+ reset_task_times(db, it_index);
+ }
+ else {
+ failed_to_archive = true;
+ }
+ }
+ trigger_autosave();
+ if failed_to_archive then log_error("Failed to archive tasks.");
+
+ // Coalesce similar tasks.
+ case #char "c"; #through;
+ case #char "C";
+ if (db.tasks.count <= 0) continue;
+ TUI.using_style(action_style);
+ if (prompt_user_key(selected_task_row, "Press enter to coalesce similar tasks.") != TUI.Keys.Enter) continue;
+ show_processing();
+
+ active_task_idx := db.active_idx;
+ prev_active_task: Task = ifx db.active_idx >= 0 then db.tasks[db.active_idx] else .{};
+
+ head_idx := 0;
+ while head_idx < db.tasks.count - 1 {
+ tail_idx := db.tasks.count - 1;
+ while tail_idx > head_idx {
+ t_head := *db.tasks[head_idx];
+ t_tail := *db.tasks[tail_idx];
+ if compare(xx t_head.name, xx t_tail.name) == 0 {
+ add_task_times(db, head_idx, db.tasks[tail_idx].times);
+ delete_task(db, tail_idx);
+ }
+ tail_idx -= 1;
+ }
+ head_idx += 1;
+ }
+
+ if active_task_idx >= 0 {
+ db.active_idx = find_similar_task(db, prev_active_task, ignore_times = true);
+ }
+ trigger_autosave();
+
+ case TUI.Keys.Home;
+ select_task(db, 0);
+
+ case TUI.Keys.Up;
+ select_task_by_delta(db, -1);
+
+ case TUI.Keys.PgUp;
+ select_task_by_delta(db, -layout_tasks_rows);
+
+ case TUI.Keys.End;
+ select_task(db, db.tasks.count-1);
+
+ case TUI.Keys.Down;
+ select_task_by_delta(db, 1);
+
+ case TUI.Keys.PgDown;
+ select_task_by_delta(db, layout_tasks_rows);
+ }
+ }
+
+ // Save any unsaved changes.
+ show_processing();
+ if (db == *archive) {
+ while true {
+ if (export_to_csv(archive, ar_file_path) == false) {
+ log_error("Failed to save archive, retry?");
+ draw_error_window();
+ if TUI.get_key() == TUI.Keys.Escape then break;
+ }
+ else break;
+ }
+ }
+ if (countdown_to_autosave > 0 || is_autosave_enabled == false) {
+ while true {
+ if (store_database(database, db_file_path) == false) {
+ log_error("Failed to save database, retry?");
+ draw_error_window();
+ if TUI.get_key() == TUI.Keys.Escape then break;
+ }
+ else break;
+ }
+ }
+
+ assert(TUI.reset_terminal(), "Failed to reset TUI.");
+
+ return;
+}