TUI editor and editor backend written in Zig
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}