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