From e7c8fea3f0bcc58801e2d4cfbd1ff330ab1557f3 Mon Sep 17 00:00:00 2001 From: CJ van den Berg Date: Fri, 21 Jun 2024 01:20:17 +0200 Subject: [PATCH] feat: add scrollbar to command palette --- src/tui/Menu.zig | 51 ++++++++--- src/tui/editor.zig | 2 +- src/tui/mode/overlay/command_palette.zig | 111 ++++++++++++++++++++--- src/tui/mode/overlay/open_recent.zig | 10 +- src/tui/scrollbar_v.zig | 19 ++-- src/tui/tui.zig | 6 +- 6 files changed, 156 insertions(+), 43 deletions(-) diff --git a/src/tui/Menu.zig b/src/tui/Menu.zig index a1cc476..1f25422 100644 --- a/src/tui/Menu.zig +++ b/src/tui/Menu.zig @@ -5,8 +5,12 @@ const planeutils = @import("renderer").planeutils; const Widget = @import("Widget.zig"); const WidgetList = @import("WidgetList.zig"); +const EventHandler = @import("EventHandler.zig"); const Button = @import("Button.zig"); const tui = @import("tui.zig"); +const scrollbar_v = @import("scrollbar_v.zig"); + +pub const Container = WidgetList; pub fn Options(context: type) type { return struct { @@ -16,6 +20,7 @@ pub fn Options(context: type) type { on_render: *const fn (ctx: context, button: *Button.State(*State(Context)), theme: *const Widget.Theme, selected: bool) bool = on_render_default, on_layout: *const fn (ctx: context, button: *Button.State(*State(Context))) Widget.Layout = on_layout_default, on_resize: *const fn (ctx: context, menu: *State(Context), box: Widget.Box) void = on_resize_default, + on_scroll: ?EventHandler = null, pub const Context = context; pub fn do_nothing(_: context, _: *Button.State(*State(Context))) void {} @@ -35,23 +40,29 @@ pub fn Options(context: type) type { pub fn on_resize_default(_: context, state: *State(Context), box_: Widget.Box) void { var box = box_; - box.h = state.menu.widgets.items.len; - state.menu.resize(box); + box.h = if (box_.h == 0) state.menu.widgets.items.len else box_.h; + state.container.resize(box); } }; } pub fn create(ctx_type: type, a: std.mem.Allocator, parent: Widget, opts: Options(ctx_type)) !*State(ctx_type) { const self = try a.create(State(ctx_type)); + const container = try WidgetList.createH(a, parent, @typeName(@This()), .dynamic); self.* = .{ .a = a, - .menu = try WidgetList.createV(a, parent, @typeName(@This()), .dynamic), - .menu_widget = self.menu.widget(), + .menu = try WidgetList.createV(a, container.widget(), @typeName(@This()), .dynamic), + .container = container, + .container_widget = container.widget(), + .scrollbar = if (opts.on_scroll) |on_scroll| (try scrollbar_v.create(a, parent, null, on_scroll)).dynamic_cast(scrollbar_v).? else null, .opts = opts, }; self.menu.ctx = self; - self.menu.on_render = State(ctx_type).on_render_menu_widgetlist; - self.menu.on_resize = State(ctx_type).on_resize_menu_widgetlist; + self.menu.on_render = State(ctx_type).on_render_menu; + container.ctx = self; + container.on_resize = State(ctx_type).on_resize_container; + try container.add(self.menu.widget()); + if (self.scrollbar) |sb| try container.add(sb.widget()); return self; } @@ -59,7 +70,9 @@ pub fn State(ctx_type: type) type { return struct { a: std.mem.Allocator, menu: *WidgetList, - menu_widget: Widget, + container: *WidgetList, + container_widget: Widget, + scrollbar: ?*scrollbar_v, opts: options_type, selected: ?usize = null, render_idx: usize = 0, @@ -112,11 +125,16 @@ pub fn State(ctx_type: type) type { return self.menu.render(theme); } - fn on_render_menu_widgetlist(ctx: ?*anyopaque, _: *const Widget.Theme) void { + fn on_render_menu(ctx: ?*anyopaque, _: *const Widget.Theme) void { const self: *Self = @ptrCast(@alignCast(ctx)); self.render_idx = 0; } + fn on_resize_container(ctx: ?*anyopaque, _: *WidgetList, box: Widget.Box) void { + const self: *Self = @ptrCast(@alignCast(ctx)); + self.opts.on_resize(self.*.opts.ctx, self, box); + } + pub fn on_layout(self: **Self, button: *Button.State(*Self)) Widget.Layout { return self.*.opts.on_layout(self.*.opts.ctx, button); } @@ -127,13 +145,8 @@ pub fn State(ctx_type: type) type { return self.*.opts.on_render(self.*.opts.ctx, button, theme, self.*.render_idx == self.*.selected); } - fn on_resize_menu_widgetlist(ctx: ?*anyopaque, _: *WidgetList, box: Widget.Box) void { - const self: *Self = @ptrCast(@alignCast(ctx)); - return self.opts.on_resize(self.opts.ctx, self, box); - } - pub fn resize(self: *Self, box: Widget.Box) void { - self.menu.resize(box); + self.container.resize(box); } pub fn update(self: *Self) void { @@ -141,7 +154,7 @@ pub fn State(ctx_type: type) type { } pub fn walk(self: *Self, walk_ctx: *anyopaque, f: Widget.WalkFn) bool { - return self.menu.walk(walk_ctx, f, &self.menu_widget); + return self.menu.walk(walk_ctx, f, &self.container_widget); } pub fn count(self: *Self) usize { @@ -163,6 +176,14 @@ pub fn State(ctx_type: type) type { } } + pub fn select_first(self: *Self) void { + self.selected = if (self.count() > 0) 0 else null; + } + + pub fn select_last(self: *Self) void { + self.selected = if (self.count() > 0) self.count() - self.header_count - 1 else null; + } + pub fn activate_selected(self: *Self) void { const selected = self.selected orelse return; self.selected_active = true; diff --git a/src/tui/editor.zig b/src/tui/editor.zig index 3ee1405..9e3c9b0 100644 --- a/src/tui/editor.zig +++ b/src/tui/editor.zig @@ -3655,7 +3655,7 @@ pub const EditorWidget = struct { const editorWidget = Widget.to(self); try container.add(try editor_gutter.create(a, container.widget(), editorWidget, &self.editor)); try container.add(editorWidget); - try container.add(try scrollbar_v.create(a, container.widget(), editorWidget)); + try container.add(try scrollbar_v.create(a, container.widget(), editorWidget, EventHandler.to_unowned(container))); return container.widget(); } diff --git a/src/tui/mode/overlay/command_palette.zig b/src/tui/mode/overlay/command_palette.zig index 7227b87..f9b1de6 100644 --- a/src/tui/mode/overlay/command_palette.zig +++ b/src/tui/mode/overlay/command_palette.zig @@ -13,6 +13,7 @@ const ucs32_to_utf8 = @import("renderer").ucs32_to_utf8; const tui = @import("../../tui.zig"); const command = @import("../../command.zig"); const EventHandler = @import("../../EventHandler.zig"); +const WidgetList = @import("../../WidgetList.zig"); const Button = @import("../../Button.zig"); const InputBox = @import("../../InputBox.zig"); const Menu = @import("../../Menu.zig"); @@ -31,18 +32,29 @@ commands: Commands = undefined, hints: ?*const tui.KeybindHints = null, longest_hint: usize = 0, +items: usize = 0, +view_rows: usize, +view_pos: usize = 0, +total_items: usize = 0, + pub fn create(a: std.mem.Allocator) !tui.Mode { const mv = if (tui.current().mainview.dynamic_cast(mainview)) |mv_| mv_ else return error.NotFound; const self: *Self = try a.create(Self); self.* = .{ .a = a, - .menu = try Menu.create(*Self, a, tui.current().mainview, .{ .ctx = self, .on_render = on_render_menu, .on_resize = on_resize_menu }), + .menu = try Menu.create(*Self, a, tui.current().mainview, .{ + .ctx = self, + .on_render = on_render_menu, + .on_resize = on_resize_menu, + .on_scroll = EventHandler.bind(self, Self.on_scroll), + }), .logger = log.logger(@typeName(Self)), .inputbox = (try self.menu.add_header(try InputBox.create(*Self, self.a, self.menu.menu.parent, .{ .ctx = self, .label = "Search commands", }))).dynamic_cast(InputBox.State(*Self)) orelse unreachable, .hints = if (tui.current().input_mode) |m| m.keybind_hints else null, + .view_rows = get_view_rows(tui.current().screen()), }; if (self.hints) |hints| { for (hints.values()) |val| @@ -50,7 +62,7 @@ pub fn create(a: std.mem.Allocator) !tui.Mode { } try self.commands.init(self); try self.start_query(); - try mv.floating_views.add(self.menu.menu_widget); + try mv.floating_views.add(self.menu.container_widget); return .{ .handler = EventHandler.to_owned(self), .name = "󱊒 command", @@ -62,7 +74,7 @@ pub fn deinit(self: *Self) void { self.commands.deinit(); tui.current().message_filters.remove_ptr(self); if (tui.current().mainview.dynamic_cast(mainview)) |mv| - mv.floating_views.remove(self.menu.menu_widget); + mv.floating_views.remove(self.menu.container_widget); self.logger.deinit(); self.a.destroy(self); } @@ -81,10 +93,7 @@ fn on_render_menu(_: *Self, button: *Button.State(*Menu.State(*Self)), theme: *c if (!(cbor.matchString(&iter, &keybind_hint) catch false)) keybind_hint = ""; const pointer = if (selected) "⏵" else " "; - _ = button.plane.print("{s}{s} ", .{ - pointer, - command_name, - }) catch {}; + _ = button.plane.print("{s}{s} ", .{ pointer, command_name }) catch {}; button.plane.set_style(style_keybind); _ = button.plane.print_aligned_right(0, "{s} ", .{keybind_hint}) catch {}; var index: usize = 0; @@ -110,7 +119,19 @@ fn on_resize_menu(self: *Self, _: *Menu.State(*Self), _: Widget.Box) void { } fn do_resize(self: *Self) void { - self.menu.resize(.{ .y = 0, .x = 25, .w = @min(self.longest, max_menu_width) + 2 }); + const screen = tui.current().screen(); + const w = @min(self.longest, max_menu_width) + 2 + 1 + self.longest_hint; + const x = if (screen.w > w) (screen.w - w) / 2 else 0; + self.view_rows = get_view_rows(screen); + const h = @min(self.items, self.view_rows); + self.menu.container.resize(.{ .y = 0, .x = x, .w = w, .h = h }); + self.update_scrollbar(); +} + +fn get_view_rows(screen: Widget.Box) usize { + var h = screen.h; + if (h > 0) h = h / 5 * 4; + return h; } fn menu_action_execute_command(menu: **Menu.State(*Self), button: *Button.State(*Menu.State(*Self))) void { @@ -121,6 +142,16 @@ fn menu_action_execute_command(menu: **Menu.State(*Self), button: *Button.State( tp.self_pid().send(.{ "cmd", command_name, .{} }) catch |e| menu.*.opts.ctx.logger.err("navigate", e); } +fn on_scroll(self: *Self, _: tp.pid_ref, m: tp.message) error{Exit}!void { + if (try m.match(.{ "scroll_to", tp.extract(&self.view_pos) })) { + try self.start_query(); + } +} + +fn update_scrollbar(self: *Self) void { + self.menu.scrollbar.?.set(@intCast(self.total_items), @intCast(self.view_rows), @intCast(self.view_pos)); +} + pub fn receive(self: *Self, _: tp.pid_ref, m: tp.message) error{Exit}!bool { var evtype: u32 = undefined; var keypress: u32 = undefined; @@ -160,6 +191,8 @@ fn mapPress(self: *Self, keypress: u32, egc: u32, modifiers: u32) tp.result { key.ESC => self.cmd("exit_overlay_mode", .{}), key.UP => self.cmd("command_palette_menu_up", .{}), key.DOWN => self.cmd("command_palette_menu_down", .{}), + key.PGUP => self.cmd("command_palette_menu_pageup", .{}), + key.PGDOWN => self.cmd("command_palette_menu_pagedown", .{}), key.ENTER => self.cmd("command_palette_menu_activate", .{}), key.BACKSPACE => self.delete_word(), else => {}, @@ -191,6 +224,8 @@ fn mapPress(self: *Self, keypress: u32, egc: u32, modifiers: u32) tp.result { key.ESC => self.cmd("exit_overlay_mode", .{}), key.UP => self.cmd("command_palette_menu_up", .{}), key.DOWN => self.cmd("command_palette_menu_down", .{}), + key.PGUP => self.cmd("command_palette_menu_pageup", .{}), + key.PGDOWN => self.cmd("command_palette_menu_pagedown", .{}), key.ENTER => self.cmd("command_palette_menu_activate", .{}), key.BACKSPACE => self.delete_code_point(), else => if (!key.synthesized_p(keypress)) @@ -209,6 +244,7 @@ fn mapRelease(self: *Self, keypress: u32, _: u32) tp.result { } fn start_query(self: *Self) tp.result { + self.items = 0; self.menu.reset_items(); self.menu.selected = null; for (command.commands.items) |cmd_| if (cmd_) |p| { @@ -216,8 +252,14 @@ fn start_query(self: *Self) tp.result { }; if (self.inputbox.text.items.len == 0) { + self.total_items = 0; + var pos: usize = 0; for (command.commands.items) |cmd_| if (cmd_) |p| { - self.add_item(p.name, null) catch |e| return tp.exit_error(e); + defer self.total_items += 1; + defer pos += 1; + if (pos < self.view_pos) continue; + if (self.items < self.view_rows) + self.add_item(p.name, null) catch |e| return tp.exit_error(e); }; } else { _ = self.query_commands(self.inputbox.text.items) catch |e| return tp.exit_error(e); @@ -261,8 +303,15 @@ fn query_commands(self: *Self, query: []const u8) error{OutOfMemory}!usize { }.less_fn; std.mem.sort(Match, matches.items, {}, less_fn); - for (matches.items) |match| - try self.add_item(match.name, match.matches); + var pos: usize = 0; + self.total_items = 0; + for (matches.items) |match| { + defer self.total_items += 1; + defer pos += 1; + if (pos < self.view_pos) continue; + if (self.items < self.view_rows) + try self.add_item(match.name, match.matches); + } return matches.items.len; } @@ -271,10 +320,11 @@ fn add_item(self: *Self, command_name: []const u8, matches: ?[]const usize) !voi defer label.deinit(); const writer = label.writer(); try cbor.writeValue(writer, command_name); - try cbor.writeValue(writer, if (self.hints) |hints| hints.get(command_name) else ""); + try cbor.writeValue(writer, if (self.hints) |hints| hints.get(command_name) orelse "" else ""); if (matches) |matches_| try cbor.writeValue(writer, matches_); try self.menu.add_item_with_handler(label.items, menu_action_execute_command); + self.items += 1; } fn delete_word(self: *Self) tp.result { @@ -284,6 +334,7 @@ fn delete_word(self: *Self) tp.result { self.inputbox.text.shrinkRetainingCapacity(0); } self.inputbox.cursor = self.inputbox.text.items.len; + self.view_pos = 0; return self.start_query(); } @@ -292,6 +343,7 @@ fn delete_code_point(self: *Self) tp.result { self.inputbox.text.shrinkRetainingCapacity(self.inputbox.text.items.len - 1); self.inputbox.cursor = self.inputbox.text.items.len; } + self.view_pos = 0; return self.start_query(); } @@ -300,12 +352,14 @@ fn insert_code_point(self: *Self, c: u32) tp.result { const bytes = ucs32_to_utf8(&[_]u32{c}, &buf) catch |e| return tp.exit_error(e); self.inputbox.text.appendSlice(buf[0..bytes]) catch |e| return tp.exit_error(e); self.inputbox.cursor = self.inputbox.text.items.len; + self.view_pos = 0; return self.start_query(); } fn insert_bytes(self: *Self, bytes: []const u8) tp.result { self.inputbox.text.appendSlice(bytes) catch |e| return tp.exit_error(e); self.inputbox.cursor = self.inputbox.text.items.len; + self.view_pos = 0; return self.start_query(); } @@ -327,13 +381,46 @@ const cmds = struct { const Ctx = command.Context; pub fn command_palette_menu_down(self: *Self, _: Ctx) tp.result { + if (self.menu.selected) |selected| { + if (selected == self.view_rows - 1) { + self.view_pos += 1; + try self.start_query(); + self.menu.select_last(); + return; + } + } self.menu.select_down(); } pub fn command_palette_menu_up(self: *Self, _: Ctx) tp.result { + if (self.menu.selected) |selected| { + if (selected == 0 and self.view_pos > 0) { + self.view_pos -= 1; + try self.start_query(); + self.menu.select_first(); + return; + } + } self.menu.select_up(); } + pub fn command_palette_menu_pagedown(self: *Self, _: Ctx) tp.result { + if (self.total_items > self.view_rows) { + self.view_pos += self.view_rows; + if (self.view_pos > self.total_items - self.view_rows) + self.view_pos = self.total_items - self.view_rows; + } + try self.start_query(); + self.menu.select_last(); + } + + pub fn command_palette_menu_pageup(self: *Self, _: Ctx) tp.result { + if (self.view_pos > self.view_rows) + self.view_pos -= self.view_rows; + try self.start_query(); + self.menu.select_first(); + } + pub fn command_palette_menu_activate(self: *Self, _: Ctx) tp.result { self.menu.activate_selected(); } diff --git a/src/tui/mode/overlay/open_recent.zig b/src/tui/mode/overlay/open_recent.zig index 12f5d86..273e2e3 100644 --- a/src/tui/mode/overlay/open_recent.zig +++ b/src/tui/mode/overlay/open_recent.zig @@ -41,7 +41,11 @@ pub fn create(a: std.mem.Allocator) !tui.Mode { const self: *Self = try a.create(Self); self.* = .{ .a = a, - .menu = try Menu.create(*Self, a, tui.current().mainview, .{ .ctx = self, .on_render = on_render_menu, .on_resize = on_resize_menu }), + .menu = try Menu.create(*Self, a, tui.current().mainview, .{ + .ctx = self, + .on_render = on_render_menu, + .on_resize = on_resize_menu, + }), .logger = log.logger(@typeName(Self)), .inputbox = (try self.menu.add_header(try InputBox.create(*Self, self.a, self.menu.menu.parent, .{ .ctx = self, @@ -53,7 +57,7 @@ pub fn create(a: std.mem.Allocator) !tui.Mode { self.query_pending = true; try project_manager.request_recent_files(max_recent_files); self.menu.resize(.{ .y = 0, .x = 25, .w = 32 }); - try mv.floating_views.add(self.menu.menu_widget); + try mv.floating_views.add(self.menu.container_widget); return .{ .handler = EventHandler.to_owned(self), .name = "󰈞 open recent", @@ -65,7 +69,7 @@ pub fn deinit(self: *Self) void { self.commands.deinit(); tui.current().message_filters.remove_ptr(self); if (tui.current().mainview.dynamic_cast(mainview)) |mv| - mv.floating_views.remove(self.menu.menu_widget); + mv.floating_views.remove(self.menu.container_widget); self.logger.deinit(); self.a.destroy(self); } diff --git a/src/tui/scrollbar_v.zig b/src/tui/scrollbar_v.zig index 4d70f6f..e346f38 100644 --- a/src/tui/scrollbar_v.zig +++ b/src/tui/scrollbar_v.zig @@ -21,27 +21,24 @@ size_virt: u32 = 1, max_ypx: i32 = 8, -parent: Widget, +event_sink: EventHandler, hover: bool = false, active: bool = false, const Self = @This(); -pub fn create(a: Allocator, parent: Widget, event_source: ?Widget) !Widget { +pub fn create(a: Allocator, parent: Widget, event_source: ?Widget, event_sink: EventHandler) !Widget { const self: *Self = try a.create(Self); - self.* = try init(parent); + self.* = .{ + .plane = try Plane.init(&(Widget.Box{}).opts(@typeName(Self)), parent.plane.*), + .event_sink = event_sink, + }; + if (event_source) |source| try source.subscribe(EventHandler.bind(self, handle_event)); return self.widget(); } -fn init(parent: Widget) !Self { - return .{ - .plane = try Plane.init(&(Widget.Box{}).opts(@typeName(Self)), parent.plane.*), - .parent = parent, - }; -} - pub fn widget(self: *Self) Widget { return Widget.to(self); } @@ -108,7 +105,7 @@ fn move_to(self: *Self, y_: i32, ypx_: i32) void { const pos_virt = self.pos_scrn_to_virt(@intFromFloat(pos_scrn_clamped)); self.set(self.size_virt, self.view_virt, pos_virt); - _ = self.parent.msg(.{ "scroll_to", pos_virt }) catch {}; + _ = self.event_sink.msg(.{ "scroll_to", pos_virt }) catch {}; } fn pos_scrn_to_virt(self: Self, pos_scrn_: u32) u32 { diff --git a/src/tui/tui.zig b/src/tui/tui.zig index a65c010..cc7df43 100644 --- a/src/tui/tui.zig +++ b/src/tui/tui.zig @@ -676,10 +676,14 @@ pub fn need_render() void { } pub fn resize(self: *Self) void { - self.mainview.resize(Widget.Box.from(self.rdr.stdplane())); + self.mainview.resize(self.screen()); need_render(); } +pub fn screen(self: *Self) Widget.Box { + return Widget.Box.from(self.rdr.stdplane()); +} + pub fn get_theme_by_name(name: []const u8) ?Widget.Theme { for (Widget.themes) |theme| { if (std.mem.eql(u8, theme.name, name))