const 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 occur pub const panic = vaxis.panic_handler; /// Set some scope levels for the vaxis scopes pub 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 events const 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 state const 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-. 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 only pub 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()); }