diff --git a/build.zig.zon b/build.zig.zon index d04bd53..577381e 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -18,8 +18,8 @@ .hash = "1220220dbc7fe91c1c54438193ca765cebbcb7d58f35cdcaee404a9d2245a42a4362", }, .thespian = .{ - .url = "https://github.com/neurocyte/thespian/archive/d2b65df7c59b58f5e86b1ce3403cd3589d06a367.tar.gz", - .hash = "122040a2dcb9569ee26ae9c205f944db4ee9e3eb7c9d5ec610c7d84cf82ab6909fa9", + .url = "https://github.com/neurocyte/thespian/archive/fdf7a0c3bb9738b68895cbde63dbae00b74f5a73.tar.gz", + .hash = "1220042d96513aec54ec3c78dcb28e2529d5f45d0c37bf676860f77f26dce590f9f8", }, .themes = .{ .url = "https://github.com/neurocyte/flow-themes/releases/download/master-770924d1aba9cc33440b66e865bd4f7dca871074/flow-themes.tar.gz", diff --git a/src/buffer/Buffer.zig b/src/buffer/Buffer.zig index 215a179..60fd470 100644 --- a/src/buffer/Buffer.zig +++ b/src/buffer/Buffer.zig @@ -39,6 +39,7 @@ file_exists: bool = true, file_eol_mode: EolMode = .lf, last_save_eol_mode: EolMode = .lf, file_utf8_sanitized: bool = false, +hidden: bool = false, undo_history: ?*UndoNode = null, redo_history: ?*UndoNode = null, diff --git a/src/buffer/Manager.zig b/src/buffer/Manager.zig index 673e311..4cc0024 100644 --- a/src/buffer/Manager.zig +++ b/src/buffer/Manager.zig @@ -31,6 +31,7 @@ pub fn open_file(self: *Self, file_path: []const u8) Buffer.LoadFromFileError!*B break :blk buffer; }; buffer.update_last_used_time(); + buffer.hidden = false; return buffer; } @@ -44,6 +45,7 @@ pub fn open_scratch(self: *Self, file_path: []const u8, content: []const u8) Buf break :blk buffer; }; buffer.update_last_used_time(); + buffer.hidden = false; return buffer; } @@ -60,17 +62,22 @@ pub fn delete_buffer(self: *Self, file_path: []const u8) bool { pub fn retire(_: *Self, _: *Buffer) void {} pub fn list_most_recently_used(self: *Self, allocator: std.mem.Allocator) error{OutOfMemory}![]*Buffer { - var buffers: std.ArrayListUnmanaged(*Buffer) = .{}; - var i = self.buffers.iterator(); - while (i.next()) |kv| - (try buffers.addOne(allocator)).* = kv.value_ptr.*; + const result = try self.list_unordered(allocator); - std.mem.sort(*Buffer, buffers.items, {}, struct { + std.mem.sort(*Buffer, result, {}, struct { fn less_fn(_: void, lhs: *Buffer, rhs: *Buffer) bool { return lhs.utime > rhs.utime; } }.less_fn); + return result; +} + +pub fn list_unordered(self: *Self, allocator: std.mem.Allocator) error{OutOfMemory}![]*Buffer { + var buffers = try std.ArrayListUnmanaged(*Buffer).initCapacity(allocator, self.buffers.size); + var i = self.buffers.iterator(); + while (i.next()) |kv| + (try buffers.addOne(allocator)).* = kv.value_ptr.*; return buffers.toOwnedSlice(allocator); } @@ -93,3 +100,12 @@ pub fn save_all(self: *const Self) Buffer.StoreToFileError!void { try buffer.store_to_file_and_clean(buffer.file_path); } } + +pub fn delete_all(self: *Self) void { + var i = self.buffers.iterator(); + while (i.next()) |p| { + self.allocator.free(p.key_ptr.*); + p.value_ptr.*.deinit(); + } + self.buffers.clearRetainingCapacity(); +} diff --git a/src/keybind/builtin/flow.json b/src/keybind/builtin/flow.json index df31630..b3399a9 100644 --- a/src/keybind/builtin/flow.json +++ b/src/keybind/builtin/flow.json @@ -1,6 +1,8 @@ { "project": { "press": [ + ["ctrl+tab", "next_tab"], + ["ctrl+shift+tab", "previous_tab"], ["ctrl+shift+e", "switch_buffers"], ["ctrl+0", "reset_fontsize"], ["ctrl+plus", "adjust_fontsize", 1.0], diff --git a/src/renderer/vaxis/Plane.zig b/src/renderer/vaxis/Plane.zig index 702103f..e1dd9d3 100644 --- a/src/renderer/vaxis/Plane.zig +++ b/src/renderer/vaxis/Plane.zig @@ -104,6 +104,10 @@ pub fn abs_yx_to_rel(self: Plane, y: c_int, x: c_int) struct { c_int, c_int } { return .{ y - self.abs_y(), x - self.abs_x() }; } +pub fn abs_y_to_rel(self: Plane, y: c_int) c_int { + return y - self.abs_y(); +} + pub fn rel_yx_to_abs(self: Plane, y: c_int, x: c_int) struct { c_int, c_int } { return .{ self.abs_y() + y, self.abs_x() + x }; } diff --git a/src/tui/WidgetList.zig b/src/tui/WidgetList.zig index 2840917..e62b6f3 100644 --- a/src/tui/WidgetList.zig +++ b/src/tui/WidgetList.zig @@ -97,6 +97,16 @@ pub fn remove(self: *Self, w: Widget) void { self.widgets.orderedRemove(i).widget.deinit(self.allocator); } +pub fn remove_all(self: *Self) void { + for (self.widgets.items) |*w| + w.widget.deinit(self.allocator); + self.widgets.clearRetainingCapacity(); +} + +pub fn pop(self: *Self) ?Widget { + return if (self.widgets.popOrNull()) |ws| ws.widget else null; +} + pub fn empty(self: *const Self) bool { return self.widgets.items.len == 0; } @@ -150,6 +160,9 @@ pub fn render(self: *Self, theme: *const Widget.Theme) bool { fn on_render_default(_: ?*anyopaque, _: *const Widget.Theme) void {} pub fn receive(self: *Self, from_: tp.pid_ref, m: tp.message) error{Exit}!bool { + if (try m.match(.{ "H", tp.more })) + return false; + for (self.widgets.items) |*w| if (try w.widget.send(from_, m)) return true; diff --git a/src/tui/editor.zig b/src/tui/editor.zig index 5ab0100..5fc89d5 100644 --- a/src/tui/editor.zig +++ b/src/tui/editor.zig @@ -3786,13 +3786,21 @@ pub const Editor = struct { pub fn close_file(self: *Self, _: Context) Result { self.cancel_all_selections(); + if (self.buffer) |buffer| { + if (buffer.is_dirty()) + return tp.exit("unsaved changes"); + buffer.hidden = true; + } try self.close(); } pub const close_file_meta = .{ .description = "Close file" }; pub fn close_file_without_saving(self: *Self, _: Context) Result { self.cancel_all_selections(); - if (self.buffer) |buffer| buffer.reset_to_last_saved(); + if (self.buffer) |buffer| { + buffer.reset_to_last_saved(); + buffer.hidden = true; + } try self.close(); } pub const close_file_without_saving_meta = .{ .description = "Close file without saving" }; diff --git a/src/tui/editor_gutter.zig b/src/tui/editor_gutter.zig index 26994d4..6d63821 100644 --- a/src/tui/editor_gutter.zig +++ b/src/tui/editor_gutter.zig @@ -273,7 +273,8 @@ fn render_diagnostic(self: *Self, diag: *const ed.Diagnostic, theme: *const Widg _ = self.plane.putc(&cell) catch {}; } -fn primary_click(self: *const Self, y: i32) error{Exit}!bool { +fn primary_click(self: *const Self, y_: i32) error{Exit}!bool { + const y = self.editor.plane.abs_y_to_rel(y_); var line = self.row + 1; line += @intCast(y); if (line > self.lines) line = self.lines; @@ -284,7 +285,8 @@ fn primary_click(self: *const Self, y: i32) error{Exit}!bool { return true; } -fn primary_drag(_: *const Self, y: i32) error{Exit}!bool { +fn primary_drag(self: *const Self, y_: i32) error{Exit}!bool { + const y = self.editor.plane.abs_y_to_rel(y_); try command.executeName("drag_to", command.fmt(.{ y + 1, 0 })); return true; } diff --git a/src/tui/mainview.zig b/src/tui/mainview.zig index c6398d7..437e2f4 100644 --- a/src/tui/mainview.zig +++ b/src/tui/mainview.zig @@ -49,7 +49,6 @@ panels: ?*WidgetList = null, last_match_text: ?[]const u8 = null, location_history: location_history, buffer_manager: BufferManager, -file_stack: std.ArrayList([]const u8), find_in_files_state: enum { init, adding, done } = .done, file_list_type: FileListType = .find_in_files, panel_height: ?usize = null, @@ -69,7 +68,6 @@ pub fn create(allocator: std.mem.Allocator) !Widget { .widgets_widget = undefined, .floating_views = WidgetStack.init(allocator), .location_history = try location_history.create(), - .file_stack = std.ArrayList([]const u8).init(allocator), .views = undefined, .views_widget = undefined, .buffer_manager = BufferManager.init(allocator), @@ -102,8 +100,6 @@ pub fn create(allocator: std.mem.Allocator) !Widget { pub fn deinit(self: *Self, allocator: std.mem.Allocator) void { self.close_all_panel_views(); - self.clear_file_stack(); - self.file_stack.deinit(); self.commands.deinit(); self.widgets.deinit(allocator); self.floating_views.deinit(); @@ -286,7 +282,7 @@ const cmds = struct { editor.clear_diagnostics(); try editor.close_file(.{}); } - self.clear_file_stack(); + self.delete_all_buffers(); self.clear_find_in_files_results(.diagnostics); if (self.file_list_type == .diagnostics and self.is_panel_view_showing(filelist_view)) try self.toggle_panel_view(filelist_view, false); @@ -644,8 +640,7 @@ const cmds = struct { pub const show_diagnostics_meta = .{ .description = "Show diagnostics panel" }; pub fn open_previous_file(self: *Self, _: Ctx) Result { - const file_path = try project_manager.request_n_most_recent_file(self.allocator, 1); - self.show_file_async_and_free(file_path orelse return error.Stop); + self.show_file_async(self.get_next_mru_buffer() orelse return error.Stop); } pub const open_previous_file_meta = .{ .description = "Open the previous file" }; @@ -749,6 +744,16 @@ const cmds = struct { tui.rdr().reset_fontface(); } pub const reset_fontface_meta = .{ .description = "Reset font to configured face" }; + + pub fn next_tab(self: *Self, _: Ctx) Result { + _ = try self.widgets_widget.msg(.{"next_tab"}); + } + pub const next_tab_meta = .{ .description = "Switch to next tab" }; + + pub fn previous_tab(self: *Self, _: Ctx) Result { + _ = try self.widgets_widget.msg(.{"previous_tab"}); + } + pub const previous_tab_meta = .{ .description = "Switch to previous tab" }; }; pub fn handle_editor_event(self: *Self, _: tp.pid_ref, m: tp.message) tp.result { @@ -759,8 +764,8 @@ pub fn handle_editor_event(self: *Self, _: tp.pid_ref, m: tp.message) tp.result return self.location_update(m); if (try m.match(.{ "E", "close" })) { - if (self.pop_file_stack(editor.file_path)) |file_path| - self.show_file_async_and_free(file_path) + if (self.get_next_mru_buffer()) |file_path| + self.show_file_async(file_path) else self.show_home_async(); self.active_editor = null; @@ -873,7 +878,6 @@ fn replace_active_view(self: *Self, widget: Widget) !void { } fn create_editor(self: *Self) !void { - if (self.get_active_file_path()) |file_path| self.push_file_stack(file_path) catch {}; try self.delete_active_view(); command.executeName("enter_mode_default", .{}) catch {}; var editor_widget = try ed.create(self.allocator, self.plane, &self.buffer_manager); @@ -898,6 +902,10 @@ fn toggle_inputview_async(_: *Self) void { fn show_file_async_and_free(self: *Self, file_path: []const u8) void { defer self.allocator.free(file_path); + self.show_file_async(file_path); +} + +fn show_file_async(_: *Self, file_path: []const u8) void { tp.self_pid().send(.{ "cmd", "navigate", .{ .file = file_path } }) catch return; } @@ -943,24 +951,22 @@ fn read_restore_info(self: *Self) !void { try editor.extract_state(buf[0..size]); } -fn push_file_stack(self: *Self, file_path: []const u8) !void { - for (self.file_stack.items, 0..) |file_path_, i| - if (std.mem.eql(u8, file_path, file_path_)) - self.allocator.free(self.file_stack.orderedRemove(i)); - (try self.file_stack.addOne()).* = try self.allocator.dupe(u8, file_path); +fn get_next_mru_buffer(self: *Self) ?[]const u8 { + const buffers = self.buffer_manager.list_most_recently_used(self.allocator) catch return null; + defer self.allocator.free(buffers); + const active_file_path = self.get_active_file_path(); + for (buffers) |buffer| { + if (active_file_path) |fp| if (std.mem.eql(u8, fp, buffer.file_path)) + continue; + if (buffer.hidden) + continue; + return buffer.file_path; + } + return null; } -fn pop_file_stack(self: *Self, closed: ?[]const u8) ?[]const u8 { - if (closed) |file_path| - for (self.file_stack.items, 0..) |file_path_, i| - if (std.mem.eql(u8, file_path, file_path_)) - self.allocator.free(self.file_stack.orderedRemove(i)); - return self.file_stack.popOrNull(); -} - -fn clear_file_stack(self: *Self) void { - for (self.file_stack.items) |file_path| self.allocator.free(file_path); - self.file_stack.clearRetainingCapacity(); +fn delete_all_buffers(self: *Self) void { + self.buffer_manager.delete_all(); } fn add_find_in_files_result( diff --git a/src/tui/mode/overlay/buffer_palette.zig b/src/tui/mode/overlay/buffer_palette.zig index c76df18..638dd32 100644 --- a/src/tui/mode/overlay/buffer_palette.zig +++ b/src/tui/mode/overlay/buffer_palette.zig @@ -45,7 +45,6 @@ fn select(menu: **Type.MenuState, button: *Type.ButtonState) void { var iter = button.opts.label; if (!(cbor.matchString(&iter, &file_path) catch false)) return; tp.self_pid().send(.{ "cmd", "exit_overlay_mode" }) catch |e| menu.*.opts.ctx.logger.err(module_name, e); - tp.self_pid().send(.{ "cmd", "navigate", .{} }) catch |e| menu.*.opts.ctx.logger.err(module_name, e); tp.self_pid().send(.{ "cmd", "navigate", .{ .file = file_path } }) catch |e| menu.*.opts.ctx.logger.err(module_name, e); } diff --git a/src/tui/status/tabs.zig b/src/tui/status/tabs.zig new file mode 100644 index 0000000..f01796f --- /dev/null +++ b/src/tui/status/tabs.zig @@ -0,0 +1,251 @@ +const std = @import("std"); +const tp = @import("thespian"); + +const EventHandler = @import("EventHandler"); +const Plane = @import("renderer").Plane; +const Buffer = @import("Buffer"); + +const tui = @import("../tui.zig"); +const Widget = @import("../Widget.zig"); +const WidgetList = @import("../WidgetList.zig"); +const Button = @import("../Button.zig"); + +const dirty_indicator = " "; +const padding = " "; + +pub fn create(allocator: std.mem.Allocator, parent: Plane, event_handler: ?EventHandler) @import("widget.zig").CreateError!Widget { + const self = try allocator.create(TabBar); + self.* = try TabBar.init(allocator, parent, event_handler); + return Widget.to(self); +} + +const TabBar = struct { + allocator: std.mem.Allocator, + plane: Plane, + widget_list: *WidgetList, + widget_list_widget: Widget, + event_handler: ?EventHandler, + tabs: []TabBarTab = &[_]TabBarTab{}, + active_buffer: ?*Buffer = null, + + const Self = @This(); + + const TabBarTab = struct { + buffer: *Buffer, + widget: Widget, + }; + + fn init(allocator: std.mem.Allocator, parent: Plane, event_handler: ?EventHandler) !Self { + var w = try WidgetList.createH(allocator, parent, "tabs", .dynamic); + w.ctx = w; + return .{ + .allocator = allocator, + .plane = w.plane, + .widget_list = w, + .widget_list_widget = w.widget(), + .event_handler = event_handler, + }; + } + + pub fn deinit(self: *Self, allocator: std.mem.Allocator) void { + self.allocator.free(self.tabs); + self.widget_list_widget.deinit(allocator); + allocator.destroy(self); + } + + pub fn layout(self: *Self) Widget.Layout { + return self.widget_list_widget.layout(); + } + + pub fn update(self: *Self) void { + self.update_tabs() catch {}; + self.widget_list_widget.resize(Widget.Box.from(self.plane)); + self.widget_list_widget.update(); + } + + pub fn render(self: *Self, theme: *const Widget.Theme) bool { + self.plane.set_base_style(theme.statusbar); + self.plane.erase(); + self.plane.home(); + return self.widget_list_widget.render(theme); + } + + pub fn receive(self: *Self, _: tp.pid_ref, m: tp.message) error{Exit}!bool { + tp.trace(tp.channel.widget, .{"receive"}); + tp.trace(tp.channel.widget, m); + + const buffer_manager = tui.get_buffer_manager() orelse @panic("tabs no buffer manager"); + var file_path: []const u8 = undefined; + if (try m.match(.{"next_tab"})) { + self.select_next_tab(); + } else if (try m.match(.{"previous_tab"})) { + self.select_previous_tab(); + } else if (try m.match(.{ "E", "open", tp.extract(&file_path), tp.more })) { + self.active_buffer = buffer_manager.get_buffer_for_file(file_path); + } else if (try m.match(.{ "E", "close" })) { + self.active_buffer = null; + } + return false; + } + + pub fn handle_resize(self: *Self, pos: Widget.Box) void { + self.widget_list_widget.resize(pos); + self.plane = self.widget_list.plane; + } + + pub fn get(self: *Self, name: []const u8) ?*Widget { + return self.widget_list_widget.get(name); + } + + pub fn walk(self: *Self, ctx: *anyopaque, f: Widget.WalkFn, _: *Widget) bool { + return self.widget_list_widget.walk(ctx, f); + } + + pub fn hover(self: *Self) bool { + return self.widget_list_widget.hover(); + } + + fn update_tabs(self: *Self) !void { + try self.update_tab_buffers(); + while (self.widget_list.pop()) |widget| if (widget.dynamic_cast(Button.State(Tab)) == null) + widget.deinit(self.widget_list.allocator); + var first = true; + for (self.tabs) |tab| { + if (first) { + first = false; + } else { + try self.widget_list.add(try self.make_spacer(1)); + } + try self.widget_list.add(tab.widget); + } + } + + fn update_tab_buffers(self: *Self) !void { + const buffer_manager = tui.get_buffer_manager() orelse @panic("tabs no buffer manager"); + const buffers = try buffer_manager.list_unordered(self.allocator); + defer self.allocator.free(buffers); + const existing_tabs = self.tabs; + defer self.allocator.free(existing_tabs); + var result: std.ArrayListUnmanaged(TabBarTab) = .{}; + errdefer result.deinit(self.allocator); + + // add existing tabs in original order if they still exist + outer: for (existing_tabs) |existing_tab| + for (buffers) |buffer| if (existing_tab.buffer == buffer) { + if (!buffer.hidden) + (try result.addOne(self.allocator)).* = existing_tab; + continue :outer; + }; + + // add new tabs + outer: for (buffers) |buffer| { + for (result.items) |result_tab| if (result_tab.buffer == buffer) + continue :outer; + if (!buffer.hidden) + (try result.addOne(self.allocator)).* = .{ + .buffer = buffer, + .widget = try Tab.create(self, buffer, self.event_handler), + }; + } + + self.tabs = try result.toOwnedSlice(self.allocator); + } + + fn make_spacer(self: @This(), comptime size: usize) !Widget { + return @import("blank.zig").Create(.{ .static = size })(self.allocator, self.widget_list.plane, null); + } + + fn select_next_tab(self: *Self) void { + tp.trace(tp.channel.widget, .{"select_next_tab"}); + var activate_next = false; + var first: ?*const TabBarTab = null; + for (self.tabs) |*tab| { + if (first == null) + first = tab; + if (activate_next) + return navigate_to_tab(tab); + if (tab.buffer == self.active_buffer) + activate_next = true; + } + if (first) |tab| + navigate_to_tab(tab); + } + + fn select_previous_tab(self: *Self) void { + tp.trace(tp.channel.widget, .{"select_previous_tab"}); + var goto: ?*const TabBarTab = if (self.tabs.len > 0) &self.tabs[self.tabs.len - 1] else null; + for (self.tabs) |*tab| { + if (tab.buffer == self.active_buffer) + break; + goto = tab; + } + if (goto) |tab| navigate_to_tab(tab); + } + + fn navigate_to_tab(tab: *const TabBarTab) void { + tp.self_pid().send(.{ "cmd", "navigate", .{ .file = tab.buffer.file_path } }) catch {}; + } +}; + +const Tab = struct { + tabbar: *TabBar, + buffer: *Buffer, + + fn create( + tabbar: *TabBar, + buffer: *Buffer, + event_handler: ?EventHandler, + ) !Widget { + return Button.create_widget(Tab, tabbar.allocator, tabbar.widget_list.plane, .{ + .ctx = .{ .tabbar = tabbar, .buffer = buffer }, + .label = name_from_buffer(buffer), + .on_click = Tab.on_click, + .on_click2 = Tab.on_click2, + .on_layout = Tab.layout, + .on_render = Tab.render, + .on_event = event_handler, + }); + } + + fn on_click(self: *@This(), _: *Button.State(@This())) void { + tp.self_pid().send(.{ "cmd", "navigate", .{ .file = self.buffer.file_path } }) catch {}; + } + + fn on_click2(self: *@This(), _: *Button.State(@This())) void { + tp.self_pid().send(.{ "cmd", "delete_buffer", .{self.buffer.file_path} }) catch {}; + } + + fn render(self: *@This(), btn: *Button.State(@This()), theme: *const Widget.Theme) bool { + const active = self.tabbar.active_buffer == self.buffer; + btn.plane.set_base_style(theme.statusbar); + btn.plane.erase(); + btn.plane.home(); + btn.plane.set_style(if (active) theme.editor else if (btn.hover) theme.statusbar_hover else theme.statusbar); + btn.plane.fill(" "); + btn.plane.home(); + btn.plane.set_style(if (active) theme.editor else if (btn.hover) theme.statusbar_hover else theme.statusbar); + _ = btn.plane.putstr(" ") catch {}; + if (self.buffer.is_dirty()) + _ = btn.plane.putstr(dirty_indicator) catch {}; + _ = btn.plane.putstr(btn.opts.label) catch {}; + _ = btn.plane.putstr(" ") catch {}; + return false; + } + + fn layout(self: *@This(), btn: *Button.State(@This())) Widget.Layout { + const len = btn.plane.egc_chunk_width(btn.opts.label, 0, 1); + const len_dirty_indicator = btn.plane.egc_chunk_width(dirty_indicator, 0, 1); + const len_padding = btn.plane.egc_chunk_width(padding, 0, 1); + return if (self.buffer.is_dirty()) + .{ .static = len + (2 * len_padding) + len_dirty_indicator } + else + .{ .static = len + (2 * len_padding) }; + } + + fn name_from_buffer(buffer: *Buffer) []const u8 { + const file_path = buffer.file_path; + const basename_begin = std.mem.lastIndexOfScalar(u8, file_path, std.fs.path.sep); + const basename = if (basename_begin) |begin| file_path[begin + 1 ..] else file_path; + return basename; + } +}; diff --git a/src/tui/status/widget.zig b/src/tui/status/widget.zig index fe52436..f0e1c02 100644 --- a/src/tui/status/widget.zig +++ b/src/tui/status/widget.zig @@ -18,6 +18,7 @@ const widgets = std.static_string_map.StaticStringMap(CreateFunction).initCompti .{ "spacer", @import("blank.zig").Create(.{ .static = 1 }) }, .{ "clock", @import("clock.zig").create }, .{ "keybind", @import("keybindstate.zig").create }, + .{ "tabs", @import("tabs.zig").create }, }); pub const CreateError = error{ OutOfMemory, Exit }; pub const CreateFunction = *const fn (allocator: std.mem.Allocator, parent: Plane, event_handler: ?EventHandler) CreateError!Widget;