TUI editor and editor backend written in Zig
at main 403 lines 15 kB view raw
1const std = @import("std"); 2const vxim = @import("vxim"); 3const vaxis = vxim.vaxis; 4const vxfw = vaxis.vxfw; 5const builtin = @import("builtin"); 6const ltf = @import("log_to_file"); 7const libfn = @import("libfn"); 8 9const c_mocha = @import("./themes/catppuccin-mocha.zig"); 10 11const Mode = enum { 12 normal, 13 insert, 14 goto, 15 maybe_exit_insert, 16 select, 17}; 18 19const Event = union(enum) { 20 key_press: vaxis.Key, 21 winsize: vaxis.Winsize, 22 mouse: vaxis.Mouse, 23 mouse_focus: vaxis.Mouse, 24}; 25 26const Widget = enum { 27 editor, 28 dbg, 29 file_menu, 30 file_menu_save, 31 file_menu_quit, 32}; 33 34const Vxim = vxim.Vxim(Event, Widget); 35 36// Set some scope levels for the vaxis log scopes and log to file in debug mode. 37pub const std_options: std.Options = if (builtin.mode == .Debug) .{ 38 .log_scope_levels = &.{ 39 .{ .scope = .vaxis, .level = .info }, 40 .{ .scope = .vaxis_parser, .level = .info }, 41 }, 42 .logFn = ltf.log_to_file, 43} else .{ 44 .logFn = ltf.log_to_file, 45}; 46 47var debug_allocator: std.heap.DebugAllocator(.{}) = .init; 48 49const State = struct { 50 gpa: std.mem.Allocator, 51 editor: libfn.Editor, 52 v_scroll: usize = 0, 53 h_scroll: usize = 0, 54 mode: Mode = .normal, 55}; 56var state: State = .{ .gpa = undefined, .editor = undefined }; 57 58pub fn main() !void { 59 const gpa, const is_debug = gpa: { 60 break :gpa switch (builtin.mode) { 61 .Debug, .ReleaseSafe => .{ debug_allocator.allocator(), true }, 62 .ReleaseFast, .ReleaseSmall => .{ std.heap.smp_allocator, false }, 63 }; 64 }; 65 defer if (is_debug) { 66 _ = debug_allocator.deinit(); 67 }; 68 69 // Process arguments. 70 const args = try std.process.argsAlloc(gpa); 71 defer std.process.argsFree(gpa, args); 72 73 if (args.len > 1 and (std.mem.eql(u8, args[1], "--help") or std.mem.eql(u8, args[1], "-h"))) { 74 var buffer: [1024]u8 = undefined; 75 var stdout = std.fs.File.stdout().writer(&buffer); 76 const writer = &stdout.interface; 77 try writer.print("Usage: fn [file]\n", .{}); 78 try writer.print("\n", .{}); 79 try writer.print("General options:\n", .{}); 80 try writer.print("\n", .{}); 81 try writer.print(" -h, --help Print fn help\n", .{}); 82 try writer.print(" -v, --version Print fn version\n", .{}); 83 try writer.flush(); 84 return; 85 } 86 if (args.len > 1 and 87 (std.mem.eql(u8, args[1], "--version") or std.mem.eql(u8, args[1], "-v"))) 88 { 89 var buffer: [1024]u8 = undefined; 90 var stdout = std.fs.File.stdout().writer(&buffer); 91 const writer = &stdout.interface; 92 try writer.print("0.0.0\n", .{}); 93 try writer.flush(); 94 return; 95 } 96 97 state.gpa = gpa; 98 state.editor = try .init(state.gpa); 99 defer state.editor.deinit(state.gpa); 100 101 if (args.len > 1) { 102 try state.editor.openFile(state.gpa, args[1]); 103 } 104 105 var app: Vxim = .init(gpa); 106 defer app.deinit(gpa); 107 108 try app.enterAltScreen(); 109 try app.setMouseMode(true); 110 111 app._vx.window().showCursor(0, 0); 112 113 try app.startLoop(gpa, update); 114} 115 116pub fn update(ctx: Vxim.UpdateContext) !Vxim.UpdateResult { 117 switch (ctx.current_event) { 118 .key_press => |key| if (state.mode == .normal and key.matches('c', .{ .ctrl = true })) 119 return .stop, 120 else => {}, 121 } 122 123 ctx.root_win.clear(); 124 125 // Draw editor. 126 { 127 const scroll_body = ctx.vxim.scrollArea(.editor, ctx.root_win, .{ 128 .y = 1, 129 .height = ctx.root_win.height -| 1, 130 .content_height = state.editor.lineCount(), 131 .content_width = state.editor.longest_line, 132 .v_content_offset = &state.v_scroll, 133 .h_content_offset = &state.h_scroll, 134 }); 135 136 try editor(ctx, scroll_body); 137 138 // Update cursor visibility. 139 draw_cursors: { 140 if (ctx.vxim.open_menu == .file_menu) { 141 scroll_body.hideCursor(); 142 break :draw_cursors; 143 } 144 145 const selection = state.editor.getPrimarySelection(); 146 147 const is_selection_row_visible = selection.cursor.row >= state.v_scroll and 148 selection.cursor.row < state.v_scroll + scroll_body.height; 149 const is_selection_col_visible = selection.cursor.col >= state.h_scroll and 150 selection.cursor.col < state.h_scroll + scroll_body.width; 151 152 if (is_selection_row_visible and is_selection_col_visible) { 153 const cursor_line = state.editor.getLine(selection.cursor.row); 154 const screen_row = selection.cursor.row -| state.v_scroll; 155 156 const screen_col = @min(selection.cursor.col, cursor_line.len) -| state.h_scroll; 157 158 scroll_body.showCursor(@intCast(screen_col), @intCast(screen_row)); 159 } else { 160 scroll_body.hideCursor(); 161 } 162 } 163 } 164 165 // Draw menubar. 166 { 167 const menu_bar_action = ctx.vxim.menuBar(ctx.root_win, &.{ 168 .{ 169 .name = "File", 170 .id = .file_menu, 171 .items = &.{ 172 .{ .name = "Save", .id = .file_menu_save }, 173 .{ .name = "Quit", .id = .file_menu_quit }, 174 }, 175 }, 176 }); 177 178 if (menu_bar_action) |a| { 179 if (a.id == .file_menu_save and a.action == .clicked) try state.editor.saveFile(state.gpa); 180 if (a.id == .file_menu_quit and a.action == .clicked) return .stop; 181 } 182 } 183 184 // Draw debug info. 185 if (builtin.mode == .Debug) { 186 const sel = state.editor.getPrimarySelection(); 187 const cursor_info = try std.fmt.allocPrint(ctx.vxim.arena(), "Cursor: {}", .{ 188 sel.cursor, 189 }); 190 191 const line_count = try std.fmt.allocPrint(ctx.vxim.arena(), "lines: {d}", .{state.editor.lineCount()}); 192 const longest_line = try std.fmt.allocPrint(ctx.vxim.arena(), "longest line: {d}", .{state.editor.longest_line}); 193 194 const dbg_width = @max( 195 cursor_info.len +| 2, 196 line_count.len, 197 longest_line.len, 198 ); 199 var dbg_pos: struct { x: u16, y: u16 } = .{ .x = @intCast(ctx.root_win.width -| dbg_width -| 2), .y = 8 }; 200 const dbg_info = ctx.vxim.window(.dbg, ctx.root_win, .{ 201 .x = &dbg_pos.x, 202 .y = &dbg_pos.y, 203 .width = @intCast(dbg_width), 204 .height = 5, 205 }); 206 207 ctx.vxim.text(dbg_info, .{ .text = cursor_info }); 208 ctx.vxim.text(dbg_info, .{ .text = line_count, .y = 1 }); 209 ctx.vxim.text(dbg_info, .{ .text = longest_line, .y = 2 }); 210 } 211 212 // Update cursor shape. 213 { 214 switch (state.mode) { 215 .insert => ctx.root_win.setCursorShape(.beam_blink), 216 .maybe_exit_insert => {}, // Just keep whatever cursor is currently active. 217 .normal => ctx.root_win.setCursorShape(.block), 218 .goto => ctx.root_win.setCursorShape(.block), 219 .select => ctx.root_win.setCursorShape(.block), 220 } 221 } 222 223 return .keep_going; 224} 225 226fn editor(ctx: Vxim.UpdateContext, container: vaxis.Window) !void { 227 std.debug.assert(state.v_scroll < state.editor.lineCount()); 228 229 switch (ctx.current_event) { 230 .key_press => |key| { 231 std.log.debug("pressed: {}", .{key}); 232 if (state.mode == .normal) { 233 if (key.matches('i', .{})) state.mode = .insert; 234 if (key.matches('I', .{})) { 235 state.editor.moveSelectionsToStartOfLine(); 236 state.mode = .insert; 237 } 238 if (key.matches('A', .{})) { 239 state.editor.moveSelectionsToEndOfLine(); 240 state.mode = .insert; 241 } 242 243 if (key.matches('g', .{})) state.mode = .goto; 244 245 if (key.matches('v', .{})) state.mode = .select; 246 247 if (key.matches('h', .{})) state.editor.moveSelectionsLeft(); 248 if (key.matches(vaxis.Key.left, .{})) state.editor.moveSelectionsLeft(); 249 if (key.matches('j', .{})) state.editor.moveSelectionsDown(); 250 if (key.matches(vaxis.Key.down, .{})) state.editor.moveSelectionsDown(); 251 if (key.matches('k', .{})) state.editor.moveSelectionsUp(); 252 if (key.matches(vaxis.Key.up, .{})) state.editor.moveSelectionsUp(); 253 if (key.matches('l', .{})) state.editor.moveSelectionsRight(); 254 if (key.matches(vaxis.Key.right, .{})) state.editor.moveSelectionsRight(); 255 256 if (key.matches('o', .{})) { 257 try state.editor.startNewLineBelow(state.gpa); 258 state.mode = .insert; 259 } 260 if (key.matches('O', .{})) { 261 try state.editor.startNewLineAbove(state.gpa); 262 state.mode = .insert; 263 } 264 265 if (key.matches('d', .{})) try state.editor.deleteInsideSelections(state.gpa); 266 } else if (state.mode == .insert) { 267 if (key.matches(vaxis.Key.enter, .{})) try state.editor.insertTextAtCursors(state.gpa, "\n"); 268 if (key.matches(vaxis.Key.tab, .{})) try state.editor.insertTextAtCursors(state.gpa, " "); 269 if (key.matches(vaxis.Key.backspace, .{})) try state.editor.deleteCharacterBeforeCursors(state.gpa); 270 if (key.matches(vaxis.Key.escape, .{})) state.mode = .normal; 271 272 if (key.matches(vaxis.Key.left, .{})) state.editor.moveSelectionsLeft(); 273 if (key.matches(vaxis.Key.down, .{})) state.editor.moveSelectionsDown(); 274 if (key.matches(vaxis.Key.up, .{})) state.editor.moveSelectionsUp(); 275 if (key.matches(vaxis.Key.right, .{})) state.editor.moveSelectionsRight(); 276 277 if (key.matches('c', .{ .ctrl = true })) state.mode = .normal; 278 279 // For some reason, when pressing cmd+backspace on macOS it's interpreted as 280 // ctrl+forward_delete. forward_delete has key code 0x75 and doesn't have an alias 281 // in vaxis yet. See https://github.com/rockorager/libvaxis/pull/272. 282 if (key.matches(0x75, .{ .ctrl = true })) try state.editor.deleteToStartOfLine(state.gpa); 283 284 if (key.text) |text| { 285 if (std.mem.eql(u8, text, "j")) 286 state.mode = .maybe_exit_insert 287 else 288 try state.editor.insertTextAtCursors(state.gpa, text); 289 } 290 } else if (state.mode == .goto) { 291 if (key.matches('h', .{})) state.editor.moveSelectionsToStartOfLine(); 292 if (key.matches('l', .{})) state.editor.moveSelectionsToEndOfLine(); 293 294 state.mode = .normal; 295 } else if (state.mode == .maybe_exit_insert) { 296 if (key.matches('k', .{})) { 297 state.mode = .normal; 298 } else { 299 try state.editor.insertTextAtCursors(state.gpa, "j"); 300 if (key.text) |text| try state.editor.insertTextAtCursors(state.gpa, text); 301 state.mode = .insert; 302 } 303 } else if (state.mode == .select) { 304 if (key.matches('h', .{})) state.editor.extendSelectionsLeft(); 305 if (key.matches('j', .{})) state.editor.extendSelectionsDown(); 306 if (key.matches('k', .{})) state.editor.extendSelectionsUp(); 307 if (key.matches('l', .{})) state.editor.extendSelectionsRight(); 308 309 if (key.matches('d', .{})) { 310 try state.editor.deleteInsideSelections(state.gpa); 311 state.mode = .normal; 312 } 313 314 if (key.matches('v', .{})) state.mode = .normal; 315 if (key.matches(vaxis.Key.escape, .{})) state.mode = .normal; 316 } 317 318 if (key.matches('s', .{ .super = true })) try state.editor.saveFile(state.gpa); 319 }, 320 .mouse => |mouse| if (container.hasMouse(mouse)) |_| { 321 if (mouse.button == .left and mouse.type == .press) { 322 // We need to make sure we get the mouse row clicked, relative to the window position. 323 const mouse_row = mouse.row -| @as(u16, @intCast(container.y_off)); 324 const clicked_line = mouse_row +| state.v_scroll; 325 326 // We need to make sure we get the mouse column clicked, relative to the container position. 327 const mouse_col = mouse.col -| @as(u16, @intCast(container.x_off)); 328 const clicked_col = mouse_col +| state.h_scroll; 329 330 const row = @min(clicked_line, state.editor.lineCount() -| 1); 331 const line = state.editor.getLine(row); 332 333 const line_with_h_scroll = if (state.h_scroll > line.len) "" else line[state.h_scroll..]; 334 const visual_line_len = if (std.mem.endsWith(u8, line_with_h_scroll, "\n")) 335 line_with_h_scroll.len -| 1 336 else 337 line_with_h_scroll.len; 338 339 const col = @min( 340 clicked_col, 341 visual_line_len, 342 ); 343 344 std.log.debug("clicked: l {d} c {d}", .{ row, col }); 345 346 state.editor.selections.clearRetainingCapacity(); 347 try state.editor.appendSelection( 348 state.gpa, 349 .createCursor(.{ .row = row, .col = col }), 350 ); 351 } 352 }, 353 else => {}, 354 } 355 356 // Draw text. 357 // FIXME: no need to draw to the end of the file. 358 for (state.v_scroll..state.editor.lineCount()) |idx| { 359 const line = state.editor.getLine(idx); 360 361 if (state.h_scroll > line.len -| 1) continue; 362 363 _ = container.printSegment( 364 .{ .text = line[state.h_scroll..] }, 365 .{ .row_offset = @intCast(idx -| state.v_scroll), .wrap = .none }, 366 ); 367 } 368 369 // Draw selections. 370 for (state.editor.selections.items) |s| { 371 const before = s.toRange().before(); 372 const after = s.toRange().after(); 373 374 for (before.row..after.row + 1) |row| { 375 if (row < state.v_scroll) continue; 376 const screen_row = row -| state.v_scroll; 377 378 const line = state.editor.getLine(row); 379 380 const col_start = if (row == before.row) 381 @min(before.col, line.len) 382 else 383 0; 384 const col_end = if (row == after.row) 385 @min(after.col, line.len) 386 else 387 line.len; 388 389 for (col_start..col_end + 1) |col| { 390 const screen_col = col -| state.h_scroll; 391 if (container.readCell(@intCast(screen_col), @intCast(screen_row))) |cell| { 392 var new_cell = cell; 393 new_cell.style = .{ .reverse = true }; 394 container.writeCell(@intCast(screen_col), @intCast(screen_row), new_cell); 395 } 396 } 397 } 398 } 399} 400 401test "refAllDecls" { 402 std.testing.refAllDeclsRecursive(@This()); 403}