diff options
| author | dam <dam@gudinoff> | 2024-05-29 12:50:48 +0100 |
|---|---|---|
| committer | dam <dam@gudinoff> | 2024-05-29 12:50:48 +0100 |
| commit | 986c0ca11d45e83e97479fcfad5facd1e56b0beb (patch) | |
| tree | 8576d455c6748a38e81787b308fb8cbbe1ad7b89 /ttt.jai | |
| parent | 393e5a926cd105c4a2f902824a233cc41af91198 (diff) | |
| parent | ec706533ca26d49670adb97617df0d565528e395 (diff) | |
| download | task-time-tracker-986c0ca11d45e83e97479fcfad5facd1e56b0beb.tar.zst task-time-tracker-986c0ca11d45e83e97479fcfad5facd1e56b0beb.zip | |
Merge with jai-prototypev2.0
Diffstat (limited to 'ttt.jai')
| -rw-r--r-- | ttt.jai | 1806 |
1 files changed, 1806 insertions, 0 deletions
@@ -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; +} |
