// Copyright 2023 Daniel Martins // License GPL-3.0-or-later // // This program is free software: you can redistribute it and/or modify it under // the terms of the GNU General Public License as published by the Free Software // Foundation, either version 3 of the License, or (at your option) any later // version. // // This program is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS // FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. // // You should have received a copy of the GNU General Public License along with // this program. If not, see . // Compilation commands: // - release : jai ttt.jai -quiet -x64 -release // - debug : jai ttt.jai -quiet -x64 #import "Basic"()(MEMORY_DEBUGGER=true); // TODO Remove after final debug sessions. This takes up ~30MB of RAM. #import "System"; #import "Sort"; #import "Math"; #import "File"; #import "File_Utilities"; #import "String"; #import "Integer_Saturating_Arithmetic"; #import "UTF8"; TUI :: #import "TUI"(COLOR_MODE=4); VERSION :: "2.0"; // Use only 3 chars (to fit layouts). YEAR :: "2024"; FIRST_DAY_OF_WEEK :: 1; // (0-6, Sunday = 0). NUM_WEEK_DAYS :: 7; // TODO This has to go - Just to be more clear about what we're looping about. NAME_SIZE :: 72; // TODO Use this instead of Task.name.count ? APP_FOLDER_NAME :: ".task_time_tracker_test"; // TODO Using different folder to avoid erasing my work data. 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_NOT_CONTAIN :: "'%' does not contain '%'."; 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; app_directory : string; db_file_path : string; ar_file_path : string; size_x : int; size_y : int; pos_x : int; pos_y : int; style_default := TUI.Style.{ background = TUI.Palette.BLACK, foreground = TUI.Palette.WHITE, }; 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, }; style_active := TUI.Style.{ background = TUI.Palette.BLACK, foreground = TUI.Palette.BLUE, bold = 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, }; Layouts :: enum u8 { NORMAL; COMPACT; } error_message: string; error_time_limit := Apollo_Time.{0, 0}; print_error :: (format :string, args : .. Any) { if TUI.active == false { print(format, ..args, to_standard_error = true); print("\n"); return; } if error_message.data != null { free(error_message.data); } error_message = sprint(format, args); 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); 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_string(error_message); } 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); } // 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 { 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, char: u8 = #char " ") { assert(size >= 0, "Cannot print negative padding values. The procedure accepts signed values just for convenience."); while size > 0 { print_character(char); 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}); 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}); 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 < bool { // TODO Maybe use `using db`. 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 :: (db: *Database, source: s64, target: s64) { // TODO Maybe `using db` assert(db != null, ASSERT_NOT_NULL, "db"); source = clamp(source, 0, db.tasks.count-1); target = clamp(target, 0, db.tasks.count-1); if (source == target) return; // Move task to new location, but first, shift the others to allow some space. temp_task := db.tasks[source]; move_size := abs(target - source); if target > source { for 0..move_size-1 db.tasks[source + it] = db.tasks[source + it + 1]; } else { for < move_size-1..0 db.tasks[target + it + 1] = db.tasks[target + it]; } db.tasks[target] = temp_task; // Adjust active and selected tasks. if (db.active_idx == source) { db.active_idx = target; } else if (source < db.active_idx && db.active_idx <= target) { db.active_idx -= 1; } else if (target <= db.active_idx && db.active_idx < source) { db.active_idx += 1; } db.selected_idx = target; } // Find similar task and return it's index, or -1 if not found. find_similar_task :: (db: *Database, task: Task) -> 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 && 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; if db.active_idx < 0 return; active_task := *db.tasks[db.active_idx]; 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 { < success: bool { assert(xx path, ASSERT_NOT_EMPTY, "path"); // Open file. file, open_success := file_open(path, for_writing = true); // log_errors: bool = true if open_success == false { print_error("Failed to open file '%' while storing database: ERROR_FROM_LOG", path); // TODO Get error from logger ?! return false; } defer file_close(*file); file_write(*file, DB_FILE_SIGN_STR); file_write(*file, *db, size_of(Database)); file_write(*file, db.tasks.data, size_of(Task) * db.tasks.count); return true; } // Loads data from binary file into database. // Returns success. load_database :: (db: *Database, path: string) -> success: bool { assert(db != null, ASSERT_NOT_NULL, "db"); assert(xx path, ASSERT_NOT_EMPTY, "path"); // Open file. file, open_success := file_open(path); // log_errors: bool = true if open_success == false { print_error("Failed to open file '%' while loading database: ERROR_FROM_LOG", path); // TODO Get error from logger ?! return false; } defer file_close(*file); // Validate file signature. file_signature: [DB_FILE_SIGN_STR.count] u8; read_success := file_read(file, *file_signature, DB_FILE_SIGN_STR.count); if read_success == false print_error("Failed to read file signature from '%'.", path); if cast(string)file_signature != DB_FILE_SIGN_STR { print_error("Invalid file signature."); return false; } // Read database structure. read_success = file_read(file, db, size_of(Database)); // TODO Use print_error or assert? if read_success == false { print_error("Failed to read database info from '%'.", path); return false; } assert(read_success == true, "Failed to read database info from '%'.", path); // Reserve database capacity for tasks. 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); assert(bytes == 0, "Unexpected content found at the end of file '%'.", path); return true; } // Exports data into CSV file. // Returns success. export_to_csv :: (db: Database, path: string) -> success: bool { assert(xx path, ASSERT_NOT_EMPTY, "path"); builder: String_Builder; defer reset(*builder); CSV_HEADER :: string.[ "task", "sunday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday" ]; print_to_builder(*builder, "%\n", join(..CSV_HEADER, separator = ",")); buffer: [Task.name.count] u8; name: string = xx buffer; for db.tasks { name.count = c_style_strlen(it.name.data); memcpy(name.data, it.name.data, name.count); replace_chars(name, ",", #char " "); print_to_builder(*builder, "%,%,%,%,%,%,%,%\n", name, it.times[0], it.times[1], it.times[2], it.times[3], it.times[4], it.times[5], it.times[6]); } write_entire_file(path, *builder); return true; } // Imports CSV file into database. // Returns success. import_from_csv :: (db: *Database, path: string) -> bool { // TODO Review code. assert(db != null, ASSERT_NOT_NULL, "db"); assert(xx path, ASSERT_NOT_EMPTY, "path"); error_code: s64; // Check file size TODO Read based on file size //file_info: stat_t; //error_code = sys_stat(path, *file_info); // TODO Check for error. //size := file_info.st_size; size := 0; success: bool; map: Map_File_Info; data: string; is_using_map := false; if size >= 1<<30 { assert(false, "Parsing big files not implemented yet."); } else { data, success = read_entire_file(path); } defer if is_using_map then map_entire_file_end(*map); else free(data.data); csv := data; if success == false { print_error("Failed to read file '%' while loading database: ERROR_FROM_LOG", path); // TODO Get error from logger ?! return false; } // TODO Helper function. advance :: inline (array: *[] $T, amount: int = 1) { assert(amount >= 0); assert(array.count >= amount); array.count -= amount; array.data += amount; } // TODO Helper function. consume_next_line :: (sp: *string) -> string, bool { // To find the end of the line, we look for a linefeed character. // We will trim a carriage return off the end if there is one there also. // Thus this works on both 'dos' and 'unix'-style files. s := << sp; found, result, right := split_from_left(s, 10,, temporary_allocator); if !found { // This is the last line; there may not have been a linefeed after that, // but we still want to handle that data, so we return true if there was // a nonzero amount of stuff there. << sp = ""; return s, (s.count > 0); } // Chop the characters we are going to return from 'sp', // which holds the remaining file data. advance(sp, result.count + 1); if result { if result[result.count-1] == 13 result.count -= 1; // If there's a carriage return at the end, remove it by decrementing the string's length. } return result, true; } //Skip header line. consume_next_line(*csv); next_line :: inline (csv: *string) -> line: string, success: bool { for 0..csv.count { if csv.data[it] == #char "\n" { line: string = < (100<<20) { // print("temp: %\n", context.temporary_storage.total_bytes_occupied >> 20); // reset_temporary_storage(); // } } } // 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 { assert(xx path, ASSERT_NOT_EMPTY, "path"); file, file_success := file_open(path, true, true); defer file_close(*file); if file_success == false { //print_error("Failed to open file '%s' while appending to CSV: %s.", path, strerror(errno)); // TODO Show internal error or something return false; } 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); // TODO Cleanup this temp mess. 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]); file_write(*file, csv_line); return true; } // 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_tui :: () { // Normal layout. layouts[Layouts.NORMAL] = .{ archive_title = " Archive ", columns = .[ .{ header = #run join(" Task Time Tracker v", VERSION, " "), width = -1, alignment = #char "L" }, .{ header = " Sun ", width = 7, alignment = #char "C" }, .{ header = " Mon ", width = 7, alignment = #char "C" }, .{ header = " Tue ", width = 7, alignment = #char "C" }, .{ header = " Wed ", width = 7, alignment = #char "C" }, .{ header = " Thu ", width = 7, alignment = #char "C" }, .{ header = " Fri ", width = 7, alignment = #char "C" }, .{ header = " Sat ", width = 7, alignment = #char "C" }, .{ header = " Total ", width = 9, alignment = #char "C" }, ] }; // Compact layout. layouts[Layouts.COMPACT] = .{ archive_title = " Archive ", columns = .[ .{ header = #run join(" TTT ", VERSION, " "), width = -1, alignment = #char "L" }, .{ header = " S ", width = 5, alignment = #char "C" }, .{ header = " M ", width = 5, alignment = #char "C" }, .{ header = " T ", width = 5, alignment = #char "C" }, .{ header = " W ", width = 5, alignment = #char "C" }, .{ header = " T ", width = 5, alignment = #char "C" }, .{ header = " F ", width = 5, alignment = #char "C" }, .{ header = " S ", width = 5, alignment = #char "C" }, .{ header = " # ", width = 5, alignment = #char "C" }, ] }; // Calculate alignment_offsets. for * layout: layouts { for * col: layout.columns { offset: int; if col.alignment == { case #char "L"; offset = 0; case #char "C"; offset = ((col.width - col.header.count) / 2); case #char "R"; offset = (col.width - col.header.count); } col.alignment_offset = offset; } } TUI.start(); } update_layout :: () { // Calculate number of available rows to display tasks. layout_tasks_rows = (size_y - NUM_HEADER_ROWS - NUM_FOOTER_ROWS); // Calculate first column width: expands to fill the remaining space dynamically. for * layout: layouts { layout.columns[0].width = size_x - (NUM_COLUMNS - 1) - 2; for 1..layout.columns.count-1 { layout.columns[0].width -= layout.columns[it].width; } } } draw_tui :: (db: *Database, layout: *Layout) { adjust_first_day_of_week := int.[ (0 + FIRST_DAY_OF_WEEK) % NUM_WEEK_DAYS, (1 + FIRST_DAY_OF_WEEK) % NUM_WEEK_DAYS, (2 + FIRST_DAY_OF_WEEK) % NUM_WEEK_DAYS, (3 + FIRST_DAY_OF_WEEK) % NUM_WEEK_DAYS, (4 + FIRST_DAY_OF_WEEK) % NUM_WEEK_DAYS, (5 + FIRST_DAY_OF_WEEK) % NUM_WEEK_DAYS, (6 + FIRST_DAY_OF_WEEK) % NUM_WEEK_DAYS, ]; x: int; y: int; col: *Column; // Get context information. active_task := get_active_task(db); selected_task := get_selected_task(db); now_utc := current_time_consensus(); now_week_day := to_calendar(now_utc, .LOCAL).day_of_week_starting_at_0; // Reset theme and clear screen. TUI.clear_terminal(); // Draw outer border. TUI.draw_box(1, 1, size_x, size_y); // Draw table grids. // TODO Maybe this could be simplified? 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 { idx := adjust_first_day_of_week[it]; x += 1; // Apply theme. if (idx == now_week_day && active_task != null) { TUI.set_style(style_active); } else if (idx == now_week_day) { TUI.set_style(style_selected_inverted); } else { TUI.set_style(style_default); } col = *layout.columns[L_DAYS_IDX + 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; y = 1; // Pagination based on currently selected task (show page where selected task is). idx_start := (db.selected_idx / layout_tasks_rows) * layout_tasks_rows; // Display up to rows allowed by the layout, or less if reached end of database. idx_stop := idx_start + (ifx layout_tasks_rows > db.tasks.count - idx_start then db.tasks.count - idx_start else layout_tasks_rows); for task_idx: idx_start..idx_stop-1 { auto_release_temp(); // TODO Temporary memory being trashed?! 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); } // Task title. x += 1; column_width = layout.columns[L_TITLE_IDX].width; // mvprintw(xx y, xx x, "%-*.*s", column_width, column_width, temp_c_string(xx task.name)); //task.name); TODO Fix required for LLVM/Cncurses. TODO DAM // TODO FIXME OH MY GOD SUCH BAD CODEZ TUI.set_cursor_position(x, y); white_spaces := column_width; // FIXME Improve by using buffer instead of printing one char at the time. while white_spaces > 0 { print_character(#char " "); white_spaces -= 1; } TUI.set_cursor_position(x, y); task_name: string = cast(string)task.name; task_name.count = ifx task_name.count > column_width then column_width else task_name.count; print("%", task_name); x += column_width; // Task times. total_time = 0; for 0..NUM_WEEK_DAYS-1 { x += 1; day_idx := (it + FIRST_DAY_OF_WEEK) % NUM_WEEK_DAYS; column_width = layout.columns[L_DAYS_IDX + day_idx].width; task_time := task.times[day_idx]; total_time = add(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); // Reset theme. TUI.clear_style(); } /////////////////////////////////////////////////////////////////////////// // 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 { idx := adjust_first_day_of_week[it]; daily_total := db.total_times[idx]; x += 1; // Apply theme. if (idx == now_week_day && active_task != null) { TUI.set_style(style_active); } else if (idx == now_week_day) { TUI.set_style(style_selected_inverted); } else { TUI.set_style(style_default); } column_width = layout.columns[L_DAYS_IDX + 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); } free_memory :: () { reset_database(*database); reset_database(*archive); free(app_directory); free(db_file_path); free(ar_file_path); //reset_temporary_storage(); } read_input_string :: (x: int, y: int, input_limit: int, padding: int = 0) -> value: string, success: bool { // TODO Draw padding (at end of inputbox)... padding was renamed to input_width... is this the best name? // TODO Maybe add another optional arg with the placeholder text (to be preset on the input)? TUI.set_cursor_position(x + input_limit, y); write_string(TUI.Commands.DrawingMode); for 1..padding { 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); 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 :: () { // -- -- -- Testing TUI -- START perform_test := false; assert_result :: (result: bool, error_message: string) { if result == true { print("- success\n", to_standard_error = true); } else { TUI.stop(); print("- ERROR: %", error_message, to_standard_error = true); exit(1); } } next_line :: inline () { x, y := TUI.get_cursor_position(); TUI.set_cursor_position(1, y+1); } if perform_test && 1 { print("TEST : set and get cursor position\n", to_standard_error = true); TUI.start(); X :: 2; Y :: 3; TUI.set_cursor_position(X, Y); x, y := TUI.get_cursor_position(); TUI.stop(); assert_result(x == X && y == Y, "Failed set/get cursor position.\n"); } if perform_test && 1 { print("TEST : module logger\n", to_standard_error = true); log("- log: before module start."); TUI.start(); TUI.set_cursor_position(3, 3); print("wait"); sleep_milliseconds(1000); log("- log: while module is active."); sleep_milliseconds(1000); print(" a bit"); sleep_milliseconds(1000); #import "Windows"; handle: HANDLE = ---; initial_stdin_mode: u32; if xx GetConsoleMode(handle, *initial_stdin_mode) == false { error_code, error_string := get_error_value_and_string(); log_error("- log: error code %, %", error_code, error_string); } TUI.stop(); log("- log: after module stop."); } if perform_test && 1 { print("TEST : test key input\n", to_standard_error = true); auto_release_temp(); TUI.start(); TUI.clear_terminal(); TUI.set_cursor_position(1, 1); write_string("Press q to exit, other key to print it to screen, wait 1s to see animation."); next_line(); key: TUI.Key; while(key != #char "q") { key = TUI.get_key(1000); if key == TUI.Keys.None { write_string("-"); } else if key == TUI.Keys.Resize { write_string("#"); } else { // else if key >= 32 && key <= 128 then print_character(cast,force(u8)key) write_string(TUI.to_string(key)); } } TUI.stop(); print("- success\n", to_standard_error = true); } if perform_test && 1 { print("TEST : draw box\n", to_standard_error = true); auto_release_temp(); TUI.start(); TUI.flush_input(); TUI.clear_terminal(); TUI.draw_box(1, 2, 5, 3); TUI.set_cursor_position(1, 1); print("Can you see the box below? (y/n)"); key := TUI.get_key(); TUI.stop(); assert_result(key == #char "y", "Failed to draw box.\n"); } if perform_test && 1 { print("TEST : get terminal size\n", to_standard_error = true); auto_release_temp(); TUI.start(); TUI.clear_terminal(); width, height := TUI.get_terminal_size(); TUI.set_cursor_position(1, 1); print("Is terminal size %x%? (y/n)", width, height); key: TUI.Key = xx TUI.Keys.None; while (key == xx TUI.Keys.None || key == xx TUI.Keys.Resize) { key = TUI.get_key(); } TUI.stop(); assert_result(key == #char "y", "Failed to get terminal size.\n"); } if perform_test && 1 { print("TEST : set terminal title\n", to_standard_error = true); TUI.start(); title := "BAZINGA"; TUI.set_terminal_title(title); TUI.set_cursor_position(1, 1); print("Is terminal title '%'? (y/n)", title); key: TUI.Key = xx TUI.Keys.None; while (key == xx TUI.Keys.None || key == xx TUI.Keys.Resize) { key = TUI.get_key(); } TUI.stop(); assert_result(key == #char "y", "Failed to set terminal title.\n"); } if perform_test && 1 { print("TEST : print keys and set terminal title\n", to_standard_error = true); auto_release_temp(); TUI.start(); TUI.set_terminal_title("bazinga"); key: TUI.Key = #char "d"; last_none_char := "X"; width, height := TUI.get_terminal_size(); TUI.clear_terminal(); TUI.draw_box(1, 1, width, height); drop_down := 0; while(key != #char "q") { if key == { case TUI.Keys.None; { TUI.set_cursor_position(2, 2); last_none_char = ifx last_none_char == "X" then "+" else "X"; write_string(last_none_char); } case TUI.Keys.Resize; #through; case #char "c"; { width, height = TUI.get_terminal_size(); TUI.clear_terminal(); TUI.draw_box(1, 1, width, height); drop_down = 0; } case; { TUI.set_cursor_position(2, 3+drop_down); str := TUI.to_string(key); array_to_print: [..] string; for 0..str.count-1 { tmp := tprint("%", FormatInt.{value = cast(u8)str[it], base=16},, temporary_allocator); array_add(*array_to_print, tmp); } string_to_print := join(..array_to_print, separator = " "); print(": % : ", string_to_print); for 0..str.count-1 { if str[it] == #char "\e" { str[it] = #char "#"; } } write_string(str); write_string(" :"); drop_down += 1; } } x := ifx width > 24 then width-24 else 1; y := ifx height > 1 then height-1 else 1; TUI.set_cursor_position(x, y); print("size = %x%\n", width, height); key = TUI.get_key(1000); // __mark := get_temporary_storage_mark(); // set_temporary_storage_mark(__mark); } print("- success"); TUI.stop(); } if perform_test && 1 { print("TEST : user input\n", to_standard_error = true); auto_release_temp(); TUI.start(); TUI.clear_terminal(); TUI.set_cursor_position(1, 1); print("Enter some text (use Enter to finish, Esc to cancel, or resize to abort):"); next_line(); str, key := TUI.read_input_line(15); TUI.set_cursor_position(1, 3); error_message: string; if key == { case TUI.Keys.Escape; { print("Have you pressed Esc? (y/n)"); error_message = "Failed to read line on Esc."; } case TUI.Keys.Resize; { print("Have you resized the terminal? (y/n)"); error_message = "Failed to read line on resize."; } case; { print("Have you entered '%'? (y/n)", str); error_message = "Failed to read line."; } } answer := TUI.get_key(); TUI.stop(); assert_result(answer == #char "y", error_message); } if perform_test && 1 { print("TEST : hidden user input\n", to_standard_error = true); auto_release_temp(); TUI.start(); TUI.clear_terminal(); TUI.set_cursor_position(1, 1); print("Enter some secret (use Enter to finish, Esc to cancel, or resize to abort):"); next_line(); str, key := TUI.read_input_line(15, false); TUI.set_cursor_position(1, 3); error_message: string; if key == { case TUI.Keys.Escape; { print("Have you pressed Esc? (y/n)"); error_message = "Failed to read line on Esc."; } case TUI.Keys.Resize; { print("Have you resized the terminal? (y/n)"); error_message = "Failed to read line on resize."; } case; { print("Have you entered '%'? (y/n)", str); error_message = "Failed to read line."; } } answer := TUI.get_key(); TUI.stop(); assert_result(answer == #char "y", error_message); } // -- -- -- Testing TUI -- STOP defer report_memory_leaks(); // TODO Remove after final debug sessions. defer free_memory(); { // Initialize app directory. auto_release_temp(); 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 { print_error("Failed to find home directory '%'.", home_dir); exit(1); } app_directory = join(home_path, "/", APP_FOLDER_NAME); db_file_path = join(app_directory, "/", DB_FILE_NAME); ar_file_path = join(app_directory, "/", AR_FILE_NAME); // TODO app data should be stored under: // Windows: APPDATA (~/AppData/Roaming) // Unix: XDG_DATA_HOME (~/.local/share) make_directory_if_it_does_not_exist(app_directory, recursive = true); } { // Initialize database and archive files if needed. auto_release_temp(); if (file_exists(db_file_path) == false) { if (store_database(database, db_file_path) == false) { print_error("Failed to initialize database."); exit(1); } } if (file_exists(ar_file_path) == false) { if (export_to_csv(archive, ar_file_path) == false) { print_error("Failed to initialize archive."); exit(1); } } } args := get_command_line_arguments(); defer array_reset(*args); if args.count > 1 { is_exit_requested := false; for 1..args.count-1 { if is_equal_to_any(args[it], "--help", "-h") { write_strings( "Usage: ttt [OPTION]... [FILE]...\n", " -i, --import-csv [FILE] Import CSV file to database (discard first row).\n", " -e, --export-csv [FILE] Export database to CSV file.\n", " -n, --no-autosave Disable autosave feature (only save on exit).\n", " -h, --help Display this help and exit.\n", " -v, --version Output version information and exit.\n", "\n", "In app commands\n", " w, W Archive a duplicate and reset times for 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 { print_error("Missing CSV file path to import."); exit(1); } if (load_database(*database, db_file_path) == false) { print_error("Failed to load database."); exit(1); } if (import_from_csv(*database, args[it]) == false) { print_error("Failed to import CSV file."); exit(1); } if (store_database(*database, db_file_path) == false) { print_error("Failed to store database."); exit(1); } reset_database(*database); is_exit_requested = true; continue; } if is_equal_to_any(args[it], "--export-csv", "-e") { it += 1; if it >= args.count { print_error("Missing CSV file path to export."); exit(1); } if (load_database(*database, db_file_path) == false) { print_error("Failed to load database."); exit(1); } if (export_to_csv(*database, args[it]) == false) { print_error("Failed to export CSV file."); exit(1); } reset_database(*database); is_exit_requested = true; continue; } if is_equal_to_any(args[it], "--no-autosave", "-n") { is_autosave_enabled = false; continue; } print_error("%: invalid option '%'.\nTry '% --help' for more information.", args[0], args[it], args[0]); exit(1); } if is_exit_requested { exit(0); } } if (load_database(*database, db_file_path) == false) { print_error("Failed to load database."); exit(1); } initialize_tui(); db := *database; layout := *layouts[Layouts.COMPACT]; action_style: TUI.Style; TUI.flush_input(); TUI.set_next_key(TUI.Keys.Resize); while (true) { 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_tui(db, layout); draw_error_window(); } reset_temporary_storage(); key := TUI.get_key(INPUT_TIMEOUT_MS); if key == #char "q" || key == #char "Q" break; update_times(*database); //timeout(INPUT_AWAIT_INF); TODO DAM // TODO WIP Remove `selected_task` and `active_task` and helper functions. // TODO Every time we add or remove tasks to the database, it may be reallocated, thus making the selected_task and active_task pointers invalid. Check if this is messing up the app. selected_task := get_selected_task(db); active_task := get_active_task(db); selected_task_row: int; { // TODO Recheck this code. 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 1 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) { export_to_csv(*archive, ar_file_path); } store_database(database, db_file_path); } } // 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); if is_empty_string(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"); // TODO DAM this cast... 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); // TODO Temp stringzes. // Abort if input if empty. if is_empty_string(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 := (selected_day + FIRST_DAY_OF_WEEK) % NUM_WEEK_DAYS; 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; } if (add_task(db, selected_task) == null) { print_error("Failed to duplicate task."); continue; } 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/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); print_error("Failed to load archive."); continue; } db = *archive; } else { if (export_to_csv(*archive, ar_file_path) == false) { print_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) { print_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; } if (add_task(*database, selected_task) == null) { print_error("Failed to restore task."); continue; } delete_task(db, db.selected_idx); trigger_autosave(); // Sort by. case #char "s"; #through; case #char "S"; // TODO The initial part should only decide what's the sorting procedure... then we would would all in a single place. 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; 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 := sort_by - #char "1"; day := (sort_by_idx + FIRST_DAY_OF_WEEK) % NUM_WEEK_DAYS; 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, 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(); for db.tasks { if (append_to_csv(it, ar_file_path) == false) { print_error("Failed to archive task."); // TODO Improve this. } reset_task_times(db, it_index); } trigger_autosave(); // 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(); 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; } 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(); error_saving := false; if (db == *archive) { if (export_to_csv(archive, ar_file_path) == false) { print_error("Failed to save archive."); error_saving |= true; } } if (countdown_to_autosave > 0 || is_autosave_enabled == false) { if (store_database(database, db_file_path) == false) { print_error("Failed to save database."); error_saving |= true; } } if (error_saving) { print_error("Press any key to close."); draw_error_window(); TUI.get_key(); } TUI.stop(); exit(xx ifx error_saving then 1 else 0); }