// Copyright 2023 Daniel Martins
// License GPL-3.0-or-later
//
// This program is free software: you can redistribute it and/or modify it under
// the terms of the GNU General Public License as published by the Free Software
// Foundation, either version 3 of the License, or (at your option) any later
// version.
//
// This program is distributed in the hope that it will be useful, but WITHOUT
// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
// FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// this program. If not, see .
// Compilation commands:
// - release : jai ttt.jai -quiet -x64 -release
// - debug : jai ttt.jai -quiet -x64
#import "Basic"()(MEMORY_DEBUGGER=true); // TODO Remove after final debug sessions. This takes up ~30MB of RAM.
#import "System";
#import "Sort";
#import "Math";
#import "File";
#import "File_Utilities";
#import "String";
#import "Integer_Saturating_Arithmetic";
TUI :: #import "TUI";
// TODO List:
// [ ] 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.
stdscr: *void; // TODO DAM
A_BOLD: s32 = 0; // TODO DAM
COLOR_PAIR :: (a: s32) -> s32 { return 0; } // TODO DAM
KEY_RESIZE :: 410; // TODO DAM
KEY_F2 :: 266; // TODO DAM
KEY_F5 :: 269; // TODO DAM
KEY_HOME :: 262; // TODO DAM
KEY_UP :: 259; // TODO DAM
KEY_DOWN :: 258; // TODO DAM
KEY_NPAGE :: 338; // TODO DAM
KEY_PPAGE :: 339; // TODO DAM
KEY_END :: 360; // TODO DAM
KEY_BACKSPACE :: 263; // TODO DAM
KEY_DC :: 330; // TODO DAM
WINDOW :: struct { } // TODO DAM
VERSION :: "2.0"; // Use only 3 chars (to fit layouts).
YEAR :: "2024";
FIRST_DAY_OF_WEEK :: 1; // (0-6, Sunday = 0).
NUM_WEEK_DAYS :: 7; // TODO This has to go - Just to be more clear about what we're looping about.
NAME_SIZE :: 72; // TODO Use this instead of Task.name.count ?
APP_FOLDER_NAME :: ".task_time_tracker_test"; // TODO Using different folder to avoid erasing my work data.
DB_FILE_NAME :: "database.bin";
AR_FILE_NAME :: "archive.csv";
DB_FILE_SIGN_STR :: "TTT:B:02";
ASSERT_NOT_NULL :: "Parameter '%' is null.";
ASSERT_NOT_EMPTY :: "Parameter '%' is empty.";
ASSERT_NOT_CONTAIN :: "'%' does not contain '%'.";
ASSERT_INVALID_INDEX:: "Invalid index '%'.";
SECONDS_IN_MINUTE :: cast(s64)60;
SECONDS_IN_HOUR :: cast(s64)60*SECONDS_IN_MINUTE;
SECONDS_IN_DAY :: cast(s64)24*SECONDS_IN_HOUR;
SECONDS_IN_YEAR :: cast(s64)365*SECONDS_IN_DAY;
MAX_DATABASE_TASKS :: S64_MAX;
Task :: struct {
times : [NUM_WEEK_DAYS] s64;
name : [72] u8;
}
Database :: struct {
modified_on : Apollo_Time;
active_idx : s64 = -1;
selected_idx : s64 = -1;
total_times : [NUM_WEEK_DAYS] s64;
tasks : [..] Task;
}
database : Database;
archive : Database;
is_autosave_enabled := true;
countdown_to_autosave := -1;
app_directory : string;
db_file_path : string;
ar_file_path : string;
size_x : 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) {
if stdscr == null { // || isendwin() == true { // Not in ncurses mode? TODO DAM
print(format, ..args);
print("\n");
}
else {
CHAR_SPACE :: #char " ";
w_size_x: s32 = ifx size_x > 120 then 120 else size_x - 2;
w_size_y: s32 = 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); TODO DAM
//wattron(error_window, COLOR_PAIR(xx Styles.ERROR)); TODO DAM
//wborder(error_window, CHAR_SPACE, CHAR_SPACE, 0, 0, ACS_HLINE, ACS_HLINE, ACS_HLINE, ACS_HLINE); TODO DAM
//mvwprintw(error_window, 0, 1, " Error "); TODO DAM
//wmove(error_window, 1, 0); TODO DAM
}
else {
//waddch(error_window, CHAR_SPACE); TODO DAM
}
//wprintw(error_window, temp_c_string(tprint(format, args))); TODO DAM
error_time_limit = current_time_monotonic() + seconds_to_apollo(5);
}
}
draw_error_window :: () {
if (error_window == null) return;
// Hide error window after time-limit or if terminal is shrank.
w_size_x: s32;
w_size_y: s32;
//getmaxyx(error_window, *w_size_y, *w_size_x); TODO DAM
if (current_time_monotonic() >= error_time_limit
|| size_x - w_size_x < 2
|| size_y - w_size_y < 2
) {
//delwin(error_window); TODO DAM
error_window = null;
return;
}
// Adjust error window position.
pos_x := (size_x - w_size_x) / 2;
pos_y := (size_y - w_size_y) / 2;
//mvwin(error_window, pos_y, pos_x); TODO DAM
// Avoid being overwritten by main window content.
//refresh(); TODO DAM
//touchwin(error_window); TODO DAM
//wrefresh(error_window); TODO DAM
}
trigger_autosave :: () {
countdown_to_autosave = 13375; // ms
}
show_processing :: () {
TUI.set_cursor_position(1, 1);
// TODO Maybe add some color?
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;
}
Text_Encoding :: enum u8 #specified {
ASCII :: 1;
UTF8 :: 2;
}
// Truncates the string to the length provided or shorter, in case of UTF8 strings that require so.
// Truncation is done by zeroing the tail of the string in place.
// Returns length of truncated string.
truncate_string :: (str: string, length: s64, $encoding: Text_Encoding = .UTF8) -> length: s64 {
assert(str.data != null, ASSERT_NOT_NULL, "str");
assert(str.count >= length, "'str.count' should be equal or greater to '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 start byte, ence '+ 1'.
}
}
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;
}
// Prints, on row y and column x, the time using 5 characters centered on space.
// Returns the result of a call to mvprintw.
print_time :: (y: int, x: int, time: s64, space: int) -> int {
TIME_CHARS :: 5;
assert(space >= TIME_CHARS);
mul_f64_s64 :: inline (a: float64, b: s64) -> s64 {
return cast(s64)(a * cast(float64)b);
}
left_padding := (space - TIME_CHARS) / 2;
right_padding := space - TIME_CHARS - left_padding;
print_padding :: (size: int, char: u8 = #char " ") {
assert(size >= 0, "Cannot print negative padding values. The procedure accepts signed values just for convenience.");
while size > 0 {
print_character(char);
size -= 1;
}
}
TUI.set_cursor_position(y, x);
if time < 0 {
print_padding(left_padding);
write_string(" - ");
print_padding(right_padding);
return 0;
}
else if time == 0 {
print_padding(left_padding);
write_string(" 0 ");
print_padding(right_padding);
return 0;
}
else if time < SECONDS_IN_MINUTE {
print_padding(left_padding);
print("%s ", FormatInt.{value = time, minimum_digits=3, padding=#char " "});
print_padding(right_padding);
return 0;
}
else if time < #run mul_f64_s64(100, SECONDS_IN_HOUR) {
hours := time / SECONDS_IN_HOUR;
minutes := (time - (hours * SECONDS_IN_HOUR) ) / SECONDS_IN_MINUTE;
print_padding(left_padding);
print("%:%", FormatInt.{value = hours, minimum_digits=2}, FormatInt.{value = minutes, minimum_digits=2});
print_padding(right_padding);
return 0;
}
else if time < #run mul_f64_s64(9999.5, SECONDS_IN_DAY) {
value := cast(float64) time / SECONDS_IN_DAY;
decimals :=
ifx time >= #run mul_f64_s64(99.95, SECONDS_IN_DAY) then 0 else
ifx time >= #run mul_f64_s64(9.995, SECONDS_IN_DAY) then 1 else
2;
print_padding(left_padding);
print("%d", FormatFloat.{value = value, trailing_width=decimals, width=4});
print_padding(right_padding);
return 0;
}
else if time < #run mul_f64_s64(9999.5, SECONDS_IN_YEAR) {
value := cast(float64) time / SECONDS_IN_YEAR;
decimals :=
ifx time >= #run mul_f64_s64(99.95, SECONDS_IN_YEAR) then 0 else
ifx time >= #run mul_f64_s64(9.995, SECONDS_IN_YEAR) then 1 else
2;
print_padding(left_padding);
print("%y", FormatFloat.{value = value, trailing_width=decimals, width=4});
print_padding(right_padding);
return 0;
}
else {
print_padding(left_padding);
write_string(" ∞ ");
print_padding(right_padding);
return 0;
}
}
// Returns active task or NULL if none applies.
get_active_task :: inline (db: Database) -> *Task {
return ifx db.active_idx >= 0 then *db.tasks[db.active_idx] else null;
}
// Returns selected task or NULL if none applies.
get_selected_task :: inline (db: Database) -> *Task {
return ifx db.selected_idx >= 0 then *db.tasks[db.selected_idx] else null;
}
is_valid_index :: inline(db: Database, index: s64) -> bool { return index >= 0 && index < db.tasks.count; }
// Adds a task to the database and returns it.
// If necessary, expands database capacity.
add_task :: (db: *Database, task: *Task = null) -> task: *Task, index: s64 {
assert(db != null, ASSERT_NOT_NULL, "db");
// If the task belongs to this database, calling array_add might invalidate the pointer
// because the memory may be reallocated, thus we always use a copy of the task.
new_task := ifx task == null then .{} else < bool { // TODO Maybe use `using db`.
assert(db != null, ASSERT_NOT_NULL, "db");
assert(is_valid_index(db, index), ASSERT_INVALID_INDEX, index);
// Remove task timer values from total timers.
for tasks[index].times {
total_times[it_index] = sub(total_times[it_index], it);
}
// Move tasks after the index position to their new positions.
for index..tasks.count-2
tasks[it] = tasks[it+1];
tasks.count -= 1;
// Adjust selected task.
if (selected_idx >= tasks.count) {
selected_idx -= 1;
}
// Adjust active task.
if (active_idx > index) {
active_idx -= 1;
}
else if (active_idx == index) {
active_idx = -1;
}
// Try to shrink database capacity if using more than 2MB.
size_of_task := size_of(Task);
if (tasks.allocated >> 2) > tasks.count && tasks.allocated * size_of_task > 2_000_000 {
new_capacity := tasks.allocated >> 1;
new_tasks_data := realloc(tasks.data, new_capacity * size_of_task, tasks.allocated * size_of_task,, tasks.allocator);
if new_tasks_data != null {
tasks.data = new_tasks_data;
tasks.allocated = new_capacity;
}
}
return true;
}
// Moves task from source to target.
// Source and target get clamped to database size.
move_task :: (db: *Database, source: s64, target: s64) { // TODO Maybe `using db`
assert(db != null, ASSERT_NOT_NULL, "db");
source = clamp(source, 0, db.tasks.count-1);
target = clamp(target, 0, db.tasks.count-1);
if (source == target) return;
// Move task to new location, but first, shift the others to allow some space.
temp_task := db.tasks[source];
move_size := abs(target - source);
if target > source {
for 0..move_size-1
db.tasks[source + it] = db.tasks[source + it + 1];
}
else {
for < move_size-1..0
db.tasks[target + it + 1] = db.tasks[target + it];
}
db.tasks[target] = temp_task;
// Adjust active and selected tasks.
if (db.active_idx == source) {
db.active_idx = target;
}
else if (source < db.active_idx && db.active_idx <= target) {
db.active_idx -= 1;
}
else if (target <= db.active_idx && db.active_idx < source) {
db.active_idx += 1;
}
db.selected_idx = target;
}
// Find similar task and return it's index, or -1 if not found.
find_similar_task :: (db: *Database, task: Task) -> idx: s64 {
compare_array :: (a: [] $T, b: [] T) -> int {
for 0..min(a.count, b.count)-1 {
if a[it] > b[it] return 1;
if a[it] < b[it] return -1;
}
if a.count > b.count return 1;
if a.count < b.count return -1;
return 0;
}
for db.tasks {
if compare(xx task.name, xx it.name) == 0 && compare_array(task.times, it.times) == 0 {
return it_index;
}
}
return -1;
}
// Updates the times on the active task (and adjusts database totals).
update_times :: (db: *Database) {
assert(db != null, ASSERT_NOT_NULL, "db");
// Get time frame in UTC.
start_time := db.modified_on;
stop_time := seconds_to_apollo(to_seconds(current_time_consensus())); // HACK Discard sub-seconds information because Task.times only store seconds. To other workaround would be to use Task.times as Apollo_Time instead of s64 seconds.
// Keep track of this update.
db.modified_on = stop_time;
if db.active_idx < 0 return;
active_task := *db.tasks[db.active_idx];
start_week_day: s8;
while (start_time < stop_time) {
start_week_day = to_calendar(start_time, .LOCAL).day_of_week_starting_at_0;
// Get next day in local time.
start_of_day_cal := to_calendar(start_time, .LOCAL);
start_of_day_cal.hour = 0;
start_of_day_cal.minute = 0;
start_of_day_cal.second = 0;
start_of_day_cal.millisecond = 0;
start_of_day := calendar_to_apollo(start_of_day_cal);
next_day := start_of_day + #run seconds_to_apollo(SECONDS_IN_DAY);
next_start := ifx next_day < stop_time then next_day else stop_time;
elapsed_time := to_seconds(next_start - start_time);
active_task.times[start_week_day] += elapsed_time;
db.total_times[start_week_day] += elapsed_time;
start_time = next_start;
}
}
// Recalculates database totals.
update_total_times :: (db: *Database) {
assert(db != null, ASSERT_NOT_NULL, "db");
for *total: db.total_times { < success: bool {
assert(xx path, ASSERT_NOT_EMPTY, "path");
// Open file.
file, open_success := file_open(path, for_writing = true); // log_errors: bool = true
if open_success == false {
print_error("Failed to open file '%' while storing database: ERROR_FROM_LOG", path); // TODO Get error from logger ?!
return false;
}
defer file_close(*file);
file_write(*file, DB_FILE_SIGN_STR);
file_write(*file, *db, size_of(Database));
file_write(*file, db.tasks.data, size_of(Task) * db.tasks.count);
return true;
}
// Loads data from binary file into database.
// Returns success.
load_database :: (db: *Database, path: string) -> success: bool {
assert(db != null, ASSERT_NOT_NULL, "db");
assert(xx path, ASSERT_NOT_EMPTY, "path");
// Open file.
file, open_success := file_open(path); // log_errors: bool = true
if open_success == false {
print_error("Failed to open file '%' while loading database: ERROR_FROM_LOG", path); // TODO Get error from logger ?!
return false;
}
defer file_close(*file);
// Validate file signature.
file_signature: [DB_FILE_SIGN_STR.count] u8;
read_success := file_read(file, *file_signature, DB_FILE_SIGN_STR.count);
if read_success == false print_error("Failed to read file signature from '%'.", path);
if cast(string)file_signature != DB_FILE_SIGN_STR {
print_error("Invalid file signature.");
return false;
}
// Read database structure.
read_success = file_read(file, db, size_of(Database));
// TODO Use print_error or assert?
if read_success == false {
print_error("Failed to read database info from '%'.", path);
return false;
}
assert(read_success == true, "Failed to read database info from '%'.", path);
// Reserve database capacity for tasks.
tasks_count := db.tasks.count;
Initialize(*db.tasks); // Cleanup whatever was read from file.
array_reserve(*db.tasks, tasks_count);
// Read database tasks.
file_read(file, db.tasks.data, size_of(Task) * tasks_count);
db.tasks.count = tasks_count;
// Make sure we are reading all the file.
buffer: u8;
success, bytes := file_read(file, *buffer, 1);
assert(bytes == 0, "Unexpected content found at the end of file '%'.", path);
return true;
}
// Exports data into CSV file.
// Returns success.
export_to_csv :: (db: Database, path: string) -> success: bool {
assert(xx path, ASSERT_NOT_EMPTY, "path");
builder: String_Builder;
defer reset(*builder);
CSV_HEADER :: string.[ "task", "sunday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday" ];
print_to_builder(*builder, "%\n", join(..CSV_HEADER, separator = ","));
buffer: [Task.name.count] u8;
name: string = xx buffer;
for db.tasks {
name.count = c_style_strlen(it.name.data);
memcpy(name.data, it.name.data, name.count);
replace_chars(name, ",", #char " ");
print_to_builder(*builder, "%,%,%,%,%,%,%,%\n",
name, it.times[0], it.times[1], it.times[2], it.times[3], it.times[4], it.times[5], it.times[6]);
}
write_entire_file(path, *builder);
return true;
}
// Imports CSV file into database.
// Returns success.
import_from_csv :: (db: *Database, path: string) -> bool {
// TODO Review code.
assert(db != null, ASSERT_NOT_NULL, "db");
assert(xx path, ASSERT_NOT_EMPTY, "path");
error_code: s64;
// Check file size TODO Read based on file size
//file_info: stat_t;
//error_code = sys_stat(path, *file_info); // TODO Check for error.
//size := file_info.st_size;
size := 0;
success: bool;
map: Map_File_Info;
data: string;
is_using_map := false;
if size >= 1<<30 {
assert(false, "Parsing big files not implemented yet.");
}
else {
data, success = read_entire_file(path);
}
defer if is_using_map then map_entire_file_end(*map); else free(data.data);
csv := data;
if success == false {
print_error("Failed to read file '%' while loading database: ERROR_FROM_LOG", path); // TODO Get error from logger ?!
return false;
}
// TODO Helper function.
advance :: inline (array: *[] $T, amount: int = 1) {
assert(amount >= 0);
assert(array.count >= amount);
array.count -= amount;
array.data += amount;
}
// TODO Helper function.
consume_next_line :: (sp: *string) -> string, bool {
// To find the end of the line, we look for a linefeed character.
// We will trim a carriage return off the end if there is one there also.
// Thus this works on both 'dos' and 'unix'-style files.
s := << sp;
found, result, right := split_from_left(s, 10,, temporary_allocator);
if !found {
// This is the last line; there may not have been a linefeed after that,
// but we still want to handle that data, so we return true if there was
// a nonzero amount of stuff there.
<< sp = "";
return s, (s.count > 0);
}
// Chop the characters we are going to return from 'sp',
// which holds the remaining file data.
advance(sp, result.count + 1);
if result {
if result[result.count-1] == 13 result.count -= 1; // If there's a carriage return at the end, remove it by decrementing the string's length.
}
return result, true;
}
//Skip header line.
consume_next_line(*csv);
next_line :: inline (csv: *string) -> line: string, success: bool {
for 0..csv.count {
if csv.data[it] == #char "\n" {
line: string = < (100<<20) {
// print("temp: %\n", context.temporary_storage.total_bytes_occupied >> 20);
// reset_temporary_storage();
// }
}
}
// Adjust selected task.
if (db.selected_idx < 0 && db.tasks.count > 0) db.selected_idx = 0;
return true;
}
// Appends task to the end of the CSV file.
// Returns success.
append_to_csv :: (task: Task, path: string) -> success: bool {
assert(xx path, ASSERT_NOT_EMPTY, "path");
file, file_success := file_open(path, true, true);
defer file_close(*file);
if file_success == false {
//print_error("Failed to open file '%s' while appending to CSV: %s.", path, strerror(errno)); // TODO Show internal error or something
return false;
}
file_size := file_length(file);
file_set_position(file, file_size-1);
last_char: u8;
file_read(file, *last_char, 1);
if (last_char != #char "\n") {
file_write(*file, "\n");
}
task_name := copy_temporary_string(xx task.name); // TODO Cleanup this temp mess.
replace_chars(task_name, ",", #char " ");
csv_line := tprint("%,%,%,%,%,%,%,%\n",
task_name, task.times[0], task.times[1], task.times[2], task.times[3], task.times[4], task.times[5], task.times[6]);
file_write(*file, csv_line);
return true;
}
// Selects task by index.
// Index gets clamped to [0, db->count[.
select_task :: (db: *Database, index: s64) {
assert(db != null, ASSERT_NOT_NULL, "db");
db.selected_idx = ifx db.tasks.count == 0 then -1 else clamp(index, 0, db.tasks.count-1);
}
// Selects task by delta relative to currently selected task.
select_task_by_delta :: (db: *Database, delta: s64) {
assert(db != null, ASSERT_NOT_NULL, "db");
idx :=
ifx (delta > 0 && db.selected_idx > S64_MAX - delta) then S64_MAX else
ifx (delta < 0 && db.selected_idx < S64_MIN - delta) then S64_MIN else
db.selected_idx + delta;
select_task(db, idx);
}
// Set active task.
// Passing -1 de-activates any previously active task.
set_active_task :: (db: *Database, index: s64) {
assert(db != null, ASSERT_NOT_NULL, "db");
assert(index == -1 || is_valid_index(db, index), ASSERT_INVALID_INDEX, index);
update_times(db);
db.active_idx = index;
}
// Returns true when database is full.
is_database_full :: inline (db: Database) -> bool {
return db.tasks.count >= MAX_DATABASE_TASKS;
}
INPUT_TIMEOUT_MS :: 1000;
INPUT_AWAIT_INF :: -1;
NUM_HEADER_ROWS :: 1;
NUM_FOOTER_ROWS :: 1;
NUM_COLUMNS :: 9;
L_TITLE_IDX :: 0;
L_DAYS_IDX :: 1;
L_TOTAL_IDX :: 8;
Column :: struct {
header : string;
width : int;
alignment_offset : int;
alignment : u8;
}
Layout :: struct {
columns : [NUM_COLUMNS] Column;
archive_title : string;
}
layouts : [#run type_info(Layouts).values.count] Layout;
layout_tasks_rows : int;
is_terminal_too_small := true;
initialize_tui :: () {
// Normal layout.
layouts[Layouts.NORMAL] = .{
archive_title = " Archive ",
columns = .[
.{ header = #run join(" Task Time Tracker v", VERSION, " "), width = -1, alignment = #char "L" },
.{ header = " Sun ", width = 7, alignment = #char "C" },
.{ header = " Mon ", width = 7, alignment = #char "C" },
.{ header = " Tue ", width = 7, alignment = #char "C" },
.{ header = " Wed ", width = 7, alignment = #char "C" },
.{ header = " Thu ", width = 7, alignment = #char "C" },
.{ header = " Fri ", width = 7, alignment = #char "C" },
.{ header = " Sat ", width = 7, alignment = #char "C" },
.{ header = " Total ", width = 9, alignment = #char "C" },
]
};
// Compact layout.
layouts[Layouts.COMPACT] = .{
archive_title = " Archive ",
columns = .[
.{ header = #run join(" TTT ", VERSION, " "), width = -1, alignment = #char "L" },
.{ header = " S ", width = 5, alignment = #char "C" },
.{ header = " M ", width = 5, alignment = #char "C" },
.{ header = " T ", width = 5, alignment = #char "C" },
.{ header = " W ", width = 5, alignment = #char "C" },
.{ header = " T ", width = 5, alignment = #char "C" },
.{ header = " F ", width = 5, alignment = #char "C" },
.{ header = " S ", width = 5, alignment = #char "C" },
.{ header = " # ", width = 5, alignment = #char "C" },
]
};
// Calculate alignment_offsets.
for * layout: layouts {
for * col: layout.columns {
offset: int;
if col.alignment == {
case #char "L";
offset = 0;
case #char "C";
offset = ((col.width - col.header.count) / 2);
case #char "R";
offset = (col.width - col.header.count);
}
col.alignment_offset = offset;
}
}
// TODO DAM
TUI.start();
// stdscr = initscr(); // Start curses mode. TODO DAM
// cbreak(); // Line buffering disabled; pass on everty thing to me. TODO DAM
// keypad(stdscr, true); // I need those nifty F1..F12. TODO DAM
// curs_set(0); // Set cursor invisible. TODO DAM
// noecho(); // Disable echoing input characters. TODO DAM
// Initialize pairs of colors.
//start_color(); TODO DAM
//use_default_colors(); // Using default (-1) instead of COLOR_BLACK. TODO DAM
//init_pair(xx Styles.SELECTED, COLOR_BLACK, COLOR_CYAN); TODO DAM
//init_pair(xx Styles.SELECTED_INVERTED, COLOR_CYAN, -1); TODO DAM
//init_pair(xx Styles.ACTIVE, COLOR_BLUE, -1); TODO DAM
//init_pair(xx Styles.ACTIVE_SELECTED, COLOR_WHITE, COLOR_BLUE); TODO DAM
//init_pair(xx Styles.ERROR, COLOR_RED, -1); TODO DAM
}
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); TODO DAM
TUI.clear_terminal();
// Draw outer border.
TUI.draw_box(1, 1, size_x, size_y);
// Draw table grids.
// TODO Maybe this could be simplified?
y = 1;
x = 1;
write_string(TUI.Commands.DrawingMode);
for 0..layout.columns.count-2 {
column := layout.columns[it];
x += 1 + column.width;
TUI.set_cursor_position(y, x);
write_string(TUI.Drawings.TeeT);
for row: 2..size_y {
TUI.set_cursor_position(row, x);
write_string(TUI.Drawings.LineV);
}
TUI.set_cursor_position(size_y, x);
write_string(TUI.Drawings.TeeB);
}
write_string(TUI.Commands.TextMode);
///////////////////////////////////////////////////////////////////////////
// Draw headers.
y = 1;
x = 1;
// Headers : title
x += 1;
col = *layout.columns[L_TITLE_IDX];
//mvaddstr(xx y, xx (x + col.alignment_offset), ifx db == *archive then layout.archive_title.data else col.header.data); TODO DAM
TUI.set_cursor_position(y, x + col.alignment_offset);
write_string(ifx db == *archive then layout.archive_title else col.header);
x += col.width;
// Headers : days
for 0..NUM_WEEK_DAYS-1 {
idx := adjust_first_day_of_week[it];
x += 1;
// Apply theme.
if (idx == now_week_day && active_task != null) {
// attron(COLOR_PAIR(xx Styles.ACTIVE) | A_BOLD); TODO DAM
}
else if (idx == now_week_day) {
// attron(COLOR_PAIR(xx Styles.SELECTED_INVERTED) | A_BOLD); TODO DAM
}
col = *layout.columns[L_DAYS_IDX + idx];
//mvaddstr(xx y, xx (x + col.alignment_offset), col.header.data); TODO DAM
TUI.set_cursor_position(y, x + col.alignment_offset);
write_string(col.header);
x += col.width;
// Reset theme.
//attrset(A_NORMAL); TODO DAM
}
// Headers : total
x += 1;
col = *layout.columns[L_TOTAL_IDX];
//mvaddstr(xx y, xx (x + col.alignment_offset), col.header.data); TODO DAM
TUI.set_cursor_position(y, x + col.alignment_offset);
write_string(col.header);
///////////////////////////////////////////////////////////////////////////
// Draw tasks.
total_time := 0;
column_width: int;
y = 1;
// Pagination based on currently selected task (show page where selected task is).
idx_start := (db.selected_idx / layout_tasks_rows) * layout_tasks_rows;
// Display up to rows allowed by the layout, or less if reached end of database.
idx_stop := idx_start + (ifx layout_tasks_rows > db.tasks.count - idx_start then db.tasks.count - idx_start else layout_tasks_rows);
for task_idx: idx_start..idx_stop-1 {
auto_release_temp(); // TODO Temporary memory being trashed?!
task := *db.tasks[task_idx];
y += 1;
x = 1;
// Apply theme.
if (task == active_task && task == selected_task) {
// attron(COLOR_PAIR(xx Styles.ACTIVE_SELECTED) | A_BOLD); TODO DAM
TUI.set_style_colors(TUI.Colors8b.Red, 6); // TODO TESTING COLORS
TUI.set_style(true, true, true, false);
}
else if (task == selected_task) {
// attron(COLOR_PAIR(xx Styles.SELECTED)); TODO DAM
TUI.set_style_colors(4, 6); // TODO TESTING COLORS
}
else if (task == active_task) {
TUI.set_style_colors(TUI.Colors8b.Red, 6); // TODO TESTING COLORS
TUI.set_style(false, false, false, true); // TODO TESTING STYLE
// attron(COLOR_PAIR(xx Styles.ACTIVE) | A_BOLD); TODO DAM
}
// Task title.
x += 1;
column_width = layout.columns[L_TITLE_IDX].width;
// mvprintw(xx y, xx x, "%-*.*s", column_width, column_width, temp_c_string(xx task.name)); //task.name); TODO Fix required for LLVM/Cncurses. TODO DAM
// TODO FIXME OH MY GOD SUCH BAD CODEZ
TUI.set_cursor_position(y, x);
white_spaces := column_width;
// FIXME Improve by using buffer instead of printing one char at the time.
while white_spaces > 0 {
print_character(#char " ");
white_spaces -= 1;
}
TUI.set_cursor_position(y, x);
task_name: string = cast(string)task.name;
task_name.count = ifx task_name.count > column_width then column_width else task_name.count;
print("%", task_name);
x += column_width;
// Task times.
total_time = 0;
for 0..NUM_WEEK_DAYS-1 {
x += 1;
day_idx := (it + FIRST_DAY_OF_WEEK) % NUM_WEEK_DAYS;
column_width = layout.columns[L_DAYS_IDX + day_idx].width;
task_time := task.times[day_idx];
total_time = add(total_time, task_time);
print_time(y, x, task_time, column_width);
x += column_width;
}
// Task total.
x += 1;
print_time(y, x, total_time, layout.columns[L_TOTAL_IDX].width);
// Reset theme.
//attrset(A_NORMAL); TODO DAM
TUI.clear_style();
}
///////////////////////////////////////////////////////////////////////////
// Draw selected/total tasks.
size := 1 + count_digits(db.selected_idx + 1) + 1 + count_digits(db.tasks.count) + 1; // " XXX/YYY "
TUI.set_cursor_position(size_y, 2);
if (size <= layout.columns[L_TITLE_IDX].width) {
print(" %/% ", db.selected_idx + 1, db.tasks.count);
}
else {
print("%", db.selected_idx + 1);
}
///////////////////////////////////////////////////////////////////////////
// Draw daily totals.
y = size_y;
x = 1 + 1 + layout.columns[L_TITLE_IDX].width;
total_time = 0;
for 0..NUM_WEEK_DAYS-1 {
idx := adjust_first_day_of_week[it];
daily_total := db.total_times[idx];
x += 1;
// Apply theme.
if (idx == now_week_day && active_task != null) {
// attron(COLOR_PAIR(xx Styles.ACTIVE) | A_BOLD); TODO DAM
}
else if (idx == now_week_day) {
// attron(COLOR_PAIR(xx Styles.SELECTED_INVERTED) | A_BOLD); TODO DAM
}
column_width = layout.columns[L_DAYS_IDX + idx].width;
total_time = add(total_time, daily_total);
print_time(y, x, daily_total, column_width);
x += column_width;
// Reset theme.
//attrset(A_NORMAL); TODO DAM
}
x += 1;
print_time(y, x, total_time, layout.columns[L_TOTAL_IDX].width);
}
free_memory :: () {
reset_database(*database);
reset_database(*archive);
free(app_directory);
free(db_file_path);
free(ar_file_path);
//reset_temporary_storage();
}
read_input_string_padded :: (row: int, column: int, style: s32, length: int, padding: int) -> string {
str := talloc_string(length);
memset(str.data, 0, str.count);
// attron(style | A_UNDERLINE); TODO DAM
// mvprintw(xx row, xx column, "%*s", padding, ""); TODO DAM
//echo(); TODO DAM
//curs_set(1); TODO DAM
//mvgetnstr(xx row, xx column, str.data, xx length); TODO DAM
truncate_string(str, length);
//noecho(); TODO DAM
//curs_set(0); TODO DAM
//attrset(A_NORMAL); TODO DAM
return str;
}
read_input_string :: (row: int, column: int, style: s32, length: int) -> string {
return read_input_string_padded(row, column, style, length, length);
}
// Returns success.
read_input_int :: (row: int, style: s32, message: string) -> value: int, success: bool {
// attron(xx style); TODO DAM
//move(xx row, 1); TODO DAM
//addch(ACS_CKBOARD); TODO DAM
//addstr(message.data); // TODO Convert to C type TODO DAM
//attrset(A_NORMAL); TODO DAM
// Get line number.
input_pos_x := 0;//getcurx(stdscr); TODO DAM
input_width := size_x - input_pos_x - 1;
str := read_input_string(row, input_pos_x, style, input_width);
value, success := parse_int(*str);
return value, success;
}
// Retuns true if user presses enter, false otherwise.
read_input_char :: (row: int, style: int, message: string) -> s32 {
assert(message.data != null, ASSERT_NOT_NULL, "message"); // TODO Improve this check?
// attron(xx style); TODO DAM
//move(xx row, 1); TODO DAM
for 0..size_x-3 {
//for (int idx = 0; idx < size_x - 2; idx++) { // TODO check what's going on here.
//addch(ACS_CKBOARD); TODO DAM
}
//mvaddstr(xx row, 2, message.data); TODO DAM
//attrset(A_NORMAL); TODO DAM
//return getch(); TODO DAM
return 0;
}
// Retuns true if user presses enter, false otherwise.
read_enter_confirmation :: inline (row: int, style: int, message: string) -> bool {
return read_input_char(row, style, message) == #char "\n";
}
main :: () {
// -- -- -- Testing TUI -- START
if 1 {
print("TEST : set and get cursor position\n", to_standard_error = true);
TUI.start();
ROW :: 3;
COLUMN :: 3;
TUI.set_cursor_position(ROW, COLUMN);
row, column := TUI.get_cursor_position();
TUI.stop();
assert(row == ROW && column == COLUMN, "# Failed set/get cursor position.\n");
print("- success\n", to_standard_error = true);
}
if 0 {
print("TEST : test key input\n", to_standard_error = true);
auto_release_temp();
TUI.start();
TUI.clear_terminal();
TUI.set_cursor_position(1, 1);
write_string("Press q to exit, other key to print it to screen, wait 1s to see animation.");
TUI.set_cursor_position(2, 1);
key: TUI.Key;
while(key != #char "q") {
__mark := get_temporary_storage_mark();
key = TUI.get_key(1000);
if key == TUI.Keys.None {
write_string("-");
}
else if key == TUI.Keys.Resize {
write_string("#");
}
else {
// else if key >= 32 && key <= 128 then print_character(cast,force(u8)key)
write_string(TUI.to_string(key));
}
set_temporary_storage_mark(__mark);
}
TUI.stop();
print("- success\n", to_standard_error = true);
}
if 0 {
print("TEST : draw box\n", to_standard_error = true);
auto_release_temp();
TUI.start(); // TODO Should start() call flush_input internally?
TUI.flush_input();
TUI.clear_terminal();
TUI.draw_box(1, 2, 5, 3);
TUI.set_cursor_position(1, 1);
print("Can you see the box below? (y/n)");
key := TUI.get_key();
TUI.stop();
assert(key == #char "y", "# Failed to draw box.\n");
print("- success\n", to_standard_error = true);
}
if 0 {
print("TEST : get terminal size\n", to_standard_error = true);
auto_release_temp();
TUI.start();
TUI.clear_terminal();
rows, columns := TUI.get_terminal_size();
TUI.set_cursor_position(1, 1);
print("Is terminal size % columns and % rows? (y/n)", columns, rows);
key: TUI.Key = xx TUI.Keys.None;
while (key == xx TUI.Keys.None || key == xx TUI.Keys.Resize) {
key = TUI.get_key();
}
TUI.stop();
assert(key == #char "y", "# Failed to get terminal size.\n");
print("- success\n", to_standard_error = true);
}
if 0 {
print("TEST : set terminal title\n", to_standard_error = true);
TUI.start();
title := "BAZINGA";
TUI.set_terminal_title(title);
TUI.set_cursor_position(1, 1);
print("Is terminal title '%'? (y/n)", title);
key: TUI.Key = xx TUI.Keys.None;
while (key == xx TUI.Keys.None || key == xx TUI.Keys.Resize) {
key = TUI.get_key();
}
TUI.stop();
assert(key == #char "y", "# Failed to set terminal title.\n");
print("- success\n", to_standard_error = true);
}
if 0 {
print("TEST : print keys and set terminal title\n", to_standard_error = true);
TUI.start();
TUI.set_terminal_title("bazinga");
key: TUI.Key = #char "d";
last_none_char := "X";
size_r, size_c := TUI.get_terminal_size();
TUI.clear_terminal();
TUI.draw_box(1, 1, size_c, size_r);
drop_down := 0;
while(key != #char "q") {
__mark := get_temporary_storage_mark();
if key == {
case TUI.Keys.None; {
TUI.set_cursor_position(2, 2);
last_none_char = ifx last_none_char == "X" then "+" else "X";
write_string(last_none_char);
}
case TUI.Keys.Resize; #through;
case #char "c"; {
size_r, size_c = TUI.get_terminal_size();
TUI.clear_terminal();
TUI.draw_box(1, 1, size_c, size_r);
drop_down = 0;
}
case; {
TUI.set_cursor_position(3+drop_down, 2);
str := TUI.to_string(key);
array_to_print: [..] string;
for 0..str.count-1 {
tmp := tprint("%", FormatInt.{value = cast(u8)str[it], base=16},, temporary_allocator);
array_add(*array_to_print, tmp);
}
string_to_print := join(..array_to_print, separator = " ");
print(": % : ", string_to_print);
for 0..str.count-1 {
if str[it] == #char "\e" {
str[it] = #char "#";
}
}
write_string(str);
write_string(" :");
drop_down += 1;
}
}
x := ifx size_r > 1 then size_r-1 else 1;
y := ifx size_c > 24 then size_c-24 else 1;
TUI.set_cursor_position(x, y);
print("size(CxR): %x%\n", size_c, size_r);
key = TUI.get_key(1000);
set_temporary_storage_mark(__mark);
}
TUI.stop();
}
if 1 {
print("TEST : user input\n", to_standard_error = true);
auto_release_temp();
TUI.start();
TUI.clear_terminal();
TUI.set_cursor_position(1, 1);
print("Enter some text (use Enter to finish, Esc to cancel, or resize to abort):");
TUI.set_cursor_position(2, 1);
str, key := TUI.read_input_line(15);
TUI.set_cursor_position(3, 1);
if key == {
case TUI.Keys.Escape; {
print("Have you pressed Esc? (y/n)");
assert(TUI.get_key() == #char "y", "Failed to read line on Esc.");
print("- success\n", to_standard_error = true);
}
case TUI.Keys.Resize; {
print("Have you resized the terminal? (y/n)");
assert(TUI.get_key() == #char "y", "Failed to read line on resize.");
print("- success\n", to_standard_error = true);
}
case; {
print("Have you entered '%'? (y/n)", str);
assert(TUI.get_key() == #char "y", "Failed to read line.");
print("- success\n", to_standard_error = true);
}
}
TUI.stop();
}
write_string("DONE\n");
exit(0);
// -- -- -- Testing TUI -- STOP
// TODO Implement signal handling and see modules/Debug.jai for examples.
defer report_memory_leaks(); // TODO Remove after final debug sessions.
defer free_memory();
{ // Initialize app directory.
auto_release_temp();
home_dir, success_dir := get_home_directory(); // Returns system owned memory.
if success_dir == false {
home_dir = ".";
}
home_path, success_path := get_absolute_path(home_dir); // Returns temporary memory.
if success_path == false {
print_error("Failed to find home directory '%'.", home_dir);
exit(1);
}
app_directory = join(home_path, "/", APP_FOLDER_NAME);
db_file_path = join(app_directory, "/", DB_FILE_NAME);
ar_file_path = join(app_directory, "/", AR_FILE_NAME);
// TODO app data should be stored under:
// Windows: APPDATA (~/AppData/Roaming)
// Unix: XDG_DATA_HOME (~/.local/share)
make_directory_if_it_does_not_exist(app_directory, recursive = true);
}
{ // Initialize database and archive files if needed.
auto_release_temp();
if (file_exists(db_file_path) == false) {
if (store_database(database, db_file_path) == false) {
print_error("Failed to initialize database.");
exit(1);
}
}
if (file_exists(ar_file_path) == false) {
if (export_to_csv(archive, ar_file_path) == false) {
print_error("Failed to initialize archive.");
exit(1);
}
}
}
args := get_command_line_arguments();
defer array_reset(*args);
if args.count > 1 {
is_exit_requested := false;
for 1..args.count-1 {
if is_equal_to_any(args[it], "--help", "-h") {
write_strings(
"Usage: ttt [OPTION]... [FILE]...\n",
" -i, --import-csv [FILE] Import CSV file to database (discard first row).\n",
" -e, --export-csv [FILE] Export database to CSV file.\n",
" -n, --no-autosave Disable autosave feature (only save on exit).\n",
" -h, --help Display this help and exit.\n",
" -v, --version Output version information and exit.\n",
"\n",
"In app commands\n",
" w, W Archive a duplicate and reset times for all tasks.\n",
" a, A Archive selected task (except if active).\n",
" r, R Restore selected task from archive.\n",
" t, T Select currently active task (if any).\n",
" d, D Duplicate selected task.\n",
" c, C Coalesce similar tasks.\n",
" n, N Create new task.\n",
" m, M Move selected task to position.\n",
" g, G Select task by position.\n",
" i, I Invert tasks order.\n",
" s, S Sort tasks by:\n",
" n name;\n",
" t total time;\n",
" 1..7 time of Nth day of week.\n",
" q, Q Save changes and exit.\n",
" F2 Rename selected task.\n",
" F5 Recalculate total times.\n",
" TAB Toggle archive view.\n",
" BACKSPACE Reset times for selected task.\n",
" DELETE Delete selected task (except if active).\n",
" SPACE, ENTER Toggle selected task as active/inactive.\n",
" 1, 2, 3, 4, 5, 6, 7 Edit selected task time for the Nth day of week:\n",
" =# sets # seconds;\n",
" -# subtracts # seconds;\n",
" # adds # seconds;\n",
" #m specifies # as minutes;\n",
" #h specifies # as hours;\n",
" #d specifies # as days;\n",
" #y specifies # as years.\n",
" UP Select task above.\n",
" DOWN Select task below.\n",
" PAGE-UP Select task 1 page above.\n",
" PAGE-DOWN Select task 1 page below.\n",
" HOME Select first/top task.\n",
" END Select last/bottom task.\n",
"\n",
"Notes\n");
print("- All data files are stored in '%'.\n", app_directory);
print(" If the home directory is undefined, './%' will be used.\n", APP_FOLDER_NAME);
write_strings(
" The database tasks are stored in binary format on the 'database.bin' file.\n",
" The archived entries are stored in CSV format on the 'archive.csv' file.\n",
"- During intensive operations such as saving or recalculating totals times,\n",
" a diamond symbol is shown on the top left corner.\n"
);
exit(0);
}
if is_equal_to_any(args[it], "--version", "-v") {
print("Task Time Tracker version % \nCopyright % Daniel Martins\nLicense GPL-3.0-or-later\n", VERSION, YEAR);
exit(0);
}
if is_equal_to_any(args[it], "--import-csv", "-i") {
it += 1;
if it >= args.count {
print_error("Missing CSV file path to import.");
exit(1);
}
if (load_database(*database, db_file_path) == false) {
print_error("Failed to load database.");
exit(1);
}
if (import_from_csv(*database, args[it]) == false) {
print_error("Failed to import CSV file.");
exit(1);
}
if (store_database(*database, db_file_path) == false) {
print_error("Failed to store database.");
exit(1);
}
reset_database(*database);
is_exit_requested = true;
continue;
}
if is_equal_to_any(args[it], "--export-csv", "-e") {
it += 1;
if it >= args.count {
print_error("Missing CSV file path to export.");
exit(1);
}
if (load_database(*database, db_file_path) == false) {
print_error("Failed to load database.");
exit(1);
}
if (export_to_csv(*database, args[it]) == false) {
print_error("Failed to export CSV file.");
exit(1);
}
reset_database(*database);
is_exit_requested = true;
continue;
}
if is_equal_to_any(args[it], "--no-autosave", "-n") {
is_autosave_enabled = false;
continue;
}
print_error("%: invalid option '%'.\nTry '% --help' for more information.", args[0], args[it], args[0]);
exit(1);
}
if is_exit_requested {
exit(0);
}
}
if (load_database(*database, db_file_path) == false) {
print_error("Failed to load database.");
exit(1);
}
initialize_tui();
db := *database;
layout := *layouts[Layouts.COMPACT];
TUI.flush_input();
TUI.set_next_key(TUI.Keys.Resize);
while (true) {
if (is_terminal_too_small) {
INVALID_WINDOW_MESSAGE :: "Terminal is too small: minimum 60x3.";
TUI.set_cursor_position(size_y / 2, (size_x - INVALID_WINDOW_MESSAGE.count) / 2);
write_strings(INVALID_WINDOW_MESSAGE);
}
else {
draw_tui(db, layout);
draw_error_window();
}
reset_temporary_storage();
key := TUI.get_key(INPUT_TIMEOUT_MS);
if key == #char "q" || key == #char "Q" break;
update_times(*database);
//timeout(INPUT_AWAIT_INF); TODO DAM
// TODO WIP Remove `selected_task` and `active_task` and helper functions.
selected_task := get_selected_task(db);
active_task := get_active_task(db);
action_style: s32;
error_style: s32;
selected_task_row: int;
{ // TODO Recheck this code.
using db;
action_style = A_BOLD | COLOR_PAIR(xx
ifx selected_idx == active_idx && selected_idx != -1 then Styles.ACTIVE
else Styles.SELECTED_INVERTED);
error_style = A_BOLD | COLOR_PAIR(xx Styles.ERROR);
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;
}
if key == {
// When getch() 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();
_y, _x := TUI.get_terminal_size();
// TODO FIX ME
size_y = xx _y;
size_x = xx _x;
is_terminal_too_small = size_x < 60 || size_y < 3;
update_layout();
layout = *layouts[ifx size_x > 100 then Layouts.NORMAL else Layouts.COMPACT];
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();
case #char "n"; #through;
case #char "N";
if is_database_full(db) {
read_enter_confirmation(selected_task_row, error_style, " 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.
//flushinp(); TODO DAM
//ungetch(KEY_F(2)); TODO DAM
case KEY_F2;
if (selected_task == null) continue;
// Change task name.
input := read_input_string_padded(selected_task_row, 1, action_style, Task.name.count, size_x - 2);
if is_empty_string(input) == false {
replace_chars(input, "\t\x0B\x0C\r", #char " ");
memcpy(selected_task.name.data, input.data, min(Task.name.count, input.count));
trigger_autosave();
}
case KEY_BACKSPACE;
if (selected_task == null) continue;
if (read_enter_confirmation(selected_task_row, action_style, " Press enter to reset task. ") == true) {
reset_task_times(db, db.selected_idx);
trigger_autosave();
}
case KEY_DC; // Delete
if (selected_task == null || selected_task == active_task) continue;
if (read_enter_confirmation(selected_task_row, action_style, " Press enter to delete task. ") == true) {
delete_task(db, db.selected_idx);
trigger_autosave();
}
case #char "1"; #through;
case #char "2"; #through;
case #char "3"; #through;
case #char "4"; #through;
case #char "5"; #through;
case #char "6"; #through;
case #char "7";
if (selected_task == null) continue;
// Prepare position to input time operation.
selected_day := cast(int)(key - #char "1"); // TODO DAM this cast...
input_width := layout.columns[L_DAYS_IDX + selected_day].width;
input_pos_x := 1 + 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.
input := read_input_string(selected_task_row, input_pos_x, action_style, input_width); // TODO Temp stringzes.
// Abort if input if empty.
if is_empty_string(input) continue;
// Search for assign '=' operator and discard everything before it.
assign_idx := find_index_from_left(input, "=");
is_assign := assign_idx >= 0;
if is_assign advance(*input, assign_idx + 1);
// Try to parse a number and abort if it fails.
input_float, parse_success := string_to_float64(input);
if parse_success == false continue;
// Try to parse a character representing the time multiplier.
multiplier: float64 = 1.0;
for 0..input.count-1 {
ch := to_lower(input[it]);
if ch == {
case #char "m"; multiplier = xx SECONDS_IN_MINUTE;
case #char "h"; multiplier = xx SECONDS_IN_HOUR;
case #char "d"; multiplier = xx SECONDS_IN_DAY;
case #char "y"; multiplier = xx SECONDS_IN_YEAR;
}
}
// Process input and check if it's valid.
input_time := input_float * multiplier;
if (input_time > xx S64_MAX || input_time < xx S64_MIN) continue;
// Apply changes.
time := cast(s64)input_time;
day := (selected_day + FIRST_DAY_OF_WEEK) % NUM_WEEK_DAYS;
if is_assign set_task_time(db, db.selected_idx, day, time);
else add_task_time(db, db.selected_idx, day, time);
trigger_autosave();
case #char "m"; #through;
case #char "M";
if selected_task == null continue;
value, success := read_input_int(selected_task_row, action_style, " Move to: ");
if success == false continue;
move_task(db, db.selected_idx, value-1); // -1 to adjust for zero based index
trigger_autosave();
case #char "g"; #through;
case #char "G";
if selected_task == null continue;
value, success := read_input_int(selected_task_row, action_style, " Go to: ");
if success == false continue;
target_index := clamp(value, 1, MAX_DATABASE_TASKS) - 1;
select_task(db, target_index);
case #char "d"; #through;
case #char "D";
if selected_task == null continue;
if is_database_full(db) {
read_enter_confirmation(selected_task_row, error_style, " Unable to duplicate task: database is full. ");
continue;
}
if (add_task(db, selected_task) == null) {
print_error("Failed to duplicate task.");
continue;
}
trigger_autosave();
case KEY_F5;
update_total_times(db);
trigger_autosave();
case #char "t"; #through;
case #char "T";
if (active_task == null) continue;
select_task(db, db.active_idx);
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();
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;
}
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 archived task.
case #char "r"; #through;
case #char "R";
if (db != *archive || selected_task == null) continue;
if is_database_full(*database) {
read_enter_confirmation(selected_task_row, error_style, " Unable to restore task: database is full. ");
continue;
}
if (add_task(*database, selected_task) == null) {
print_error("Failed to restore task.");
continue;
}
delete_task(db, db.selected_idx);
trigger_autosave();
// Sort by.
case #char "s"; #through;
case #char "S";
// TODO The initial part should only decide what's the sorting procedure... then we would would all in a single place.
sort_by := read_input_char(selected_task_row, action_style, " Sort by (n) name, (1..7) day, or (t) total time. ");
show_processing();
sort_procedure: (a: Task, b: Task) -> s64;
active_task: Task = ifx db.active_idx >= 0 then db.tasks[db.active_idx] else .{};
if sort_by == {
case #char "n"; #through;
case #char "N";
sort_procedure = (x, y) => compare_strings(xx x.name, xx y.name);
case #char "t"; #through;
case #char "T";
sum_total :: inline (t: Task) -> s64 { total: s64; for t.times { total = add(total, it); } return total; }
sort_procedure = (x, y) => sum_total(x) - sum_total(y);
case #char "1"; #through;
case #char "2"; #through;
case #char "3"; #through;
case #char "4"; #through;
case #char "5"; #through;
case #char "6"; #through;
case #char "7";
sort_by_idx := sort_by - #char "1";
day := (sort_by_idx + FIRST_DAY_OF_WEEK) % NUM_WEEK_DAYS;
if day == {
case 0; sort_procedure = (x, y) => x.times[0] - y.times[0];
case 1; sort_procedure = (x, y) => x.times[1] - y.times[1];
case 2; sort_procedure = (x, y) => x.times[2] - y.times[2];
case 3; sort_procedure = (x, y) => x.times[3] - y.times[3];
case 4; sort_procedure = (x, y) => x.times[4] - y.times[4];
case 5; sort_procedure = (x, y) => x.times[5] - y.times[5];
case 6; sort_procedure = (x, y) => x.times[6] - y.times[6];
}
case;
continue;
}
quick_sort(db.tasks, sort_procedure);
if db.active_idx >= 0 {
db.active_idx = find_similar_task(db, active_task);
}
trigger_autosave();
// Workspace cleanup.
case #char "w"; #through;
case #char "W";
if (db != *database || db.tasks.count <= 0) continue;
if (read_enter_confirmation(selected_task_row, action_style, " Press enter to archive duplicates and reset all. ") == false) continue;
show_processing();
for db.tasks {
if (append_to_csv(it, ar_file_path) == false) {
print_error("Failed to archive task."); // TODO Improve this.
}
reset_task_times(db, it_index);
}
trigger_autosave();
// Coalesce similar tasks.
case #char "c"; #through;
case #char "C";
if (db.tasks.count <= 0) continue;
if (read_enter_confirmation(selected_task_row, action_style, " Press enter to coalesce similar tasks. ") == false) 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 KEY_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 KEY_END;
select_task(db, db.tasks.count-1);
case TUI.Keys.Down;
select_task_by_delta(db, 1);
case TUI.Keys.PgDown;
select_task_by_delta(db, layout_tasks_rows);
}
}
// Save any unsaved changes.
show_processing();
error_saving := false;
if (db == *archive) {
if (export_to_csv(archive, ar_file_path) == false) {
print_error("Failed to save archive.");
error_saving |= true;
}
}
if (countdown_to_autosave > 0 || is_autosave_enabled == false) {
if (store_database(database, db_file_path) == false) {
print_error("Failed to save database.");
error_saving |= true;
}
}
if (error_saving) {
print_error("Press any key to close.");
draw_error_window();
TUI.get_key();
}
TUI.stop();
exit(xx ifx error_saving then 1 else 0);
}