Something went wrong. Try again.
Immediate mode renderer for libvaxis
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784pub fn Vxim(comptime Event: type, comptime WidgetId: type) type { return struct { const Self = @This();
const InternalElement = enum { v_scroll_thumb, h_scroll_thumb, };
const Id = union(enum) { external: WidgetId, internal: struct { widget: WidgetId, element: InternalElement }, };
current_event: Event = undefined, mouse_focused_widget: ?WidgetId = null, open_menu: ?WidgetId = null, last_mouse: ?vaxis.Mouse = null,
widget_being_dragged: ?Id = null,
show_debug_ui: bool = builtin.mode == .Debug,
_tty_buffer: []u8 = undefined, _tty: *vaxis.Tty, _vx: *vaxis.Vaxis, _loop: *vaxis.Loop(Event), _arena_state: std.heap.ArenaAllocator,
pub const UpdateContext = struct { root_win: vaxis.Window, vx: *vaxis.Vaxis, vxim: *Self, current_event: Event, };
pub const UpdateResult = enum { keep_going, stop, };
pub const Style = struct { pub const Button = struct { default: vaxis.Style = .{ .bg = .{ .index = 12 } }, hovered: vaxis.Style = .{ .bg = .{ .index = 12 } }, pressed: vaxis.Style = .{ .bg = .{ .index = 4 } },
text: Text = .{ .default = .{ .fg = .{ .index = 12 }, .reverse = true }, .hovered = .{ .fg = .{ .index = 12 }, .reverse = true }, .pressed = .{ .fg = .{ .index = 4 }, .reverse = true }, }, };
pub const Text = struct { default: vaxis.Style = .{}, hovered: vaxis.Style = .{}, pressed: vaxis.Style = .{}, }; };
const ButtonAction = enum { clicked, hovered, none, };
const ButtonOptions = struct { x: u16 = 0, y: u16 = 0, text: []const u8 = "", style: Style.Button = .{}, };
pub const TextOptions = struct { x: u16 = 0, y: u16 = 0, text: []const u8 = "", style: vaxis.Style = .{}, allow_selection: bool = false, };
const MenuItem = struct { name: []const u8, id: WidgetId, };
const Menu = struct { name: []const u8, items: []const MenuItem, id: WidgetId, };
const MenuBarAction = struct { id: WidgetId, action: ButtonAction, };
const WindowOptions = struct { x: *u16, y: *u16, width: u16 = 10, height: u16 = 10, title: []const u8 = "", };
const ScrollAreaOptions = struct { x: u16 = 0, y: u16 = 0, width: ?u16 = null, height: ?u16 = null, content_height: usize, content_width: usize, /// how far down you've scrolled in rows. v_content_offset: *usize, h_content_offset: *usize, };
const PaddingOpts = union(enum) { all: u16, hv: struct { horizontal: u16, vertical: u16 }, each: struct { top: u16, right: u16, bottom: u16, left: u16 }, };
pub fn deinit(self: *Self, gpa: std.mem.Allocator) void { self._arena_state.deinit();
self._loop.stop(); self._vx.deinit(gpa, self._tty.writer()); self._tty.deinit();
gpa.destroy(self._loop); gpa.destroy(self._vx); gpa.destroy(self._tty); gpa.free(self._tty_buffer); }
pub fn init(gpa: std.mem.Allocator) Self { const tty_buffer = gpa.alloc(u8, 1024) catch @panic("Failed to allocate memory for TTY buffer"); const tty = gpa.create(vaxis.Tty) catch @panic("Failed to allocate memory for TTY"); tty.* = vaxis.Tty.init(tty_buffer) catch @panic("Failed to initialize TTY");
const vx = gpa.create(vaxis.Vaxis) catch @panic("Failed to allocate memory for vaxis"); vx.* = vaxis.init(gpa, .{}) catch @panic("Failed to initialize vaxis");
const loop = gpa.create(vaxis.Loop(Event)) catch @panic("Failed to allocate memory for event loop"); loop.* = .{ .tty = tty, .vaxis = vx, }; loop.init() catch @panic("Failed to initialize event loop"); loop.start() catch @panic("Failed to start event loop");
return .{ ._arena_state = .init(gpa), ._tty_buffer = tty_buffer, ._tty = tty, ._vx = vx, ._loop = loop, }; }
pub fn arena(self: *Self) std.mem.Allocator { return self._arena_state.allocator(); }
pub fn enterAltScreen(self: *Self) !void { try self._vx.enterAltScreen(self._tty.writer()); try self._vx.queryTerminal(self._tty.writer(), 1 * std.time.ns_per_s); }
pub fn setMouseMode(self: *Self, mouse_mode: bool) !void { try self._vx.setMouseMode(self._tty.writer(), mouse_mode); }
pub fn button(self: *Self, id: WidgetId, win: vaxis.Window, opts: ButtonOptions) ButtonAction { const button_widget = win.child(.{ .x_off = opts.x, .y_off = opts.y, .width = @min( @as(u16, @truncate(opts.text.len)) + 2, win.width, ), .height = 1, });
switch (self.current_event) { .mouse_focus => |mouse| { if (button_widget.hasMouse(mouse)) |_| { self.mouse_focused_widget = id; } }, .mouse => |mouse| { if (button_widget.hasMouse(mouse)) |_| self._vx.setMouseShape(.pointer); }, else => {}, }
const button_style = switch (self.current_event) { .mouse => |mouse| style: { if (button_widget.hasMouse(mouse)) |_| { if (self.mouse_focused_widget == id and mouse.button == .left and mouse.type == .press) { break :style opts.style.pressed; }
break :style opts.style.hovered; }
break :style opts.style.default; }, else => opts.style.default, };
const text_style = switch (self.current_event) { .mouse => |mouse| style: { if (button_widget.hasMouse(mouse)) |_| { if (self.mouse_focused_widget == id and mouse.button == .left and mouse.type == .press) { break :style opts.style.text.pressed; }
break :style opts.style.text.hovered; }
break :style opts.style.text.default; }, else => opts.style.text.default, };
button_widget.fill(.{ .style = button_style });
_ = button_widget.printSegment( .{ .text = opts.text, .style = text_style }, .{ .row_offset = 0, .col_offset = 1 }, );
// now decide the status to return. switch (self.current_event) { .mouse => |mouse| { if (button_widget.hasMouse(mouse)) |_| { if (self.mouse_focused_widget == id and mouse.button == .left and mouse.type == .press) { return .clicked; }
return .hovered; }
return .none; }, else => return .none, } }
pub fn text(self: *Self, win: vaxis.Window, opts: TextOptions) void { _ = win.printSegment( .{ .text = opts.text, .style = opts.style }, .{ .col_offset = opts.x, .row_offset = opts.y, .wrap = .word }, );
if (!opts.allow_selection) return;
// If text can be selected we show the right mouse shape and allow selecting text.
const result_box = win.child(.{ .x_off = opts.x, .y_off = opts.y, .width = @truncate(opts.text.len), .height = 1, });
switch (self.current_event) { .mouse => |mouse| { if (result_box.hasMouse(mouse)) |_| self._vx.setMouseShape(.text); }, else => {}, } }
pub fn padding(_: *Self, win: vaxis.Window, opts: PaddingOpts) vaxis.Window { switch (opts) { .all => |space| return win.child(.{ .x_off = space, .y_off = space, .width = win.width -| (2 * space), .height = win.height -| (2 * space), }), .hv => |space| return win.child(.{ .x_off = space.horizontal, .y_off = space.vertical, .width = win.width -| (2 * space.horizontal), .height = win.height -| (2 * space.vertical), }), .each => |space| return win.child(.{ .x_off = space.left, .y_off = space.top, .width = win.width -| space.left -| space.right, .height = win.height -| space.top -| space.bottom, }), } }
pub fn scrollArea(self: *Self, id: WidgetId, parent: vaxis.Window, opts: ScrollAreaOptions) vaxis.Window { const area = parent.child(.{ .x_off = opts.x, .y_off = opts.y, .width = opts.width orelse parent.width, .height = opts.height orelse parent.height, });
const has_vertical_bar = area.height < opts.content_height; const has_horizontal_bar = area.width < opts.content_width;
if (!has_vertical_bar and !has_horizontal_bar) return area;
if (has_vertical_bar) { const v_thumb_height = area.height * area.height / opts.content_height; const v_thumb_offset = opts.v_content_offset.* * area.height / opts.content_height;
const v_thumb = area.child(.{ .x_off = area.width -| 1, .y_off = @intCast(v_thumb_offset), .width = 1, .height = @intCast(@max(v_thumb_height, 1)), });
const v_thumb_grapheme = switch (self.current_event) { .mouse => |mouse| if (v_thumb.hasMouse(mouse)) |_| "█" else "▐", else => "▐", };
v_thumb.fill(.{ .char = .{ .grapheme = v_thumb_grapheme } });
switch (self.current_event) { .mouse_focus => |mouse| { if (area.hasMouse(mouse)) |_| self.mouse_focused_widget = id; if (v_thumb.hasMouse(mouse)) |_| self.widget_being_dragged = .{ .internal = .{ .widget = id, .element = .v_scroll_thumb }, }; }, .mouse => |mouse| if (self.mouse_focused_widget == id) handle_scroll: { // We always want at least 1 row of the content to be visible. const max_offset = opts.content_height -| 1;
if (mouse.button == .wheel_up and mouse.type == .press) { opts.v_content_offset.* -|= 1; } if (mouse.button == .wheel_down and mouse.type == .press) { const new_offset = opts.v_content_offset.* +| 1; opts.v_content_offset.* = @min(new_offset, max_offset); }
const is_thumb_being_dragged = if (self.widget_being_dragged) |w| switch (w) { .external => false, .internal => |widget| self.mouse_focused_widget == id and widget.widget == id and widget.element == .v_scroll_thumb, } else false;
// Handle dragging the scroll thumb if appropriate if (!is_thumb_being_dragged or mouse.button != .left or mouse.type != .drag) break :handle_scroll; if (self.last_mouse) |last_mouse| { const step_size = @max(opts.content_height / area.height, 1);
const dragging_down = mouse.row > last_mouse.row;
const mouse_movement = if (dragging_down) mouse.row -| last_mouse.row else last_mouse.row -| mouse.row;
const new_offset = if (dragging_down) opts.v_content_offset.* +| (mouse_movement * step_size) else opts.v_content_offset.* -| (mouse_movement * step_size);
opts.v_content_offset.* = @min(new_offset, max_offset); } }, else => {}, } }
if (has_horizontal_bar) { const h_thumb_width = area.width * area.width / opts.content_width; const h_thumb_offset = opts.h_content_offset.* * area.width / opts.content_width;
const h_thumb = area.child(.{ .x_off = @intCast(h_thumb_offset), .y_off = area.height -| 1, .width = @intCast(@max(h_thumb_width, 1)), .height = 1, });
const h_thumb_grapheme = switch (self.current_event) { .mouse => |mouse| if (h_thumb.hasMouse(mouse)) |_| "▄" else "▂", else => "▂", };
h_thumb.fill(.{ .char = .{ .grapheme = h_thumb_grapheme } });
switch (self.current_event) { .mouse_focus => |mouse| { if (area.hasMouse(mouse)) |_| self.mouse_focused_widget = id; if (h_thumb.hasMouse(mouse)) |_| self.widget_being_dragged = .{ .internal = .{ .widget = id, .element = .h_scroll_thumb }, }; }, .mouse => |mouse| if (self.mouse_focused_widget == id) handle_scroll: { // We always want at least 1 column of the content to be visible. const max_offset = opts.content_width -| 1;
// NOTE: Mouse movement inverted, maybe that shouldn't be the case on Windows? if (mouse.button == .wheel_right and mouse.type == .press) { opts.h_content_offset.* -|= 1; } if (mouse.button == .wheel_left and mouse.type == .press) { const new_offset = opts.h_content_offset.* +| 1; opts.h_content_offset.* = @min(new_offset, max_offset); }
const is_thumb_being_dragged = if (self.widget_being_dragged) |w| switch (w) { .external => false, .internal => |widget| self.mouse_focused_widget == id and widget.widget == id and widget.element == .h_scroll_thumb, } else false;
// Handle dragging the scroll thumb if appropriate if (!is_thumb_being_dragged or mouse.button != .left or mouse.type != .drag) break :handle_scroll; if (self.last_mouse) |last_mouse| { const step_size = @max(opts.content_width / area.width, 1);
const dragging_right = mouse.col > last_mouse.col;
const mouse_movement = if (dragging_right) mouse.col -| last_mouse.col else last_mouse.col -| mouse.col;
const new_offset = if (dragging_right) opts.h_content_offset.* +| (mouse_movement * step_size) else opts.h_content_offset.* -| (mouse_movement * step_size);
opts.h_content_offset.* = @min(new_offset, max_offset); } }, else => {}, } }
// Return the body, i.e. the area without the scroll bars.
const body_width = if (has_vertical_bar) area.width -| 1 else area.width; const body_height = if (has_horizontal_bar) area.height -| 1 else area.height; return area.child(.{ .width = body_width, .height = body_height }); }
pub fn window(self: *Self, id: WidgetId, win: vaxis.Window, opts: WindowOptions) vaxis.Window { // 1. Get window.
const window_widget = win.child(.{ .x_off = opts.x.*, .y_off = opts.y.*, .width = opts.width, .height = opts.height, }); window_widget.clear();
// 2. Set focus.
switch (self.current_event) { .mouse_focus => |mouse| { if (window_widget.hasMouse(mouse)) |_| self.mouse_focused_widget = id; }, .mouse => |mouse| { if (window_widget.hasMouse(mouse)) |_| self._vx.setMouseShape(.default); }, else => {}, }
const has_title = !std.mem.eql(u8, opts.title, "");
const inner_window = window_widget.child(.{ .x_off = 1, .y_off = 1, .width = window_widget.width -| 2, .height = window_widget.height -| 2, });
// 2. Draw borders.
const top_border = window_widget.child(.{ .x_off = 1, .width = window_widget.width -| 2, .height = 1, }); const right_border = window_widget.child(.{ .x_off = window_widget.width -| 1, .y_off = 1, .width = 1, .height = window_widget.height -| 2, }); const bottom_border = window_widget.child(.{ .x_off = 1, .y_off = window_widget.height -| 1, .width = window_widget.width -| 2, .height = 1, }); const left_border = window_widget.child(.{ .y_off = 1, .width = 1, .height = window_widget.height -| 2, });
top_border.fill(.{ .char = .{ .grapheme = "─" } }); right_border.fill(.{ .char = .{ .grapheme = "│" } }); bottom_border.fill(.{ .char = .{ .grapheme = "─" } }); left_border.fill(.{ .char = .{ .grapheme = "│" } });
window_widget.writeCell(0, 0, .{ .char = .{ .grapheme = "┌" } }); window_widget.writeCell(window_widget.width -| 1, 0, .{ .char = .{ .grapheme = "┐" } }); window_widget.writeCell( window_widget.width -| 1, window_widget.height -| 1, .{ .char = .{ .grapheme = "┘" } }, ); window_widget.writeCell(0, window_widget.height -| 1, .{ .char = .{ .grapheme = "└" } });
if (!has_title) return inner_window;
// Drag handling
if (self.mouse_focused_widget == id) switch (self.current_event) { .mouse_focus => |mouse| { if (top_border.hasMouse(mouse)) |_| { self.widget_being_dragged = .{ .external = id }; } if (mouse.mods.alt == true) { if (window_widget.hasMouse(mouse)) |_| self.widget_being_dragged = .{ .external = id }; } }, .mouse => |mouse| handle_drag: { const is_widget_being_dragged = if (self.widget_being_dragged) |w| switch (w) { .internal => |_| false, .external => |widget_id| id == widget_id, } else false;
if (is_widget_being_dragged and mouse.button == .left and mouse.type == .drag) { if (self.last_mouse) |last_mouse| { if (mouse.col == last_mouse.col and mouse.row == last_mouse.row) break :handle_drag;
const is_moving_right = mouse.col > last_mouse.col; const is_moving_down = mouse.row > last_mouse.row;
if (is_moving_right) opts.x.* +|= mouse.col - last_mouse.col; if (!is_moving_right) opts.x.* -|= last_mouse.col - mouse.col;
if (is_moving_down) opts.y.* +|= mouse.row - last_mouse.row; if (!is_moving_down) opts.y.* -|= last_mouse.row - mouse.row; } }
if (is_widget_being_dragged and mouse.button == .left and mouse.type == .release) self.widget_being_dragged = null; }, else => {}, };
// Otherwise, include the title bar.
const title_bar = window_widget.child(.{ .x_off = 1, .width = window_widget.width -| 2, .height = 1, });
self.text(title_bar, .{ .text = opts.title });
return inner_window; }
pub fn menuBar(self: *Self, win: vaxis.Window, menus: []const Menu) ?MenuBarAction { const menu_bar = win.child(.{ .height = 1 });
var menu_button_offset: u16 = 0;
var action: ?MenuBarAction = null;
for (menus) |menu| { const menu_width = widest_item_width: { var width: usize = 0; for (menu.items) |item| width = @max(width, item.name.len + 2);
break :widest_item_width width; };
const menu_button = self.button(menu.id, menu_bar, .{ .x = menu_button_offset, .text = menu.name, });
const menu_area = win.child(.{ .x_off = menu_button_offset, .y_off = 1, .width = @min(menu_width, win.width), .height = @min(menu.items.len, win.height), });
// Draw the drop-down menu, if one is open.
if (self.open_menu == menu.id) { for (menu.items, 0..) |item, idx| { const button_text_writer_buf = self.arena().alloc(u8, menu_width) catch @panic("failed to allocate for aligning menu items"); var button_text_writer: std.Io.Writer = .fixed(button_text_writer_buf);
// Make sure button covers the entire menu and make the text left aligned.
button_text_writer.alignBuffer(item.name, menu_width, .left, ' ') catch @panic("failed to center menu item"); const button_text = button_text_writer.buffered();
const button_action = self.button( item.id, menu_area, .{ .y = @truncate(idx), .text = button_text }, );
// If the button is clicked, we close the menu. if (button_action == .clicked) self.open_menu = null; // If any interaction is done with the button we use that as the return // value for this function to communicate which menu was interacted with. if (button_action != .none) action = .{ .id = item.id, .action = button_action }; }
// Close the drop-down menu if you left-click outside it.
switch (self.current_event) { .mouse => |mouse| if (mouse.button == .left and mouse.type == .press) { if (menu_button != .clicked and menu_area.hasMouse(mouse) == null) self.open_menu = null; }, else => {}, } }
const should_toggle_menu = menu_button == .clicked;
if (should_toggle_menu) { self.open_menu = if (self.open_menu == menu.id) null else menu.id; }
menu_button_offset +|= @as(u16, @truncate(menu.name.len)) + 2; }
return action; }
pub fn startLoop( self: *Self, gpa: std.mem.Allocator, updateFn: fn (ctx: UpdateContext) anyerror!UpdateResult, ) !void { main_loop: while (true) { defer _ = self._arena_state.reset(.retain_capacity);
self.current_event = self._loop.nextEvent();
self._vx.setMouseShape(.default);
const win = self._vx.window();
switch (self.current_event) { .winsize => |ws| try self._vx.resize(gpa, self._tty.writer(), ws), .mouse => |mouse| if (mouse.type == .press and @hasField(Event, "mouse_focus")) {
// Builtin widgets read directly from self.current_event so we have to make // sure that's set correctly. It's not sufficient to just pass a different // event to the `updateFn`. const original_mouse_event = self.current_event; self.current_event = .{ .mouse_focus = mouse };
const update_result = try updateFn(.{ .root_win = win, .vx = self._vx, .vxim = self, .current_event = .{ .mouse_focus = mouse }, });
_ = self._arena_state.reset(.retain_capacity);
if (update_result == .stop) break :main_loop;
self.current_event = original_mouse_event; }, else => {}, }
const update_result = try updateFn(.{ .root_win = win, .vx = self._vx, .vxim = self, .current_event = self.current_event, });
switch (self.current_event) { .mouse => |mouse| self.last_mouse = mouse, else => {}, }
// Debug info if (self.show_debug_ui) { const last_cursor_text = try std.fmt.allocPrint( self.arena(), "Last cursor: ({d},{d}) ({?}) ({?})", .{ if (self.last_mouse) |m| m.col else 0, if (self.last_mouse) |m| m.row else 0, if (self.last_mouse) |m| m.button else null, if (self.last_mouse) |m| m.type else null, }, ); const focused_widget_text = try std.fmt.allocPrint( self.arena(), "Focused widget: {?}", .{self.mouse_focused_widget}, ); const widget_being_dragged_text = try std.fmt.allocPrint( self.arena(), "Dragging: {?}", .{self.widget_being_dragged}, );
const widest_text = @max(last_cursor_text.len, focused_widget_text.len, widget_being_dragged_text.len);
const dbg_win = win.child(.{ .x_off = win.width -| 2 -| @as(u16, @truncate(widest_text)), .y_off = 1, .width = @as(u16, @truncate(widest_text)) +| 2, .height = 5, .border = .{ .where = .all }, }); dbg_win.clear();
self.text(dbg_win, .{ .x = dbg_win.width -| @as(u16, @truncate(last_cursor_text.len)), .y = 0, .text = last_cursor_text, }); self.text(dbg_win, .{ .x = dbg_win.width -| @as(u16, @truncate(focused_widget_text.len)), .y = 1, .text = focused_widget_text, }); self.text(dbg_win, .{ .x = dbg_win.width -| @as(u16, @truncate(widget_being_dragged_text.len)), .y = 2, .text = widget_being_dragged_text, });
self.text(win, .{ .x = @as(u16, @intCast(dbg_win.x_off)), .y = @as(u16, @intCast(dbg_win.y_off)) -| 1, .text = "Debug Info", }); }
try self._vx.render(self._tty.writer());
if (update_result == .stop) break :main_loop; } } };}
const std = @import("std");const builtin = @import("builtin");pub const vaxis = @import("vaxis");