Something went wrong. Try again.
This repository has no description
Something went wrong. Try again.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994const std = @import("std");const vaxis = @import("vaxis");const known_folders = @import("known-folders");const zeit = @import("zeit");
/// Set known folders to use XDG paths on macOS.pub const known_folders_config = .{ .xdg_on_mac = true,};
/// Set the default panic handler to the vaxis panic_handler. This will clean up the terminal if any/// panics occurpub const panic = vaxis.panic_handler;
/// Set some scope levels for the vaxis scopespub const std_options: std.Options = .{ .log_scope_levels = &.{ .{ .scope = .vaxis, .level = .warn }, .{ .scope = .vaxis_parser, .level = .warn }, },
.logFn = log_to_file,};
const default_style: vaxis.Cell.Style = .{ .fg = .default, .bg = .default };// FIXME: Define a proper hover style that's distinct from the active style.const hover_style: vaxis.Cell.Style = .{ .fg = .default, .bg = .default, .reverse = true };const active_style: vaxis.Cell.Style = .{ .fg = .default, .bg = .default, .reverse = true };
const TagId = usize;
const Button = union(enum) { None, DetailsCloseButton, ActiveTasksButton, CompletedTasksButton, ClearFilterButton, ScrollUp, ScrollDown, Task: TagId, Tag: TagId,
fn eql(self: Button, other: Button) bool { switch (self) { .None => return other == .None, .DetailsCloseButton => return other == .DetailsCloseButton, .ActiveTasksButton => return other == .ActiveTasksButton, .CompletedTasksButton => return other == .CompletedTasksButton, .ClearFilterButton => return other == .ClearFilterButton, .ScrollUp => return other == .ScrollUp, .ScrollDown => return other == .ScrollDown, .Task => |task_id| { switch (other) { .Task => |other_task_id| return task_id == other_task_id, else => return false, } }, .Tag => |task_id| { switch (other) { .Tag => |other_task_id| return task_id == other_task_id, else => return false, } }, } return false; }};
/// Tagged union of all events our application will handle. These can be generated by Vaxis or your/// own custom eventsconst Event = union(enum) { key_press: vaxis.Key, key_release: vaxis.Key, mouse: vaxis.Mouse, focus_in, // window has gained focus focus_out, // window has lost focus paste_start, // bracketed paste start paste_end, // bracketed paste end paste: []const u8, // osc 52 paste, caller must free color_report: vaxis.Color.Report, // osc 4, 10, 11, 12 response color_scheme: vaxis.Color.Scheme, // light / dark OS theme changes winsize: vaxis.Winsize, // the window size has changed. This event is always sent when the loop // is started task_clicked: usize, // sent when a task in the list is clicked. button_clicked: Button, // The button that was just clicked.};
const TaskType = union(enum) { ActiveTask, CompletedTask,};
const Task = struct { title: std.ArrayList(u8), /// All tags are currently stored as a single string. tags: std.ArrayList(u8), details: std.ArrayList(u8), file_path: std.ArrayList(u8),};
const Layout = enum { TaskList, TaskDetails, TaskFilter, CompletedTasks };
/// The application stateconst TodoApp = struct { allocator: std.mem.Allocator, // Arena allocator for easy event loops, see https://github.com/rockorager/libvaxis/blob/main/examples/table.zig#L110. arena_allocator: std.heap.ArenaAllocator, // A flag for if we should quit should_quit: bool, /// The tty we are talking to tty: vaxis.Tty, /// The vaxis instance vx: vaxis.Vaxis, /// Reference to the event loop. loop: ?vaxis.Loop(Event), /// A mouse event that we will handle in the draw cycle mouse: ?vaxis.Mouse, /// List of loaded tasks. tasks: std.ArrayList(Task), /// currently active layout. active_layout: std.ArrayList(Layout), /// Index of currently selected task in active task list. selected_active_task: usize, /// The title input. task_filter_input: vaxis.widgets.TextInput, /// The task filter. task_filter: std.ArrayList(u8), /// Indicates whether the tags should be displayed. should_show_tags_in_task_list: bool, button_currently_down: Button, scroll_offset: usize,
pub fn init(allocator: std.mem.Allocator) !TodoApp { var vx = try vaxis.init(allocator, .{}); var layout = std.ArrayList(Layout).init(allocator); try layout.append(.TaskList); return .{ .allocator = allocator, .arena_allocator = std.heap.ArenaAllocator.init(allocator), .should_quit = false, .tty = try vaxis.Tty.init(), .vx = vx, .loop = null, .mouse = null, .tasks = std.ArrayList(Task).init(allocator), .active_layout = layout, .selected_active_task = 0, .task_filter_input = vaxis.widgets.TextInput.init(allocator, &vx.unicode), .task_filter = std.ArrayList(u8).init(allocator), .should_show_tags_in_task_list = true, .button_currently_down = .None, .scroll_offset = 0, }; }
pub fn deinit(self: *TodoApp) void { // Deinit takes an optional allocator. You can choose to pass an allocator to clean up // memory, or pass null if your application is shutting down and let the OS clean up the // memory self.vx.deinit(self.allocator, self.tty.anyWriter()); self.tty.deinit();
// Free any memory used by the arena allocator. // FIXME: Shouldn't this be done at the end of every loop? self.arena_allocator.deinit();
// Free any memory used by the text inputs. self.task_filter_input.deinit();
// Free memory used by filters. self.task_filter.deinit();
// Free memory used to track the active layout. self.active_layout.deinit();
// Make sure all the individual task structs are properly cleaned and freed before we // free the main task list. self.clear_tasks(); self.tasks.deinit(); }
fn get_task_from_file(self: *TodoApp, file: []const u8) !Task { var task: Task = Task{ .title = std.ArrayList(u8).init(self.allocator), .tags = std.ArrayList(u8).init(self.allocator), .details = std.ArrayList(u8).init(self.allocator), .file_path = std.ArrayList(u8).init(self.allocator), };
try task.file_path.appendSlice(file);
const fd = try std.fs.openFileAbsolute(file, .{}); defer fd.close();
var buf_reader = std.io.bufferedReader(fd.reader()); const reader = buf_reader.reader();
var line = std.ArrayList(u8).init(self.allocator); defer line.deinit();
const writer = line.writer(); var line_no: usize = 0;
while (reader.streamUntilDelimiter(writer, '\n', null)) { // Clear the line so we can reuse it. defer line.clearRetainingCapacity(); line_no += 1;
if (line_no == 1) { try task.title.appendSlice(line.items); } else if (line_no == 2) { try task.tags.appendSlice(line.items); } else if (line_no > 3) { try task.details.appendSlice(line.items); try task.details.appendSlice("\n"); } } else |err| switch (err) { error.EndOfStream => { // end of file line_no += 1;
if (line_no == 1) { try task.title.appendSlice(line.items); } else if (line_no > 3) { try task.details.appendSlice(line.items); } }, else => return err, // Propagate error }
return task; }
fn get_tasks_from_dir(self: *TodoApp, dir: []const u8) !std.ArrayList(Task) { var tasks = std.ArrayList(Task).init(self.allocator);
std.fs.makeDirAbsolute(dir) catch |err| switch (err) { error.PathAlreadyExists => {}, else => return err, }; var todo_dir = try std.fs.openDirAbsolute(dir, .{}); defer todo_dir.close();
var iterator = todo_dir.iterate();
while (try iterator.next()) |f| { // We're only interested in files. if (f.kind != std.fs.Dir.Entry.Kind.file) { continue; }
// And we're only interested in .todo files specifically. if (!std.mem.endsWith(u8, f.name, ".todo")) { continue; }
const file_path = try std.fs.path.join(self.allocator, &.{ dir, f.name }); defer self.allocator.free(file_path);
const task = try self.get_task_from_file(file_path);
// If a filter is passed in, make sure to only add tasks that match the filter. if (self.task_filter.items.len > 0) { if (std.mem.containsAtLeast(u8, task.tags.items, 1, self.task_filter.items)) { try tasks.append(task); } else { // Need to free memory if the task is not added. task.title.deinit(); task.tags.deinit(); task.details.deinit(); task.file_path.deinit(); } } else { try tasks.append(task); } }
return tasks; }
fn load_tasks(self: *TodoApp, task_type: TaskType) !void { const todo_folder_path = switch (task_type) { .ActiveTask => try get_todo_file_storage_path_caller_should_free(self.allocator), .CompletedTask => try get_completed_todo_file_storage_path_caller_should_free(self.allocator), }; defer self.allocator.free(todo_folder_path);
self.tasks.clearRetainingCapacity(); self.tasks = try self.get_tasks_from_dir(todo_folder_path);
// Sort tasks. if (task_type == .ActiveTask) { std.sort.heap(Task, self.tasks.items, {}, compare_tasks); } else { std.sort.heap(Task, self.tasks.items, {}, compare_task_file_names); } }
fn clear_tasks(self: *TodoApp) void { // Cleanup the ArrayLists in the Task struct. for (self.tasks.items) |t| { t.title.deinit(); t.tags.deinit(); t.details.deinit(); t.file_path.deinit(); }
self.tasks.clearRetainingCapacity(); }
fn reload_tasks(self: *TodoApp, task_type: TaskType) !void { self.clear_tasks(); try self.load_tasks(task_type); }
fn calculate_completed_task_file_name(self: *TodoApp, task: Task) ![]const u8 { // Get the completed storage directory. const completed_storage = try get_completed_todo_file_storage_path_caller_should_free(self.allocator); defer self.allocator.free(completed_storage);
// Make sure the completed storage directory exists. std.fs.makeDirAbsolute(completed_storage) catch |err| switch (err) { error.PathAlreadyExists => {}, else => return err, };
// Get the executable environment. var env = try std.process.getEnvMap(self.allocator); defer env.deinit();
// Load local timezone. const local = try zeit.local(self.allocator, &env); defer local.deinit();
// Get zeit instant for current date. const now = try zeit.instant(.{ .timezone = &local }); const dt = now.time();
// YYYY-mm-dd should only have 10 characters, 6 more included for future proofing? // Obviously this should all work at least until the year 999999999. Obviously. var buf: [16]u8 = undefined; var fbs = std.io.fixedBufferStream(&buf); const writer = fbs.writer().any();
try dt.strftime(writer, "%Y-%m-%d"); const date_str = fbs.getWritten();
// To make sure the hash is unique even if multiple files are completed per day we hash a // string consisting of "{file_name}{millisecond_timestamp}{task-title}". const str_to_hash = try std.fmt.allocPrint(self.allocator, "{s}{d}{s}", .{ task.file_path.items, std.time.milliTimestamp(), task.title.items }); defer self.allocator.free(str_to_hash);
var sha256 = std.crypto.hash.sha2.Sha256.init(.{}); sha256.update(str_to_hash); const hash = sha256.finalResult();
const hex_digest = try std.fmt.allocPrint(self.allocator, "{s}", .{std.fmt.fmtSliceHexLower(&hash)}); defer self.allocator.free(hex_digest);
// Construct new path as yyyy-mm-dd-<hash>. const new_file_name = try std.fmt.allocPrint(self.allocator, "{s}-{s}.todo", .{ date_str, hex_digest }); defer self.allocator.free(new_file_name);
return try std.fs.path.join(self.allocator, &.{ completed_storage, new_file_name }); }
fn rename_task(self: *TodoApp, task: *Task, to: usize) !void { const storage_path = try get_todo_file_storage_path_caller_should_free(self.allocator); defer self.allocator.free(storage_path);
const new_file_name = try std.fmt.allocPrint(self.allocator, "{d}.todo", .{to}); defer self.allocator.free(new_file_name);
const new_file_path = try std.fs.path.join(self.allocator, &.{ storage_path, new_file_name }); defer self.allocator.free(new_file_path);
try std.fs.renameAbsolute(task.file_path.items, new_file_path);
task.file_path.clearRetainingCapacity(); try task.file_path.appendSlice(new_file_path); }
fn complete_task(self: *TodoApp, idx: usize) !void { std.log.debug("Starting complete task", .{});
var index = idx;
if (index < 0 or index >= self.tasks.items.len) { std.log.debug("Completing requested task {d} not possible. Task does not exist.", .{index}); std.log.debug("End complete task", .{}); return; }
// Retain a copy of the file path so we can find the task to complete after reload. const file_path_copy = try self.tasks.items[index].file_path.clone(); defer file_path_copy.deinit();
std.log.debug("Reloading tasks without a filter", .{});
// Reload all tasks without a filter so the amount of total tasks is correct. // NOTE: Not deferring a free here because `filter` will be moved back into the // ArrayList when the filter is reapplied. const filter = try self.task_filter.toOwnedSlice(); try self.reload_tasks(.ActiveTask);
std.log.debug("Looking up the right index for task to complete after reloading all tasks", .{});
// Find the index of the task again. for (0..self.tasks.items.len) |i| { if (std.mem.eql(u8, self.tasks.items[i].file_path.items, file_path_copy.items)) { index = i; break; } }
std.log.debug("New index: {d}", .{index});
if (index < 0 or index >= self.tasks.items.len) { std.log.debug("Completing requested task {d} not possible. Task does not exist.", .{index}); std.log.debug("End complete task", .{}); return; }
std.log.debug("Completing task {d}: '{s}'", .{ index, self.tasks.items[index].title.items });
const completed_file_name = try self.calculate_completed_task_file_name(self.tasks.items[index]); defer self.allocator.free(completed_file_name);
std.log.debug("Completed task's new file name: {s}", .{completed_file_name});
try std.fs.renameAbsolute(self.tasks.items[index].file_path.items, completed_file_name);
std.log.debug("Task {d} completed", .{index});
// Shift all files that come after this task up by one. for (index + 1..self.tasks.items.len) |i| { std.log.debug("Renaming task {d} to {d}", .{ i, i - 1 }); try self.rename_task(&self.tasks.items[i], i - 1); }
// Reapply the filter. self.task_filter = std.ArrayList(u8).fromOwnedSlice(self.allocator, filter);
try self.reload_tasks(.ActiveTask);
std.log.debug("End complete task", .{}); }
fn edit_file_path(self: *TodoApp, file_path: []const u8) !void { // Reset terminal state. _ = try self.vx.resetState(self.tty.anyWriter());
// Halt the event loop. self.loop.?.stop();
// Get the executable environment. var env = try std.process.getEnvMap(self.allocator); defer env.deinit();
// Use the $EDITOR environment variable if it's available; default to nano. const editor = env.get("EDITOR") orelse "nano";
// Edit the todo file using $EDITOR. var child = std.process.Child.init(&.{ editor, file_path }, self.allocator); _ = try child.spawnAndWait();
// Restart the loop. try self.loop.?.start(); try self.vx.enterAltScreen(self.tty.anyWriter()); // Re-enable mouse events. try self.vx.setMouseMode(self.tty.anyWriter(), true); self.vx.queueRefresh(); }
fn create_new_task(self: *TodoApp) !void { // Reload all tasks without a filter so the amount of total tasks is correct. // NOTE: Not deferring a free here because `filter` will be moved back into the // ArrayList when the filter is reapplied. const filter = try self.task_filter.toOwnedSlice(); try self.reload_tasks(.ActiveTask);
// Get the storage path. const storage_path = try get_todo_file_storage_path_caller_should_free(self.allocator); defer self.allocator.free(storage_path);
// Create the full file path for the new file. const new_file_name = try std.fmt.allocPrint(self.allocator, "{d}.todo", .{self.tasks.items.len}); defer self.allocator.free(new_file_name);
const new_file_path = try std.fs.path.join(self.allocator, &.{ storage_path, new_file_name }); defer self.allocator.free(new_file_path);
try self.edit_file_path(new_file_path);
// Reapply the filter. self.task_filter = std.ArrayList(u8).fromOwnedSlice(self.allocator, filter);
// Once new task is created, reload all the tasks. try self.reload_tasks(.ActiveTask); }
fn edit_task(self: *TodoApp, task: Task) !void { // Retain a copy of the file path for after the tasks are reloaded. const file_path_copy = try task.file_path.clone(); defer file_path_copy.deinit();
try self.edit_file_path(task.file_path.items);
// Reload the tasks after the edit session is done. try self.reload_tasks(.ActiveTask);
// Set the selected task to the same item we just finished editing. for (self.tasks.items, 0..) |t, i| { if (std.mem.eql(u8, t.file_path.items, file_path_copy.items)) { self.selected_active_task = i; } } }
fn swap_tasks(self: *TodoApp, a_idx: usize, b_idx: usize) !void { std.log.debug("Start swap tasks", .{});
if (a_idx == b_idx) { std.log.debug("Trying to swap same task {d} with itself: {d}", .{ a_idx, b_idx }); std.log.debug("End swap tasks", .{}); return; }
// Can't move out of bounds. if (a_idx < 0 or a_idx >= self.tasks.items.len or b_idx < 0 or b_idx >= self.tasks.items.len) { std.log.debug("Can't swap tasks that don't exist", .{}); std.log.debug("End swap tasks", .{}); return; }
std.log.debug("Reloading tasks without a filter to get correct number of tasks", .{});
// Reload all tasks without a filter so the amount of total tasks is correct. // NOTE: Not deferring a free here because `filter` will be moved back into the // ArrayList when the filter is reapplied. const filter = try self.task_filter.toOwnedSlice(); try self.reload_tasks(.ActiveTask);
// Use the end of the list as a temporary slot while swapping files. const tmp_idx = self.tasks.items.len;
std.log.debug("Reapplying filter", .{});
// Reapply the filter. self.task_filter = std.ArrayList(u8).fromOwnedSlice(self.allocator, filter);
// Reload the tasks list so we get the right tasks to swap. try self.reload_tasks(.ActiveTask);
// Get the tasks being swapped; var task_a = self.tasks.items[a_idx]; var task_b = self.tasks.items[b_idx]; const task_a_number = try get_file_number(task_a.file_path.items); const task_b_number = try get_file_number(task_b.file_path.items);
std.log.debug("Swapping tasks {d} ('{s}') and {d} ('{s}')", .{ task_a_number, task_a.title.items, task_b_number, task_b.title.items });
std.log.debug("Moving task {d}.todo to {d}.todo", .{ task_a_number, tmp_idx });
// Move task a to the temporary position. try self.rename_task(&task_a, tmp_idx);
std.log.debug("Moving task {d}.todo to {d}.todo", .{ task_b_number, task_a_number });
// Move task b to task a original position. try self.rename_task(&task_b, task_a_number);
std.log.debug("Moving task {d}.todo to {d}.todo", .{ task_a_number, task_b_number });
// Move task a to task b original position. try self.rename_task(&task_a, task_b_number);
std.log.debug("Reloading tasks list", .{});
// Reload the tasks list. try self.reload_tasks(.ActiveTask);
std.log.debug("End swap tasks", .{}); }
pub fn run(self: *TodoApp) !void { // Load tasks. Loading early so I can log things. try self.load_tasks(.ActiveTask);
// Initialize our event loop. This particular loop requires intrusive init self.loop = .{ .tty = &self.tty, .vaxis = &self.vx, }; try self.loop.?.init();
// Start the event loop. Events will now be queued try self.loop.?.start();
try self.vx.enterAltScreen(self.tty.anyWriter());
// Query the terminal to detect advanced features, such as kitty keyboard protocol, etc. // This will automatically enable the features in the screen you are in, so you will want to // call it after entering the alt screen if you are a full screen application. The second // arg is a timeout for the terminal to send responses. Typically the response will be very // fast, however it could be slow on ssh connections. try self.vx.queryTerminal(self.tty.anyWriter(), 1 * std.time.ns_per_s);
// Enable mouse events try self.vx.setMouseMode(self.tty.anyWriter(), true);
// This is the main event loop. The basic structure is // 1. Handle events // 2. Draw application // 3. Render while (!self.should_quit) { // pollEvent blocks until we have an event self.loop.?.pollEvent(); // tryEvent returns events until the queue is empty while (self.loop.?.tryEvent()) |event| { try self.update(event); } // Draw our application after handling events try self.draw();
// It's best to use a buffered writer for the render method. TTY provides one, but you // may use your own. The provided bufferedWriter has a buffer size of 4096 var buffered = self.tty.bufferedWriter(); // Render the application to the screen try self.vx.render(buffered.writer().any()); try buffered.flush();
// Clear the arena memory after every render loop. _ = self.arena_allocator.reset(.retain_capacity); } }
fn get_available_height_for_tasks(self: *TodoApp) usize { // Window height - 4 because we need to account for the app header. // If the tasks fit within the window, there is no need to scroll. return self.vx.window().height -| 4; }
fn get_completed_tasks_draw_height(self: *TodoApp) !usize { // FIXME: This doesn't account for text wrapping.
var last_date_str: [10]u8 = undefined; var number_of_dates: usize = 0;
// Count the number of different dates. for (self.tasks.items) |task| { const date_str = std.fs.path.stem(task.file_path.items)[0..10]; if (!std.mem.eql(u8, date_str, &last_date_str)) { number_of_dates += 1;
// Update last_date_str. _ = try std.fmt.bufPrint(&last_date_str, "{s}", .{date_str}); } }
// Each new date adds 3 rows, and thus: // draw_height = (3 * number_of_dates) + number_of_tasks. // FIXME: We might technically overflow usize here. return (3 * number_of_dates) + self.tasks.items.len; }
fn scroll_up(self: *TodoApp) void { const active_layout = self.active_layout.getLast();
if (active_layout == .TaskList) { // Window height - 4 because we need to account for the app header. // If the tasks fit within the window, there is no need to scroll. const active_task_list_height = self.get_available_height_for_tasks(); if (self.tasks.items.len < active_task_list_height) { return; }
self.scroll_offset -|= 1; } else if (active_layout == .CompletedTasks) { // FIXME: Don't allow overscroll. self.scroll_offset -|= 1; } }
fn scroll_down(self: *TodoApp) !void { const active_layout = self.active_layout.getLast();
// Window height - 4 because we need to account for the app header. const available_height_for_tasks = self.get_available_height_for_tasks();
if (active_layout == .TaskList) { // If the tasks fit within the window, there is no need to scroll. const max_allowed_offset = self.tasks.items.len -| available_height_for_tasks; if (self.scroll_offset >= max_allowed_offset) { return; }
if (self.tasks.items.len < available_height_for_tasks) { return; }
self.scroll_offset += 1; } else if (active_layout == .CompletedTasks) { const max_allowed_offset = try self.get_completed_tasks_draw_height() -| available_height_for_tasks; if (self.scroll_offset >= max_allowed_offset) { return; }
self.scroll_offset += 1; } }
fn get_selected_task_draw_offset_from_top(self: *TodoApp) !usize { // Only the completed task list adds additional spacing to the view. All other views can // use the index (i.e. location) of the currently selected task. if (self.active_layout.getLast() != .CompletedTasks) return self.selected_active_task;
// Calculate the offset necessary to get to the selected completed task, where we need to // take into account the space taken by the date headers; 3 rows.
// FIXME: This doesn't account for text wrapping. var last_date_str: [10]u8 = undefined; var number_of_dates: usize = 0;
// Count the number of different dates. for (self.tasks.items[0..@min(self.selected_active_task + 1, self.tasks.items.len)]) |task| { const date_str = std.fs.path.stem(task.file_path.items)[0..10]; if (!std.mem.eql(u8, date_str, &last_date_str)) { number_of_dates += 1;
// Update last_date_str. _ = try std.fmt.bufPrint(&last_date_str, "{s}", .{date_str}); } }
// Each new date adds 3 rows, and thus: // draw_height = (3 * number_of_dates) + number_of_tasks. // NOTE: in this case number_of_tasks == self.selected_active_task. // FIXME: We might technically overflow usize here. return (3 * number_of_dates) + self.selected_active_task; }
fn ensure_selected_task_is_visible(self: *TodoApp) !void { const selected_task_offset = try self.get_selected_task_draw_offset_from_top();
// If selected task is above the scroll offset fold we need to scroll up. // Else if selected task is below the scroll offset + window height we need to scroll down. if (selected_task_offset < self.scroll_offset) { self.scroll_offset = selected_task_offset; } else if (selected_task_offset > self.scroll_offset + self.get_available_height_for_tasks()) { self.scroll_offset = selected_task_offset -| self.get_available_height_for_tasks(); } }
/// Update our application state from an event pub fn update(self: *TodoApp, event: Event) !void { switch (event) { .button_clicked => |id| { // FIXME: Cursor from filter view is not properly clicked when appropriate. switch (id) { .None => {}, .DetailsCloseButton => _ = self.active_layout.pop(), .ActiveTasksButton => { if (self.active_layout.getLast() != .TaskList) { // Reset scroll. self.scroll_offset = 0; self.selected_active_task = 0;
try self.reload_tasks(.ActiveTask);
try self.active_layout.append(.TaskList); } }, .CompletedTasksButton => { if (self.active_layout.getLast() != .CompletedTasks) { // Reset scroll. self.scroll_offset = 0; self.selected_active_task = 0;
try self.reload_tasks(.CompletedTask);
try self.active_layout.append(.CompletedTasks); } }, .ClearFilterButton => { // Reset scroll. self.scroll_offset = 0;
// Clear the filter. self.task_filter.clearRetainingCapacity();
// We're ok with popping the layout stack here because we want to go back // to either the completed task or active task layout anyway. while (self.active_layout.getLast() != .TaskList and self.active_layout.getLast() != .CompletedTasks) { _ = self.active_layout.pop();
if (self.active_layout.items.len == 0) { try self.active_layout.append(.TaskList); } }
// Get the right type of task to load in. const task_type: TaskType = switch (self.active_layout.getLast()) { .CompletedTasks => .CompletedTask, else => .ActiveTask, }; try self.reload_tasks(task_type);
self.selected_active_task = 0; }, .ScrollUp => self.scroll_up(), .ScrollDown => try self.scroll_down(), .Task => |task_id| { self.selected_active_task = task_id; try self.active_layout.append(.TaskDetails); }, .Tag => |task_id| { // Reset scroll. self.scroll_offset = 0;
// Reset and update the filter. self.task_filter.clearRetainingCapacity(); try self.task_filter.appendSlice(self.tasks.items[task_id].tags.items);
// Get the right type of task to load in. const task_type: TaskType = switch (self.active_layout.getLast()) { .CompletedTasks => .CompletedTask, else => .ActiveTask, };
if (self.tasks.items.len == 0) { try self.reload_tasks(task_type); } else { // Get file path for current task to see if we can find the same task // after the list has been filtered. const file_path_copy = try self.tasks.items[self.selected_active_task].file_path.clone(); defer file_path_copy.deinit();
try self.reload_tasks(task_type);
// Try to find the same task to keep it selected. for (0..self.tasks.items.len) |i| { if (std.mem.eql(u8, self.tasks.items[i].file_path.items, file_path_copy.items)) { self.selected_active_task = i; break; } } }
// Make sure the selectd row isn't outside allowed bounds. if (self.selected_active_task >= self.tasks.items.len) { self.selected_active_task = self.tasks.items.len -| 1; } }, } }, .key_press => |key| { // key.matches does some basic matching algorithms. Key matching can be complex in // the presence of kitty keyboard encodings, this will generally be a good approach. // There are other matching functions available for specific purposes, as well if (key.matches('c', .{ .ctrl = true })) { self.should_quit = true; } else if (key.matches('q', .{})) { self.should_quit = true; }
// This should never happen, but we add this here just in case. if (self.active_layout.items.len == 0) { try self.active_layout.append(.TaskList); }
switch (self.active_layout.getLast()) { .TaskList => { // Movement if (key.matchesAny(&.{ vaxis.Key.up, 'k' }, .{})) { self.selected_active_task -|= 1;
try self.ensure_selected_task_is_visible(); } if (key.matchesAny(&.{ vaxis.Key.down, 'j' }, .{})) { if (self.selected_active_task < self.tasks.items.len) { self.selected_active_task += 1;
try self.ensure_selected_task_is_visible(); } } if (key.matches('g', .{})) { self.selected_active_task = 0;
try self.ensure_selected_task_is_visible(); } if (key.matches('G', .{})) { self.selected_active_task = self.tasks.items.len -| 1;
try self.ensure_selected_task_is_visible(); }
// Move tasks down. if (key.matches('J', .{}) or key.matches(vaxis.Key.down, .{ .shift = true })) { try self.swap_tasks(self.selected_active_task, self.selected_active_task + 1); self.selected_active_task += 1;
try self.ensure_selected_task_is_visible(); } // Move tasks up. if (key.matches('K', .{}) or key.matches(vaxis.Key.up, .{ .shift = true })) { try self.swap_tasks(self.selected_active_task, self.selected_active_task -| 1); self.selected_active_task -|= 1;
try self.ensure_selected_task_is_visible(); }
// Make sure the active table row never exceeds the number of tasks. if (self.selected_active_task >= self.tasks.items.len) { self.selected_active_task = self.tasks.items.len -| 1; }
// Actions.
// Open task details. if (key.matchesAny(&.{ vaxis.Key.enter, 'l' }, .{})) { try self.active_layout.append(.TaskDetails); }
// Reload list. if (key.matches('r', .{})) { try self.reload_tasks(.ActiveTask); }
// Complete a task. if (key.matches('c', .{})) { try self.complete_task(self.selected_active_task); }
// Edit task. if (key.matches('e', .{})) { try self.edit_task(self.tasks.items[self.selected_active_task]); }
// Create new task. if (key.matches('A', .{})) { try self.create_new_task(); }
// Start filter input. if (key.matches('f', .{})) { try self.active_layout.append(.TaskFilter); }
// Toggle the display of tags. if (key.matches('H', .{})) { self.should_show_tags_in_task_list = !self.should_show_tags_in_task_list; }
// Switch to completed task view. if (key.matches(vaxis.Key.tab, .{})) { try self.reload_tasks(.CompletedTask);
try self.active_layout.append(.CompletedTasks);
// Reset scroll. self.scroll_offset = 0; self.selected_active_task = 0; } }, .TaskDetails => { const task = self.tasks.items[self.selected_active_task];
if (key.matchesAny(&.{ vaxis.Key.escape, 'h' }, .{})) { _ = self.active_layout.pop(); }
if (key.matches('c', .{})) { try self.complete_task(self.selected_active_task);
_ = self.active_layout.pop(); }
if (key.matches('e', .{})) { try self.edit_task(task); } }, .TaskFilter => { if (key.matches(vaxis.Key.enter, .{})) { // Reset scroll. self.scroll_offset = 0;
// Reset the current filter. self.task_filter.clearRetainingCapacity();
// Get the text from the filter input. // .toOwnedSlice() resets the input, so no further action needed for // the input. const filter = try self.task_filter_input.toOwnedSlice(); defer self.allocator.free(filter);
// Update the filter. try self.task_filter.appendSlice(filter);
// Switch back to the previous layout. _ = self.active_layout.pop();
// Make sure the cursor is hidden. self.vx.window().hideCursor();
// Get the right type of task to load in. const task_type: TaskType = switch (self.active_layout.getLast()) { .CompletedTasks => .CompletedTask, else => .ActiveTask, };
if (self.tasks.items.len == 0) { try self.reload_tasks(task_type); } else { // Get file path for current task to see if we can find the same task // after the list has been filtered. const file_path_copy = try self.tasks.items[self.selected_active_task].file_path.clone(); defer file_path_copy.deinit();
try self.reload_tasks(task_type);
// Try to find the same task to keep it selected. for (0..self.tasks.items.len) |i| { if (std.mem.eql(u8, self.tasks.items[i].file_path.items, file_path_copy.items)) { self.selected_active_task = i; break; } } }
// Make sure the selectd row isn't outside allowed bounds. if (self.selected_active_task >= self.tasks.items.len) { self.selected_active_task = self.tasks.items.len; } } else { try self.task_filter_input.update(.{ .key_press = key }); } }, .CompletedTasks => { // Switch to task list view. if (key.matches(vaxis.Key.tab, .{})) { try self.reload_tasks(.ActiveTask);
try self.active_layout.append(.TaskList);
// Reset scroll. self.scroll_offset = 0; self.selected_active_task = 0; }
// Movement if (key.matchesAny(&.{ vaxis.Key.up, 'k' }, .{})) { self.selected_active_task -|= 1;
try self.ensure_selected_task_is_visible(); } if (key.matchesAny(&.{ vaxis.Key.down, 'j' }, .{})) { if (self.selected_active_task < self.tasks.items.len) { self.selected_active_task += 1;
try self.ensure_selected_task_is_visible(); } } if (key.matches('g', .{})) { self.selected_active_task = 0;
try self.ensure_selected_task_is_visible(); } if (key.matches('G', .{})) { self.selected_active_task = self.tasks.items.len -| 1;
try self.ensure_selected_task_is_visible(); }
// Make sure the active table row never exceeds the number of tasks. if (self.selected_active_task >= self.tasks.items.len) { self.selected_active_task = self.tasks.items.len -| 1; }
// Actions.
// Open task details. if (key.matchesAny(&.{ vaxis.Key.enter, 'l' }, .{})) { try self.active_layout.append(.TaskDetails); // FIXME: This should be removed after we convert the completed task list. self.selected_active_task = self.selected_active_task; }
if (key.matches('r', .{})) { try self.reload_tasks(.CompletedTask); }
// Start filter input. if (key.matches('f', .{})) { try self.active_layout.append(.TaskFilter); }
// Toggle the display of tags. if (key.matches('H', .{})) { self.should_show_tags_in_task_list = !self.should_show_tags_in_task_list; } }, } }, .mouse => |mouse| { self.mouse = mouse;
switch (mouse.button) { .wheel_up => self.scroll_up(), .wheel_down => try self.scroll_down(), else => {}, } }, .winsize => |ws| try self.vx.resize(self.allocator, self.tty.anyWriter(), ws), else => {}, } }
/// Draw our current state pub fn draw(self: *TodoApp) !void { // Window is a bounded area with a view to the screen. You cannot draw outside of a windows // bounds. They are light structures, not intended to be stored. const win = self.vx.window();
// Clearing the window has the effect of setting each cell to it's "default" state. Vaxis // applications typically will be immediate mode, and you will redraw your entire // application during the draw cycle. win.clear();
// In addition to clearing our window, we want to clear the mouse shape state since we may // be changing that as well self.vx.setMouseShape(.default);
// Draw scrollbars first. try self.draw_scroll_bars();
switch (self.active_layout.getLast()) { .TaskList => try self.draw_task_list(), .TaskDetails => try self.draw_task_details(), .TaskFilter => try self.draw_task_filter(), .CompletedTasks => try self.draw_completed_tasks(), } }
fn draw_scroll_bars(self: *TodoApp) !void { const win = self.vx.window();
const can_scroll = can_scroll: { const active_layout = self.active_layout.getLast();
// Available height = window_height - title_bar_height. const available_height_for_tasks = win.height - 3;
if (active_layout == .TaskList) { // Scrolling can only happen when the number of tasks exceeds the available // height. if (self.tasks.items.len > available_height_for_tasks) { break :can_scroll true; } } else if (active_layout == .CompletedTasks) { if (try self.get_completed_tasks_draw_height() > available_height_for_tasks) { break :can_scroll true; } }
// We only allow scrolling for the active and completed task lists. break :can_scroll false; };
// Draw the borders marking the dimensions of the scroll bar. // _ = win.child(.{ // .x_off = win.width - 2, // .y_off = 3, // .width = .{ .limit = 3 }, // .height = .{ .limit = win.height - 3 }, // .border = .{ .where = .{ .other = .{ .left = true, .right = true } } }, // });
// Draw container bar. // _ = win.child(.{ // .x_off = win.width - 3, // .y_off = 6, // .width = .{ .limit = 3 }, // .height = .{ .limit = win.height - 9 }, // .border = .{ .where = .left }, // });
// Draw scroll up button. const up_button = win.child(.{ .x_off = win.width - 1, .y_off = 3, .width = .{ .limit = 1 }, .height = .{ .limit = 1 }, // .border = .{ .where = .bottom }, });
// Process clicks. if (up_button.hasMouse(self.mouse)) |mouse| { // If we can't scroll, disable the button. if (can_scroll) { switch (mouse.type) { .press => if (mouse.button == .left) { self.button_currently_down = .ScrollUp; }, .release => { if (self.button_currently_down.eql(.ScrollUp)) { _ = self.loop.?.tryPostEvent(.{ .button_clicked = .ScrollUp }); }
self.button_currently_down = .None; }, else => {}, } } }
// Get button and mouse style. const up_button_style = up_style: { // If we can't scroll, disable the button. if (!can_scroll) { break :up_style default_style; }
if (up_button.hasMouse(self.mouse)) |_| { self.vx.setMouseShape(.pointer);
break :up_style hover_style; } break :up_style default_style; };
_ = try up_button.printSegment(.{ .text = "\u{2191}", .style = up_button_style }, .{});
// Draw scroll down button. const down_button = win.child(.{ .x_off = win.width - 1, .y_off = win.height - 1, .width = .{ .limit = 1 }, .height = .{ .limit = 1 }, });
// Process clicks. if (down_button.hasMouse(self.mouse)) |mouse| { // If we can't scroll, disable the button. if (can_scroll) { switch (mouse.type) { .press => if (mouse.button == .left) { self.button_currently_down = .ScrollDown; }, .release => { if (self.button_currently_down.eql(.ScrollDown)) { _ = self.loop.?.tryPostEvent(.{ .button_clicked = .ScrollDown }); }
self.button_currently_down = .None; }, else => {}, } } }
// Get up button and mouse style. const down_button_style = down_style: { // If we can't scroll, disable the button. if (!can_scroll) { break :down_style default_style; }
if (down_button.hasMouse(self.mouse)) |_| { self.vx.setMouseShape(.pointer);
break :down_style hover_style; } break :down_style default_style; };
_ = try down_button.printSegment(.{ .text = "\u{2193}", .style = down_button_style }, .{});
// Draw movable plane in scrollbar for scrolling, if we actually can scroll. if (can_scroll) { // Available height = window_height - title_bar_height - up_button_height - down_button_height. const available_height_for_scroll_pane = @as(f64, @floatFromInt(win.height - 3 - 1 - 1)); // Available height = window_height - title_bar_height. const available_height_for_tasks = @as(f64, @floatFromInt(win.height - 3));
const task_draw_height_f = switch (self.active_layout.getLast()) { .TaskList => @as(f64, @floatFromInt(self.tasks.items.len)), .CompletedTasks => @as(f64, @floatFromInt(try self.get_completed_tasks_draw_height())), else => unreachable, };
// Get pane height, converted back to usize so we know what height to use. const pane_height = @as(usize, @intFromFloat((available_height_for_tasks / task_draw_height_f) * available_height_for_scroll_pane));
const scroll_offset_f = @as(f64, @floatFromInt(self.scroll_offset));
const pane_offset = @as(usize, @intFromFloat((available_height_for_scroll_pane / task_draw_height_f) * scroll_offset_f));
const plane = win.child(.{ .x_off = win.width - 1, .y_off = 4 + pane_offset, .width = .{ .limit = 1 }, .height = .{ .limit = @max(pane_height, 1) }, // Make sure height is always at least 1 cell. });
plane.fill(.{ .style = .{ .fg = .default, .reverse = true } }); } }
fn draw_title_bar(self: *TodoApp) !void { const window = self.vx.window();
const title_bar_box = window.child(.{ .x_off = 0, .y_off = 0, .width = .{ .limit = window.width }, .height = .{ .limit = 3 }, .border = .{ .where = .bottom }, });
const title_box = title_bar_box.child(.{ .x_off = 0, .y_off = 0, .width = .{ .limit = 5 }, .height = .{ .limit = 1 } });
_ = try title_box.printSegment(.{ .text = "TODUI" }, .{});
const active_button_box = title_bar_box.child(.{ .x_off = 0, .y_off = 1, .width = .{ .limit = 8 }, .height = .{ .limit = 1 }, }); const completed_button_box = title_bar_box.child(.{ .x_off = active_button_box.width + 1, .y_off = 1, .width = .{ .limit = 11 }, .height = .{ .limit = 1 }, }); const clear_filter_button_box = title_bar_box.child(.{ .x_off = active_button_box.width + completed_button_box.width + 2, .y_off = 1, .width = .{ .limit = "[Clear filter]".len }, .height = .{ .limit = 1 } });
// Process active_task_button_clicks. if (active_button_box.hasMouse(self.mouse)) |mouse| { self.vx.setMouseShape(.pointer);
switch (mouse.type) { .press => if (mouse.button == .left) { self.button_currently_down = .ActiveTasksButton; }, .release => { if (self.button_currently_down.eql(.ActiveTasksButton)) { _ = self.loop.?.tryPostEvent(.{ .button_clicked = .ActiveTasksButton }); } self.button_currently_down = .None; }, else => {}, } }
// Get active_task_button style. const active_box_style: vaxis.Cell.Style = blk: { if (self.active_layout.getLast() == .TaskList) break :blk active_style;
if (active_button_box.hasMouse(self.mouse)) |mouse| { switch (mouse.type) { .press => if (mouse.button == .left) break :blk active_style else break :blk hover_style, else => break :blk hover_style, } } else { break :blk default_style; } };
// Process completed_task_button_clicks. if (completed_button_box.hasMouse(self.mouse)) |mouse| { self.vx.setMouseShape(.pointer);
switch (mouse.type) { .press => if (mouse.button == .left) { self.button_currently_down = .CompletedTasksButton; }, .release => { if (self.button_currently_down.eql(.CompletedTasksButton)) { _ = self.loop.?.tryPostEvent(.{ .button_clicked = .CompletedTasksButton }); } self.button_currently_down = .None; }, else => {}, } }
// Get completed_task_button style. const completed_box_style: vaxis.Cell.Style = blk: { if (self.active_layout.getLast() == .CompletedTasks) break :blk active_style;
if (completed_button_box.hasMouse(self.mouse)) |mouse| { switch (mouse.type) { .press => if (mouse.button == .left) break :blk active_style else break :blk hover_style, else => break :blk hover_style, } } else { break :blk default_style; } };
// Process completed_task_button_clicks. if (clear_filter_button_box.hasMouse(self.mouse)) |mouse| { self.vx.setMouseShape(.pointer);
switch (mouse.type) { .press => if (mouse.button == .left) { self.button_currently_down = .ClearFilterButton; }, .release => { if (self.button_currently_down.eql(.ClearFilterButton)) { _ = self.loop.?.tryPostEvent(.{ .button_clicked = .ClearFilterButton }); } self.button_currently_down = .None; }, else => {}, } }
// Get clear_filter_button style. const clear_filter_box_style: vaxis.Cell.Style = blk: { if (clear_filter_button_box.hasMouse(self.mouse)) |mouse| { switch (mouse.type) { .press => if (mouse.button == .left) break :blk active_style else break :blk hover_style, else => break :blk hover_style, } } else { break :blk default_style; } };
_ = try active_button_box.printSegment(.{ .text = "[Active]", .style = active_box_style }, .{}); _ = try completed_button_box.printSegment(.{ .text = "[Completed]", .style = completed_box_style }, .{}); _ = try clear_filter_button_box.printSegment(.{ .text = "[Clear filter]", .style = clear_filter_box_style }, .{}); }
fn draw_task_list(self: *TodoApp) !void { try self.draw_title_bar();
// Make sure there's space for the scroll-bar. const window = self.vx.window().child(.{ .x_off = 0, .y_off = 0, .width = .{ .limit = self.vx.window().width - 2 }, .height = .{ .limit = self.vx.window().height }, });
var y_off: usize = 3; for (self.tasks.items[self.scroll_offset..self.tasks.items.len], self.scroll_offset..) |task, i| { // FIXME: Make sure we don't divide by zero. var task_title_box = window.child(.{ .x_off = 0, .y_off = y_off, .width = .{ .limit = @min(window.width, task.title.items.len + 1) }, .height = .{ .limit = 1 }, }); // First pass at printing to get the overflow result. const print_result = try task_title_box.printSegment(.{ .text = task.title.items }, .{ .wrap = .word, .commit = false });
// Update the box if there was an overflow. if (print_result.overflow) { task_title_box = window.child(.{ .x_off = 0, .y_off = y_off, .width = .{ .limit = window.width }, .height = .{ .limit = print_result.row + 1 }, }); }
// FIXME: Handle multiple tags better. const task_tag_box = window.child(.{ .x_off = print_result.col + 2, .y_off = y_off + print_result.row, .width = .{ .limit = task.tags.items.len }, .height = .{ .limit = 1 }, });
// Update the y offset based on the printed row offset. y_off +|= print_result.row + 1;
// Process clicks. if (task_title_box.hasMouse(self.mouse)) |mouse| { // Use a pointer when hovering tasks. self.vx.setMouseShape(.pointer);
switch (mouse.type) { .press => if (mouse.button == .left) { self.button_currently_down = .{ .Task = i }; }, .release => { if (self.button_currently_down.eql(.{ .Task = i })) { _ = self.loop.?.tryPostEvent(.{ .button_clicked = .{ .Task = i } }); } self.button_currently_down = .None; }, else => {}, } }
// Process tag clicks. if (task_tag_box.hasMouse(self.mouse)) |mouse| { // Use a pointer when hovering tasks. self.vx.setMouseShape(.pointer);
switch (mouse.type) { .press => if (mouse.button == .left) { self.button_currently_down = .{ .Tag = i }; }, .release => { if (self.button_currently_down.eql(.{ .Tag = i })) { _ = self.loop.?.tryPostEvent(.{ .button_clicked = .{ .Tag = i } }); } self.button_currently_down = .None; }, else => {}, } }
// Set the style for the row. const style: vaxis.Cell.Style = blk: { // Active task always has a bright background. if (self.selected_active_task == i) { break :blk active_style; }
// Hovered tasks have a slightly faded background. if (task_title_box.hasMouse(self.mouse)) |_| { break :blk hover_style; } else { break :blk default_style; } };
const tag_style: vaxis.Cell.Style = blk: { if (task_tag_box.hasMouse(self.mouse)) |_| { break :blk .{ .fg = .{ .index = 5 }, .reverse = true }; }
break :blk .{ .fg = .{ .index = 5 } }; };
_ = try task_title_box.printSegment(.{ .text = task.title.items, .style = style }, .{ .wrap = .word }); _ = try task_tag_box.printSegment(.{ .text = task.tags.items, .style = tag_style }, .{}); } }
fn draw_task_details(self: *TodoApp) !void { const win = self.vx.window();
try self.draw_title_bar();
const overlay = win.child(.{ .x_off = 0, .y_off = 2, .width = .{ .limit = win.width }, .height = .{ .limit = win.height - 4 }, .border = .{ .where = .{ .other = .{ .top = true, .bottom = true } } }, });
overlay.clear();
// Draw close button. // FIXME: Abstract the idea of a button. const close_button_box = overlay.child(.{ .x_off = 0, .y_off = 0, .width = .{ .limit = 7 }, .height = .{ .limit = 1 }, });
// Process close button click. if (close_button_box.hasMouse(self.mouse)) |mouse| { self.vx.setMouseShape(.pointer);
switch (mouse.type) { .press => if (mouse.button == .left) { self.button_currently_down = .DetailsCloseButton; }, .release => { if (self.button_currently_down.eql(.DetailsCloseButton)) { _ = self.loop.?.tryPostEvent(.{ .button_clicked = .DetailsCloseButton }); } self.button_currently_down = .None; }, else => {}, } }
// Get close button style. const style: vaxis.Cell.Style = blk: { // Give hovered button a hover background. if (close_button_box.hasMouse(self.mouse)) |mouse| { switch (mouse.type) { .press => if (mouse.button == .left) { break :blk active_style; }, else => {}, }
break :blk hover_style; } else { break :blk default_style; } }; _ = try close_button_box.printSegment(.{ .text = "[Close]", .style = style }, .{});
const task = self.tasks.items[self.selected_active_task];
const title_to_width_ratio = task.title.items.len / win.width;
const title_box = overlay.child(.{ .x_off = 0, .y_off = 1, .width = .{ .limit = overlay.width }, .height = .{ .limit = title_to_width_ratio + 1 }, });
const tags_box = overlay.child(.{ .x_off = 0, .y_off = 2 + title_to_width_ratio, .width = .{ .limit = overlay.width }, .height = .{ .limit = 1 }, });
const details_box = overlay.child(.{ .x_off = 0, .y_off = 3 + title_to_width_ratio + 1, .width = .{ .limit = overlay.width }, .height = .{ .limit = overlay.height - 2 }, });
// If the title fits within the window width, center the title. // Otherwise, left align it and make sure it wraps. if (title_to_width_ratio == 0) { _ = try title_box.printSegment(.{ .text = task.title.items }, .{ .col_offset = (title_box.width / 2) -| (task.title.items.len / 2) }); } else { _ = try title_box.printSegment(.{ .text = task.title.items }, .{ .col_offset = 0 }); } _ = try tags_box.printSegment(.{ .text = task.tags.items }, .{ .col_offset = (tags_box.width / 2) -| (task.tags.items.len / 2) }); _ = try details_box.printSegment(.{ .text = task.details.items }, .{}); }
fn draw_task_filter(self: *TodoApp) !void { try self.draw_title_bar();
const win = self.vx.window(); const overlay = win.child(.{ .x_off = 0, .y_off = 2, .width = .{ .limit = win.width - 2 }, .height = .{ .limit = win.height - 4 }, .border = .{ .where = .top }, });
overlay.clear();
const label_container = overlay.child(.{ .x_off = 2, .y_off = 2, .width = .{ .limit = overlay.width - 4 }, .height = .{ .limit = 1 }, }); _ = try label_container.printSegment(.{ .text = "Filter tasks by tag:" }, .{});
const input_container = overlay.child(.{ .x_off = 2, .y_off = 3, .width = .{ .limit = overlay.width - 4 }, .height = .{ .limit = 3 }, .border = .{ .where = .all }, });
self.task_filter_input.draw(input_container); }
fn draw_completed_tasks(self: *TodoApp) !void { const draw_table_allocator = self.arena_allocator.allocator();
var task_list = std.ArrayList(struct { title: []const u8, tags: []const u8, file_path: []const u8 }).init(draw_table_allocator);
for (self.tasks.items[0..self.tasks.items.len]) |task| { try task_list.append(.{ .title = task.title.items, .tags = task.tags.items, .file_path = task.file_path.items }); }
try self.draw_title_bar();
// Make sure there's space for the scroll-bar. const window = self.vx.window().child(.{ .x_off = 0, .y_off = 3, .width = .{ .limit = self.vx.window().width - 2 }, .height = .{ .limit = self.vx.window().height }, });
var y_off: usize = 0; var last_date_str: [10]u8 = undefined;
for (task_list.items, 0..) |task, i| { const date_str = std.fs.path.stem(task.file_path)[0..10]; if (!std.mem.eql(u8, date_str, &last_date_str)) { y_off +|= 1;
// Only draw if we've hit the scroll offset. if (y_off >= self.scroll_offset) { const task_date_box = window.child(.{ .x_off = 0, .y_off = y_off -| self.scroll_offset, .width = .{ .limit = window.width }, .height = .{ .limit = 2 }, .border = .{ .where = .bottom }, }); _ = try task_date_box.printSegment(.{ .text = date_str }, .{}); }
y_off +|= 2;
_ = try std.fmt.bufPrint(&last_date_str, "{s}", .{date_str}); }
var task_title_box = window.child(.{ .x_off = 0, .y_off = y_off -| self.scroll_offset, .width = .{ .limit = @min(window.width, task.title.len + 1) }, .height = .{ .limit = 1 }, }); // First pass at printing to get the overflow result. const print_result = try task_title_box.printSegment(.{ .text = task.title }, .{ .wrap = .word, .commit = false });
// Update the box if there was an overflow. if (print_result.overflow) { task_title_box = window.child(.{ .x_off = 0, .y_off = y_off -| self.scroll_offset, .width = .{ .limit = window.width }, .height = .{ .limit = print_result.row + 1 }, }); }
// FIXME: Handle multiple tags better. const task_tag_box = window.child(.{ .x_off = print_result.col + 2, .y_off = y_off + print_result.row -| self.scroll_offset, .width = .{ .limit = task.tags.len }, .height = .{ .limit = 1 }, });
// Update the y offset based on the printed row offset. y_off +|= print_result.row + 1;
// Only process clicks if we've hit the scroll offset. if (y_off >= self.scroll_offset) { // Process clicks. if (task_title_box.hasMouse(self.mouse)) |mouse| { // Use a pointer when hovering tasks. self.vx.setMouseShape(.pointer);
switch (mouse.type) { .press => if (mouse.button == .left) { self.button_currently_down = .{ .Task = i }; }, .release => { if (self.button_currently_down.eql(.{ .Task = i })) { _ = self.loop.?.tryPostEvent(.{ .button_clicked = .{ .Task = i } }); }
self.button_currently_down = .None; }, else => {}, } }
// Process tag clicks. if (task_tag_box.hasMouse(self.mouse)) |mouse| { // Use a pointer when hovering tasks. self.vx.setMouseShape(.pointer);
switch (mouse.type) { .press => if (mouse.button == .left) { self.button_currently_down = .{ .Tag = i }; }, .release => { if (self.button_currently_down.eql(.{ .Tag = i })) { _ = self.loop.?.tryPostEvent(.{ .button_clicked = .{ .Tag = i } }); } self.button_currently_down = .None; }, else => {}, } } }
// Set the style for the row. const style: vaxis.Cell.Style = blk: { // Active task always has a bright background. if (self.selected_active_task == i) { break :blk active_style; }
// Hovered tasks have a slightly faded background. if (task_title_box.hasMouse(self.mouse)) |_| { break :blk hover_style; } else { break :blk default_style; } };
const tag_style: vaxis.Cell.Style = blk: { if (task_tag_box.hasMouse(self.mouse)) |_| { break :blk .{ .fg = .{ .index = 5 }, .reverse = true }; }
break :blk .{ .fg = .{ .index = 5 } }; };
// Only draw if we've hit the scroll offset. if (y_off >= self.scroll_offset) { _ = try task_title_box.printSegment(.{ .text = task.title, .style = style }, .{ .wrap = .word }); _ = try task_tag_box.printSegment(.{ .text = task.tags, .style = tag_style }, .{}); } } }};
fn get_todo_file_storage_path_caller_should_free(allocator: std.mem.Allocator) ![]const u8 { return try std.fs.path.join(allocator, &.{ data_storage_path, "todo" });}
fn get_completed_todo_file_storage_path_caller_should_free(allocator: std.mem.Allocator) ![]const u8 { return try std.fs.path.join(allocator, &.{ data_storage_path, "todo", "completed" });}
fn get_todo_app_log_storage_path(allocator: std.mem.Allocator) ![]const u8 { // NOTE: We're not using the global data_storage_path because this function gets called from // the custom logger function, which can run both before and after the memory in // data_storage_path is initialized or freed. const data_path = try known_folders.getPath(allocator, known_folders.KnownFolder.data); defer { if (data_path) |p| { allocator.free(p); } }
if (data_path) |p| { return try std.fs.path.join(allocator, &.{ p, "todo", "logs" }); }
unreachable;}
fn get_file_number(file_name: []const u8) !usize { const stem = std.fs.path.stem(file_name); return try std.fmt.parseInt(usize, stem, 10);}
fn compare_tasks(context: void, a: Task, b: Task) bool { // Parse file name stem into i32. const number_a = get_file_number(a.file_path.items) catch |err| switch (err) { else => 0, }; const number_b = get_file_number(b.file_path.items) catch |err| switch (err) { else => 0, };
return std.sort.asc(usize)(context, number_a, number_b);}
fn compare_task_file_names(_: void, a: Task, b: Task) bool { return std.mem.order(u8, a.file_path.items, b.file_path.items) == .gt;}
fn log_to_file(comptime message_level: std.log.Level, comptime scope: @TypeOf(.enum_literal), comptime format: []const u8, args: anytype) void { // Get level text and log prefix. // See https://ziglang.org/documentation/master/std/#std.log.defaultLog. const level_txt = comptime message_level.asText(); const prefix2 = if (scope == .default) ": " else "(" ++ @tagName(scope) ++ "): ";
// Get an allocator to use for getting path to the log file. var gpa = std.heap.GeneralPurposeAllocator(.{}){}; defer { const deinit_status = gpa.deinit(); //fail test; can't try in defer as defer is executed after we return if (deinit_status == .leak) { std.log.err("memory leak in custom logger", .{}); } }
const allocator = gpa.allocator();
// Get the log directory. const log_directory_path = get_todo_app_log_storage_path(allocator) catch return; defer allocator.free(log_directory_path);
// Make sure log directory exists. std.fs.makeDirAbsolute(log_directory_path) catch |err| switch (err) { error.PathAlreadyExists => {}, else => return, };
// Construct the absolute path to the log file. const log_file_path = std.fs.path.join(allocator, &.{ log_directory_path, "debug.log" }) catch return; defer allocator.free(log_file_path);
// Open the log file, create it if doesn't already exist. const log = std.fs.openFileAbsolute(log_file_path, .{ .mode = .write_only }) catch |err| switch (err) { error.FileNotFound => std.fs.createFileAbsolute(log_file_path, .{}) catch return, else => return, };
log.seekFromEnd(0) catch return;
// Get a writer. // See https://ziglang.org/documentation/master/std/#std.log.defaultLog. const log_writer = log.writer(); var bw = std.io.bufferedWriter(log_writer); const writer = bw.writer();
// Write to the log file. // See https://ziglang.org/documentation/master/std/#std.log.defaultLog. nosuspend { writer.print(level_txt ++ prefix2 ++ format ++ "\n", args) catch return; bw.flush() catch return; }}
/// Global reference to where the data, logs, and config should be stored.var data_storage_path: []u8 = undefined;
/// Keep our main function small. Typically handling arg parsing and initialization onlypub fn main() !void { var gpa = std.heap.GeneralPurposeAllocator(.{}){}; defer { const deinit_status = gpa.deinit(); //fail test; can't try in defer as defer is executed after we return if (deinit_status == .leak) { std.log.err("memory leak", .{}); } } const allocator = gpa.allocator();
// Process arguments. const args = try std.process.argsAlloc(allocator); defer std.process.argsFree(allocator, args);
if (args.len > 1 and (std.mem.eql(u8, args[1], "--help") or std.mem.eql(u8, args[1], "-h"))) { const writer = std.io.getStdOut().writer(); try writer.print("Usage: todui [storage_folder]\n", .{}); try writer.print("\n", .{}); try writer.print("Positional options:\n", .{}); try writer.print("\n", .{}); try writer.print(" [storage_folder] The path to where todui data should be stored.\n", .{}); try writer.print(" Defaults to ~/.local/share/todo/ when no path is provided.\n", .{}); try writer.print("\n", .{}); try writer.print("General options:\n", .{}); try writer.print("\n", .{}); try writer.print(" -h, --help Print todui help\n", .{}); try writer.print(" -v, --version Print todui version\n", .{}); std.process.exit(0); } if (args.len > 1 and (std.mem.eql(u8, args[1], "--version") or std.mem.eql(u8, args[1], "-v"))) { const writer = std.io.getStdOut().writer(); try writer.print("2024.11.2-dev\n", .{}); std.process.exit(0); } if (args.len > 1) { const cwd = std.fs.cwd(); cwd.makePath(args[args.len - 1]) catch |err| switch (err) { error.PathAlreadyExists => {}, else => return err, };
data_storage_path = try cwd.realpathAlloc(allocator, args[args.len - 1]); } else { const storage_path = (try known_folders.getPath(allocator, known_folders.KnownFolder.data)) orelse unreachable; defer allocator.free(storage_path); data_storage_path = try allocator.alloc(u8, storage_path.len); std.mem.copyForwards(u8, data_storage_path, storage_path); } defer allocator.free(data_storage_path);
// Initialize our application var app = try TodoApp.init(allocator); defer app.deinit();
// Run the application try app.run();}
test "simple test" { var list = std.ArrayList(i32).init(std.testing.allocator); defer list.deinit(); // try commenting this out and see if zig detects the memory leak! try list.append(42); try std.testing.expectEqual(@as(i32, 42), list.pop());}