// 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
DEBUG_MEMORY :: true;
#if DEBUG_MEMORY {
#import "Basic"()(MEMORY_DEBUGGER=true); // TODO Remove after final debug sessions. This takes up ~30MB of RAM.
} else {
#import "Basic";
}
#import "System";
#import "Sort";
#import "Math";
#import "File";
#import "File_Utilities";
#import "String";
#import "Integer_Saturating_Arithmetic";
#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_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;
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;
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_message: string;
error_time_limit := Apollo_Time.{0, 0};
print_error :: (format :string, args : .. Any) {
if TUI.is_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 {
// 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 < 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) -> 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");
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]);
}
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;
}
}
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;
}
dbg_average := 0; // DEBUG
dbg_count := 0; // DEBUG
buffer: String_Builder; // TODO
draw_user_interface :: (db: *Database, layout: *Layout, redraw_all: bool = true) {
auto_release_temp();
/* TODO
It's not safe to use temporary memory here because the console resolution may increase and use more than what we have in temporary memory.
And temporary memory is configured at compile time.
We should dynamically allocate memory with some headroom and, at beggining of function... adjust it if necessary.
// init_string_builder(*buffer, 100000);
// builder := buffer;
*/
builder := String_Builder.{ allocator = temporary_allocator };
TUI.using_builder_as_output(*builder);
// 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(*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.
// TODO Maybe this could be simplified?
y = 1;
x = 1;
TUI.tui_write_string(TUI.Commands.DrawingMode); // append(*builder, TUI.Commands.DrawingMode); TODO
for 0..layout.columns.count-2 {
column := layout.columns[it];
x += 1 + column.width;
TUI.set_cursor_position(x, y);
TUI.tui_write_string(TUI.Drawings.TeeT); // TODO append(*builder, TUI.Drawings.TeeT);
for row: 2..size_y {
TUI.set_cursor_position(x, row);
TUI.tui_write_string(TUI.Drawings.LineV); // TODO append(*builder, TUI.Drawings.LineV);
}
TUI.set_cursor_position(x, size_y);
TUI.tui_write_string(TUI.Drawings.TeeB); // TODO append(*builder, TUI.Drawings.TeeB);
}
TUI.tui_write_string(TUI.Commands.TextMode); // TODO append(*builder, 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);
TUI.tui_write_string(ifx db == *archive then layout.archive_title else col.header); // TODO append(*builder, 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);
TUI.tui_write_string(col.header); // TODO append(*builder, 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);
TUI.tui_write_string(col.header); // TODO append(*builder, 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);
TUI.tui_write_string(task_name); // TODO append(*builder, 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 };
TUI.tui_write_string(paint_remaining); // TODO append(*builder, 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) {
TUI.tui_print(" %/% ", db.selected_idx + 1, db.tasks.count);
}
else {
TUI.tui_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(*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_MEMORY {
defer report_memory_leaks(); // TODO Remove after final debug sessions.
}
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 {
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.
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",
" -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 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], "--start-of-week", "-s") {
it += 1;
if it >= args.count {
print_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;
}
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];
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 {
start := current_time_monotonic(); // DEBUG
draw_user_interface(db, layout, redraw_all);
stop := current_time_monotonic(); // DEBUG
dbg_sample := to_nanoseconds(stop-start); // DEBUG
dbg_average = (dbg_sample + dbg_count * dbg_average) / (dbg_count + 1); // DEBUG
dbg_count += 1; // DEBUG
TUI.set_cursor_position(3, 1);
TUI.tui_print("Average % us (% / % : % bytes) ---------", dbg_average/1000, context.temporary_storage.total_bytes_occupied, context.temporary_storage.high_water_mark, context.temporary_storage.size); // DEBUG
// write_string(builder_to_string(*builder,, allocator = temporary_allocator));
draw_error_window();
TUI.set_cursor_position(40, 1);
TUI.tui_print(">%<", redraw_all);
}
key := TUI.get_key(INPUT_TIMEOUT_MS);
if key == #char "q" || key == #char "Q" break;
redraw_all = key != TUI.Keys.None;
update_times(*database);
if key == #char "k" { dbg_average = 0; dbg_count = 0; redraw_all = false; continue; } // DEBUG
/* TODO
Remove `selected_task` and `active_task` and helper functions.
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.
Maybe use a macro returns the selected/active task based on the indices.
*/
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,, 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;
}
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 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);
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";
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 := 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, 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";
// TODO Active task is lost...
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();
}
assert(TUI.reset_terminal(), "Failed to reset TUI.");
#if DEBUG_MEMORY {
return;
} else {
exit(xx ifx error_saving then 1 else 0);
}
}