├── .github └── workflows │ └── ci.yml ├── .gitignore ├── README.md ├── build.zig └── src └── main.zig /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | 9 | jobs: 10 | build: 11 | strategy: 12 | fail-fast: false 13 | matrix: 14 | os: [ubuntu-latest] 15 | runs-on: ${{ matrix.os }} 16 | steps: 17 | - uses: actions/checkout@v2 18 | with: 19 | submodules: true 20 | - uses: goto-bus-stop/setup-zig@v1 21 | with: 22 | version: 0.13.0 23 | 24 | - name: Fmt 25 | run: zig fmt . --check 26 | if: matrix.os == 'ubuntu-latest' 27 | 28 | - name: Build 29 | run: zig build 30 | 31 | - name: Build release 32 | run: zig build -Doptimize=ReleaseSafe 33 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .zig-cache/ 2 | zig-out 3 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Gram 2 | 3 | Gram is a Zig port of the [Kilo editor](https://github.com/antirez/kilo) which was written in C. 4 | 5 | Gram has primitive search support and simple syntax highlighting for Zig just as Kilo does for C/C++. 6 | 7 | There are some unsupported features (non-prints), but this implementation tries to stay true to the original as much as possible. 8 | 9 | ![gramv2](https://user-images.githubusercontent.com/25565268/231774694-033b8eb1-0d33-4e28-94ca-7377125acdb1.gif) 10 | 11 | ## Build 12 | 13 | Gram is built on `v0.13.0`. 14 | 15 | ```sh 16 | zig build -Doptimize=ReleaseSafe 17 | ``` 18 | 19 | ## Usage 20 | 21 | ```sh 22 | gram [file_name] 23 | ``` 24 | 25 | This was written in a personal endeavour to learn Zig and may, like the original kilo, 26 | serve as a starting point to write other editors in Zig. 27 | 28 | -------------------------------------------------------------------------------- /build.zig: -------------------------------------------------------------------------------- 1 | const std = @import("std"); 2 | 3 | // Although this function looks imperative, note that its job is to 4 | // declaratively construct a build graph that will be executed by an external 5 | // runner. 6 | pub fn build(b: *std.Build) void { 7 | // Standard target options allows the person running `zig build` to choose 8 | // what target to build for. Here we do not override the defaults, which 9 | // means any target is allowed, and the default is native. Other options 10 | // for restricting supported target set are available. 11 | const target = b.standardTargetOptions(.{}); 12 | 13 | // Standard optimization options allow the person running `zig build` to select 14 | // between Debug, ReleaseSafe, ReleaseFast, and ReleaseSmall. Here we do not 15 | // set a preferred release mode, allowing the user to decide how to optimize. 16 | const optimize = b.standardOptimizeOption(.{}); 17 | 18 | const exe = b.addExecutable(.{ 19 | .name = "gram", 20 | // In this case the main source file is merely a path, however, in more 21 | // complicated build scripts, this could be a generated file. 22 | .root_source_file = b.path("src/main.zig"), 23 | .target = target, 24 | .optimize = optimize, 25 | }); 26 | 27 | // This declares intent for the executable to be installed into the 28 | // standard location when the user invokes the "install" step (the default 29 | // step when running `zig build`). 30 | b.installArtifact(exe); 31 | 32 | // This *creates* a RunStep in the build graph, to be executed when another 33 | // step is evaluated that depends on it. The next line below will establish 34 | // such a dependency. 35 | const run_cmd = b.addRunArtifact(exe); 36 | 37 | // By making the run step depend on the install step, it will be run from the 38 | // installation directory rather than directly from within the cache directory. 39 | // This is not necessary, however, if the application depends on other installed 40 | // files, this ensures they will be present and in the expected location. 41 | run_cmd.step.dependOn(b.getInstallStep()); 42 | 43 | // This allows the user to pass arguments to the application in the build 44 | // command itself, like this: `zig build run -- arg1 arg2 etc` 45 | if (b.args) |args| { 46 | run_cmd.addArgs(args); 47 | } 48 | 49 | // This creates a build step. It will be visible in the `zig build --help` menu, 50 | // and can be selected like this: `zig build run` 51 | // This will evaluate the `run` step rather than the default, which is "install". 52 | const run_step = b.step("run", "Run the app"); 53 | run_step.dependOn(&run_cmd.step); 54 | 55 | // Creates a step for unit testing. 56 | const exe_tests = b.addTest(.{ 57 | .root_source_file = b.path("src/main.zig"), 58 | .target = target, 59 | .optimize = optimize, 60 | }); 61 | 62 | // Similar to creating the run step earlier, this exposes a `test` step to 63 | // the `zig build --help` menu, providing a way for the user to request 64 | // running the unit tests. 65 | const test_step = b.step("test", "Run unit tests"); 66 | test_step.dependOn(&exe_tests.step); 67 | } 68 | -------------------------------------------------------------------------------- /src/main.zig: -------------------------------------------------------------------------------- 1 | const std = @import("std"); 2 | 3 | const fs = std.fs; 4 | const os = std.os; 5 | const io = std.io; 6 | const mem = std.mem; 7 | const ascii = std.ascii; 8 | 9 | const ArrayList = std.ArrayList; 10 | 11 | const GRAM_QUIT_TIMES = 3; 12 | const GRAM_QUERY_LEN = 256; 13 | const GRAM_VERSION = "0.1"; 14 | 15 | const SEPARATORS = " ,.()+-/*=~%[];"; 16 | 17 | const KEYWORDS: [46][]const u8 = [46][]const u8{ 18 | "align", "allowzero", "and", "asm", 19 | "async", "await", "break", "callconv", 20 | "catch", "comptime", "const", "continue", 21 | "defer", "else", "enum", "errdefer", 22 | "error", "export", "extern", "fn", 23 | "for", "if", "inline", "noalias", 24 | "nosuspend", "noinline", "opaque", "or", 25 | "orelse", "packed", "pub", "resume", 26 | "return", "linksection", "struct", "suspend", 27 | "switch", "test", "threadlocal", "try", 28 | "union", "unreachable", "usingnamespace", "var", 29 | "volatile", "while", 30 | }; 31 | 32 | const Row = struct { 33 | src: []u8, 34 | render: []u8, 35 | hl: []Highlight, 36 | }; 37 | 38 | const Highlight = enum(u8) { 39 | number = 31, 40 | match = 34, 41 | string = 35, 42 | comment = 36, 43 | normal = 37, 44 | }; 45 | 46 | const Key = enum(u8) { 47 | ctrl_c = 3, 48 | ctrl_f = 6, 49 | ctrl_h = 8, 50 | tab = 9, 51 | ctrl_l = 12, 52 | enter = 13, 53 | ctrl_q = 17, 54 | ctrl_s = 19, 55 | ctrl_u = 21, 56 | esc = 27, 57 | backspace = 127, 58 | arrow_left = 128, 59 | arrow_right, 60 | arrow_up, 61 | arrow_down, 62 | del, 63 | home, 64 | end, 65 | page_up, 66 | page_down, 67 | _, 68 | }; 69 | 70 | const FindDirection = enum { Next, Previous, None }; 71 | 72 | const Syntax = enum { zig }; 73 | 74 | fn isSeparator(c: u8) bool { 75 | for (SEPARATORS) |s| if (s == c) return true; 76 | return false; 77 | } 78 | 79 | const Editor = struct { 80 | const Self = @This(); 81 | 82 | allocator: mem.Allocator, 83 | file_path: []const u8, 84 | rows: ArrayList(Row), 85 | c: [1]u8, 86 | dirty: bool = false, 87 | quit_times: u3 = GRAM_QUIT_TIMES, 88 | raw_mode: bool = false, 89 | syntax: ?Syntax = Syntax.zig, 90 | orig_termios: os.linux.termios = undefined, 91 | cx: usize = 0, 92 | cy: usize = 0, 93 | row_offset: usize = 0, 94 | col_offset: usize = 0, 95 | screenrows: u16 = 0, 96 | screencols: u16 = 0, 97 | status_message: ArrayList(u8), 98 | 99 | fn init(allocator: mem.Allocator) !Self { 100 | return Self{ 101 | .allocator = allocator, 102 | .file_path = undefined, 103 | .c = [1]u8{0}, 104 | .rows = ArrayList(Row).init(allocator), 105 | .status_message = try ArrayList(u8).initCapacity(allocator, 80), 106 | }; 107 | } 108 | 109 | fn getWindowSize(self: *Self) !void { 110 | var wsz: os.linux.winsize = undefined; 111 | const fd = @as(usize, @bitCast(@as(isize, os.linux.STDOUT_FILENO))); 112 | if (os.linux.ioctl( 113 | fd, 114 | 0x5413, 115 | @intFromPtr(&wsz), 116 | ) == -1 or wsz.ws_col == 0) { 117 | const strn = "\x1b[999C\x1b[999B"; 118 | _ = os.linux.write(os.linux.STDOUT_FILENO, strn, strn.len); 119 | return self.getCursorPosition(); 120 | } else { 121 | self.screenrows = wsz.ws_row; 122 | self.screencols = wsz.ws_col; 123 | } 124 | } 125 | 126 | fn updateWindowSize(self: *Self) !void { 127 | try self.getWindowSize(); 128 | self.screenrows -= 2; 129 | } 130 | 131 | fn findRestoreHighlight( 132 | self: *Self, 133 | saved_hl: *?[]Highlight, 134 | saved_hl_ix: ?usize, 135 | ) void { 136 | if (saved_hl.*) |hl| { 137 | mem.copyForwards(Highlight, self.rows.items[saved_hl_ix.?].hl, hl); 138 | saved_hl.* = null; 139 | } 140 | } 141 | 142 | fn updateSyntax(self: *Self, row: *Row) !void { 143 | row.hl = try self.allocator.realloc(row.hl, row.render.len); 144 | @memset(row.hl, Highlight.normal); 145 | var prev_sep: bool = true; // Tell the parser if 'i' points to start of word. */ 146 | var found_quotes: bool = false; 147 | 148 | if (self.syntax == null) return; 149 | 150 | for (0..row.render.len) |i| { 151 | if (prev_sep and i != row.render.len - 1 and row.render[i] == '/' and row.render[i + 1] == '/') { 152 | @memset(row.hl, Highlight.comment); 153 | return; 154 | } 155 | 156 | if (found_quotes) { 157 | row.hl[i] = Highlight.string; 158 | if (row.render[i] == '"') found_quotes = false; 159 | } else { 160 | if (row.render[i] == '"') { 161 | found_quotes = true; 162 | row.hl[i] = Highlight.string; 163 | prev_sep = false; 164 | continue; 165 | } 166 | } 167 | 168 | if (!ascii.isPrint(row.render[i])) { 169 | row.hl[i] = Highlight.normal; 170 | prev_sep = false; 171 | continue; 172 | } 173 | 174 | if (prev_sep) { 175 | keyword_match: for (KEYWORDS) |keyword| { 176 | if (std.mem.eql( 177 | u8, 178 | keyword, 179 | row.render[i..@min(row.render.len - 1, i + keyword.len)], 180 | ) and isSeparator(row.render[i + keyword.len])) { 181 | prev_sep = false; 182 | @memset(row.hl[i..@min(row.render.len - 1, i + keyword.len)], Highlight.number); 183 | break :keyword_match; 184 | } 185 | } 186 | 187 | prev_sep = false; 188 | continue; 189 | } 190 | prev_sep = isSeparator(row.render[i]); 191 | } 192 | 193 | return; 194 | } 195 | 196 | fn find(self: *Self) !void { 197 | var query: [GRAM_QUERY_LEN]u8 = mem.zeroes([GRAM_QUERY_LEN]u8); 198 | var qlen: usize = 0; 199 | var last_match: ?usize = null; // Last line where a match was found. null for none. 200 | var find_direction: FindDirection = .None; 201 | var saved_hl: ?[]Highlight = null; 202 | var saved_hl_ix: ?usize = null; 203 | 204 | const saved_cx = self.cx; 205 | const saved_cy = self.cy; 206 | const saved_col_offset = self.col_offset; 207 | const saved_row_offset = self.row_offset; 208 | 209 | while (true) { 210 | // 49 is the amount of characters allowed for query, since our status message is capped at 80. 211 | try self.setStatusMessage("Search: {s} (Use ESC/Arrows/Enter)", .{query[0..@min(qlen, 49)]}); 212 | try self.refreshScreen(); 213 | 214 | const c = try self.readKey(); 215 | switch (@as(Key, @enumFromInt(c))) { 216 | .del, .ctrl_h, .backspace => { 217 | if (qlen != 0) { 218 | qlen -= 1; 219 | } 220 | last_match = null; 221 | }, 222 | .enter, .esc => { 223 | if (@as(Key, @enumFromInt(c)) == .esc) { 224 | self.cx = saved_cx; 225 | self.cy = saved_cy; 226 | self.col_offset = saved_col_offset; 227 | self.row_offset = saved_row_offset; 228 | } 229 | 230 | self.findRestoreHighlight(&saved_hl, saved_hl_ix); 231 | try self.setStatusMessage("", .{}); 232 | return; 233 | }, 234 | .arrow_up, .arrow_left => find_direction = .Previous, 235 | .arrow_down, .arrow_right => find_direction = .Next, 236 | else => { 237 | if (ascii.isPrint(c)) { 238 | query[qlen] = c; 239 | qlen += 1; 240 | last_match = null; 241 | } 242 | }, 243 | } 244 | 245 | // Search occurrence. 246 | if (last_match == null) find_direction = .Next; 247 | if (find_direction == .Next or find_direction == .Previous) { 248 | var match: ?usize = null; 249 | var current: usize = if (last_match) |safe_last_match| safe_last_match else 0; 250 | 251 | match_for: for (self.rows.items) |_| { 252 | switch (find_direction) { 253 | FindDirection.Next => current += 1, 254 | FindDirection.Previous => current = if (current - 1 == 0) 0 else current - 1, 255 | else => {}, 256 | } 257 | 258 | if (find_direction == .Previous and current == 0) current = self.rows.items.len - 1; 259 | if (current == self.rows.items.len) current = 0; 260 | 261 | match = mem.indexOf(u8, self.rows.items[current].render, query[0..qlen]); 262 | if (match) |_| { 263 | break :match_for; 264 | } 265 | } 266 | 267 | find_direction = .None; 268 | self.findRestoreHighlight(&saved_hl, saved_hl_ix); 269 | 270 | if (match) |safe_match| { 271 | var row = self.rows.items[current]; 272 | last_match = current; 273 | 274 | saved_hl_ix = current; 275 | saved_hl = try self.allocator.alloc(Highlight, row.render.len); 276 | mem.copyForwards(Highlight, saved_hl.?, row.hl); 277 | @memset(row.hl[safe_match .. safe_match + qlen], Highlight.match); 278 | 279 | self.cy = 0; 280 | self.cx = safe_match; 281 | self.row_offset = current; 282 | self.col_offset = 0; 283 | 284 | if (self.cx > self.screencols) { 285 | const diff = self.cx - self.screencols; 286 | self.cx -= diff; 287 | self.col_offset += diff; 288 | } 289 | } 290 | } 291 | } 292 | } 293 | 294 | // Load the specified program in the editor memory. 295 | fn open(self: *Self, file_path: []const u8) !void { 296 | self.file_path = file_path; 297 | const file = try fs.cwd().createFile(self.file_path, .{ 298 | .read = true, 299 | .truncate = false, 300 | }); 301 | 302 | defer file.close(); 303 | 304 | var i: usize = 0; 305 | // Just read the entire file into memory... what could go wrong 306 | const file_bytes = try file.reader().readAllAlloc(self.allocator, std.math.maxInt(u32)); 307 | var it = std.mem.split(u8, file_bytes, "\n"); 308 | 309 | while (it.next()) |line| { 310 | try self.insertRow(i, line); 311 | i += 1; 312 | } 313 | self.dirty = false; 314 | return; 315 | } 316 | 317 | // Append the string 's' at the end of a row 318 | fn rowAppendString(self: *Self, row: *Row, s: []const u8) !void { 319 | const len = row.src.len; 320 | const s_len = s.len; 321 | row.src = try self.allocator.realloc(row.src[0..len], len + s_len); 322 | _ = self.allocator.resize(row.src[0..len], len + s_len); 323 | 324 | mem.copyForwards(u8, row.src[len .. len + s_len], s); 325 | 326 | try self.updateRow(row); 327 | self.dirty = true; 328 | } 329 | 330 | fn delRow(self: *Self, at: usize) !void { 331 | if (at >= self.rows.items.len) return; 332 | 333 | _ = self.rows.orderedRemove(at); 334 | self.dirty = true; 335 | } 336 | 337 | fn delChar(self: *Self) !void { 338 | const file_row = self.row_offset + self.cy; 339 | var file_col = self.col_offset + self.cx; 340 | 341 | if (file_row >= self.rows.items.len or (file_col == 0 and file_row == 0)) return; 342 | 343 | const row = &self.rows.items[file_row]; 344 | if (file_col == 0) { 345 | file_col = self.rows.items[file_row - 1].src.len; 346 | try self.rowAppendString( 347 | &self.rows.items[file_row - 1], 348 | row.src, 349 | ); 350 | try self.delRow(file_row); 351 | 352 | if (self.cy == 0) self.row_offset -= 1 else self.cy -= 1; 353 | self.cx = file_col; 354 | 355 | if (self.cx >= self.screencols) { 356 | const shift: usize = self.screencols - self.cx + 1; 357 | self.cx -= shift; 358 | self.col_offset += shift; 359 | } 360 | } else { 361 | try self.rowDelChar(row, file_col - 1); 362 | if (self.cx == 0 and self.col_offset > 0) { 363 | self.col_offset -= 1; 364 | } else { 365 | self.cx -= 1; 366 | } 367 | try self.updateRow(row); 368 | } 369 | } 370 | 371 | // Delete the character at offset 'at' from the specified row. 372 | fn rowDelChar(self: *Self, row: *Row, at: usize) !void { 373 | if (row.src.len <= at) return; 374 | 375 | mem.copyForwards(u8, row.src[at..row.src.len], row.src[at + 1 .. row.src.len]); 376 | try self.updateRow(row); 377 | row.src.len -= 1; 378 | self.dirty = true; 379 | } 380 | 381 | /// Insert a character at the specified position in a row, moving the remaining 382 | /// chars on the right if needed. 383 | fn rowInsertChar(self: *Self, row: *Row, at: usize, c: u8) !void { 384 | const old_src = try self.allocator.dupe(u8, row.src); 385 | row.src = try self.allocator.realloc(row.src, old_src.len + 1); 386 | 387 | if (at > row.src.len) { 388 | @memset(row.src[at .. at + 1], c); 389 | } else { 390 | var j: usize = 0; 391 | for (0..row.src.len) |i| { 392 | if (i == at) { 393 | row.src[i] = c; 394 | } else { 395 | row.src[i] = old_src[j]; 396 | j += 1; 397 | } 398 | } 399 | } 400 | 401 | try self.updateRow(row); 402 | self.dirty = true; 403 | } 404 | 405 | fn insertRow(self: *Self, at: usize, buf: []const u8) !void { 406 | if (at < 0 or at > self.rows.items.len) return; 407 | 408 | var row = Row{ .src = try self.allocator.dupe(u8, buf), .render = try self.allocator.alloc(u8, buf.len), .hl = try self.allocator.alloc(Highlight, buf.len) }; 409 | 410 | @memset(row.hl, Highlight.normal); 411 | 412 | try self.updateRow(&row); 413 | try self.rows.insert(at, row); 414 | 415 | self.dirty = true; 416 | } 417 | 418 | // Update the rendered version. 419 | fn updateRow(self: *Self, row: *Row) !void { 420 | self.allocator.free(row.render); 421 | 422 | row.render = try self.allocator.dupe(u8, row.src); 423 | 424 | try self.updateSyntax(row); 425 | } 426 | 427 | fn fixCursor(self: *Self) void { 428 | if (self.cy == self.screenrows - 1) self.row_offset += 1 else self.cy += 1; 429 | 430 | self.cx = 0; 431 | self.col_offset = 0; 432 | } 433 | 434 | fn insertNewline(self: *Self) !void { 435 | const file_row = self.row_offset + self.cy; 436 | var file_col = self.col_offset + self.cx; 437 | 438 | if (file_row >= self.rows.items.len) { 439 | if (file_row == self.rows.items.len) { 440 | try self.insertRow(file_row, ""); 441 | self.fixCursor(); 442 | } 443 | return; 444 | } 445 | 446 | var row = &self.rows.items[file_row]; 447 | if (file_col >= row.src.len) file_col = row.src.len; 448 | 449 | if (file_col == 0) { 450 | try self.insertRow(file_row, ""); 451 | } else { 452 | try self.insertRow(file_row + 1, row.src[file_col..row.src.len]); 453 | 454 | // mem.trim_ 455 | // row.*.src = mem.trimRight(u8, row.src, row.src[file_col..row.src.len]); 456 | var i: usize = 0; 457 | for (row.src[0..file_col]) |c| { 458 | row.src[i] = c; 459 | i += 1; 460 | } 461 | 462 | _ = self.allocator.resize(row.src, file_col); 463 | row.*.src.len = file_col; 464 | // update row 465 | try self.updateRow(row); 466 | } 467 | 468 | self.fixCursor(); 469 | } 470 | 471 | /// Read a key from the terminal put in raw mode, trying to handle 472 | /// escape sequences. 473 | fn readKey(self: *Self) !u8 { 474 | var seq = try self.allocator.alloc(u8, 3); 475 | defer self.allocator.free(seq); 476 | _ = os.linux.read(os.linux.STDIN_FILENO, &self.c, 1); 477 | 478 | switch (self.c[0]) { 479 | @intFromEnum(Key.esc) => { 480 | _ = os.linux.read(os.linux.STDIN_FILENO, seq[0..1], 1); 481 | _ = os.linux.read(os.linux.STDIN_FILENO, seq[1..2], 1); 482 | 483 | if (seq[0] == '[') { 484 | switch (seq[1]) { 485 | '0'...'9' => { 486 | _ = os.linux.read(os.linux.STDIN_FILENO, seq[2..3], 1); 487 | if (seq[2] == '~') { 488 | switch (seq[1]) { 489 | '1' => return @intFromEnum(Key.home), 490 | '3' => return @intFromEnum(Key.del), 491 | '4' => return @intFromEnum(Key.end), 492 | '5' => return @intFromEnum(Key.page_up), 493 | '6' => return @intFromEnum(Key.page_down), 494 | '7' => return @intFromEnum(Key.home), 495 | '8' => return @intFromEnum(Key.end), 496 | else => {}, 497 | } 498 | } 499 | }, 500 | 'A' => return @intFromEnum(Key.arrow_up), 501 | 'B' => return @intFromEnum(Key.arrow_down), 502 | 'C' => return @intFromEnum(Key.arrow_right), 503 | 'D' => return @intFromEnum(Key.arrow_left), 504 | 'H' => return @intFromEnum(Key.home), 505 | 'F' => return @intFromEnum(Key.end), 506 | else => {}, 507 | } 508 | } else if (seq[0] == 'O') { 509 | switch (seq[1]) { 510 | 'H' => return @intFromEnum(Key.home), 511 | 'F' => return @intFromEnum(Key.end), 512 | else => {}, 513 | } 514 | } 515 | 516 | return @intFromEnum(Key.esc); 517 | }, 518 | else => return self.c[0], 519 | } 520 | 521 | return self.c[0]; 522 | } 523 | 524 | fn rowsToString(self: *Self) ![]u8 { 525 | var len: usize = 0; 526 | for (self.rows.items) |row| { 527 | len += row.src.len + 1; 528 | } 529 | 530 | var buf = try self.allocator.alloc(u8, len); 531 | 532 | len = 0; 533 | var prev_len: usize = 0; 534 | for (self.rows.items) |row| { 535 | mem.copyForwards(u8, buf[prev_len .. prev_len + row.src.len], row.src); 536 | mem.copyForwards(u8, buf[prev_len + row.src.len .. prev_len + row.src.len + 1], "\n"); 537 | prev_len += row.src.len + 1; 538 | } 539 | 540 | return buf; 541 | } 542 | 543 | // Save current file on disk. 544 | fn save(self: *Self) !void { 545 | const buf = try self.rowsToString(); 546 | defer self.allocator.free(buf); 547 | 548 | const file = try fs.cwd().createFile( 549 | self.file_path, 550 | .{ 551 | .read = true, 552 | }, 553 | ); 554 | defer file.close(); 555 | 556 | file.writeAll(buf) catch |err| { 557 | return err; 558 | }; 559 | 560 | try self.setStatusMessage("{d} bytes written on disk", .{buf.len}); 561 | 562 | self.dirty = false; 563 | return; 564 | } 565 | 566 | fn processKeypress(self: *Self) !void { 567 | const c = try self.readKey(); 568 | 569 | switch (@as(Key, @enumFromInt(c))) { 570 | .enter => return try self.insertNewline(), 571 | .ctrl_c => return, 572 | .ctrl_q => { 573 | if (self.dirty and self.quit_times > 0) { 574 | try self.setStatusMessage( 575 | "WARNING!!! File has unsaved changes. Press Ctrl-Q {d} more times to quit.", 576 | .{self.quit_times}, 577 | ); 578 | self.quit_times -= 1; 579 | return; 580 | } 581 | self.disableRawMode() catch unreachable; 582 | os.linux.exit(0); 583 | }, 584 | .ctrl_s => { 585 | self.save() catch |err| { 586 | try self.setStatusMessage("Can't save! I/O error: {any}", .{err}); 587 | }; 588 | }, 589 | .ctrl_f => try self.find(), 590 | .backspace, .ctrl_h, .del => { 591 | if (@as(Key, @enumFromInt(c)) == .del) self.moveCursor(@intFromEnum(Key.arrow_right)); 592 | try self.delChar(); 593 | }, 594 | .arrow_left, .arrow_up, .arrow_down, .arrow_right => self.moveCursor(c), 595 | .esc, .ctrl_l => return, 596 | .home => self.cx = 0, 597 | .end => { 598 | if (self.cy < self.rows.items.len) self.cx = self.rows.items[self.cy].src.len; 599 | }, 600 | .page_up, .page_down => |pg| { 601 | if (pg == .page_up and self.cy != 0) { 602 | self.cy = 0; 603 | } else if (pg == .page_down and self.cy != self.screenrows - 1) { 604 | self.cy = self.screenrows - 1; 605 | } 606 | 607 | const direction: Key = 608 | if (pg == Key.page_up) .arrow_up else .arrow_down; 609 | for (0..self.screenrows - 1) |_| { 610 | self.moveCursor(@intFromEnum(direction)); 611 | } 612 | }, 613 | else => try self.insertChar(c), 614 | } 615 | 616 | self.quit_times = GRAM_QUIT_TIMES; // Reset it to the original value. 617 | } 618 | 619 | // Insert 'c' at the current prompt position. 620 | fn insertChar(self: *Self, c: u8) !void { 621 | const file_row = self.row_offset + self.cy; 622 | const file_col = self.col_offset + self.cx; 623 | 624 | if (file_row >= self.rows.items.len) { 625 | for (self.rows.items.len..file_row + 1) |_| try self.insertRow(self.rows.items.len, ""); 626 | } 627 | 628 | try self.rowInsertChar(&self.rows.items[file_row], file_col, c); 629 | 630 | if (self.cx == self.screencols - 1) self.col_offset += 1 else self.cx += 1; 631 | } 632 | 633 | fn enableRawMode(self: *Self) !void { 634 | if (self.raw_mode) return; 635 | 636 | const VMIN = 5; 637 | const VTIME = 6; 638 | 639 | _ = os.linux.tcgetattr(os.linux.STDIN_FILENO, &self.orig_termios); // So we can restore later 640 | var termios = self.orig_termios; 641 | 642 | // input modes: no break, no CR to NL, no parity check, no strip char, no start/stop output ctrl. 643 | termios.iflag = os.linux.tc_iflag_t{}; 644 | // output modes: disable post processing 645 | termios.oflag = os.linux.tc_oflag_t{}; 646 | // control modes: set 8 bit chars 647 | termios.cflag = os.linux.tc_cflag_t{ 648 | .CSIZE = os.linux.CSIZE.CS8, 649 | }; 650 | // local modes: choign off, canonical off, no extended functions, no signal chars (^Z, ^C) 651 | termios.lflag = os.linux.tc_lflag_t{}; 652 | termios.cc[VMIN] = 0; 653 | termios.cc[VTIME] = 1; 654 | 655 | _ = os.linux.tcsetattr(os.linux.STDIN_FILENO, .FLUSH, &termios); 656 | self.raw_mode = true; 657 | } 658 | 659 | fn disableRawMode(self: *Self) !void { 660 | if (self.raw_mode) { 661 | _ = os.linux.tcsetattr(os.linux.STDIN_FILENO, .FLUSH, &self.orig_termios); 662 | self.raw_mode = false; 663 | } 664 | } 665 | 666 | fn deinit(self: *Self) void { 667 | self.disableRawMode() catch unreachable; 668 | for (self.rows.items) |row| { 669 | self.allocator.free(row.src); 670 | self.allocator.free(row.render); 671 | self.allocator.free(row.hl); 672 | } 673 | self.rows.deinit(); 674 | } 675 | 676 | // Writes the whole screen using VT100 escape characters. 677 | fn refreshScreen(self: *Self) !void { 678 | var ab = ArrayList(u8).init(self.allocator); 679 | defer ab.deinit(); 680 | 681 | try ab.appendSlice("\x1b[?25l"); // Hide cursor 682 | try ab.appendSlice("\x1b[H"); 683 | 684 | // Draw rows 685 | for (0..self.screenrows) |y| { 686 | const file_row = self.row_offset + y; 687 | 688 | if (file_row >= self.rows.items.len) { 689 | if (self.rows.items.len == 0 and y == self.screenrows / 3) { 690 | var buf: [32]u8 = undefined; 691 | 692 | const welcome = try std.fmt.bufPrint(&buf, "Gram editor -- version {s}\x1b[0K\r\n", .{GRAM_VERSION}); 693 | const padding: usize = if (welcome.len > self.screencols) 0 else (self.screencols - welcome.len) / 2; 694 | for (0..padding) |_| try ab.appendSlice(" "); 695 | try ab.appendSlice(welcome); 696 | } else { 697 | try ab.appendSlice("~\x1b[0K\r\n"); 698 | } 699 | } else { 700 | const row = &self.rows.items[file_row]; 701 | var len = if (row.render.len <= self.col_offset) 0 else row.render.len - self.col_offset; 702 | var current_color: u8 = 0; 703 | 704 | if (len > 0) { 705 | if (len > self.screencols) len = self.screencols; 706 | 707 | const start = self.col_offset; 708 | for (0..len) |j| { 709 | const hl = row.hl[j]; 710 | switch (hl) { 711 | Highlight.normal => { 712 | if (current_color > 0) { 713 | try ab.appendSlice("\x1b[39m"); 714 | current_color = 0; 715 | } 716 | 717 | try ab.appendSlice(row.render[start + j .. start + j + 1]); 718 | }, 719 | else => { 720 | const color = @intFromEnum(hl); 721 | if (color != current_color) { 722 | var buf: [16]u8 = undefined; 723 | 724 | current_color = color; 725 | try ab.appendSlice(try std.fmt.bufPrint(&buf, "\x1b[{d}m", .{color})); 726 | } 727 | try ab.appendSlice(row.render[start + j .. start + j + 1]); 728 | }, 729 | } 730 | } 731 | } 732 | try ab.appendSlice("\x1b[39m"); 733 | try ab.appendSlice("\x1b[0K"); 734 | try ab.appendSlice("\r\n"); 735 | } 736 | } 737 | 738 | // Create a two status rows status. First row: 739 | try ab.appendSlice("\x1b[0K"); 740 | try ab.appendSlice("\x1b[7m"); 741 | var rstatus: [80]u8 = undefined; 742 | 743 | var status = try std.fmt.allocPrint(self.allocator, "{s} - {d} lines {s}", .{ 744 | self.file_path, 745 | self.rows.items.len, 746 | if (self.dirty) "(modified)" else "", 747 | }); 748 | const len = if (status.len > self.screencols) self.screencols else status.len; 749 | _ = try std.fmt.bufPrint(&rstatus, "{d}/{d}", .{ 750 | self.row_offset + self.cy + 1, 751 | self.rows.items.len, 752 | }); 753 | try ab.appendSlice(status[0..status.len]); 754 | 755 | for (len..self.screencols) |_| { 756 | if (self.screencols - len == rstatus.len) { 757 | try ab.appendSlice(&rstatus); 758 | break; 759 | } else { 760 | try ab.appendSlice(" "); 761 | } 762 | } 763 | try ab.appendSlice("\x1b[0m\r\n"); 764 | 765 | // Second row 766 | try ab.appendSlice("\x1b[0K"); 767 | try ab.appendSlice(self.status_message.items); 768 | 769 | // Draw cursor 770 | var buf: [32]u8 = undefined; 771 | var cx: usize = 1; 772 | const file_row = self.row_offset + self.cy; 773 | 774 | if (file_row < self.rows.items.len) { 775 | const row = self.rows.items[file_row]; 776 | for (self.col_offset..self.col_offset + self.cx) |j| { 777 | if (j < row.src.len and row.src[j] == '\t') cx += 7 - (cx % 8); 778 | cx += 1; 779 | } 780 | } 781 | try ab.appendSlice(try std.fmt.bufPrint(&buf, "\x1b[{d};{d}H", .{ self.cy + 1, cx })); 782 | try ab.appendSlice("\x1b[?25h"); 783 | 784 | _ = os.linux.write(os.linux.STDOUT_FILENO, ab.items.ptr, ab.items.len); 785 | } 786 | 787 | fn moveCursor(self: *Self, c: u8) void { 788 | var file_row = self.row_offset + self.cy; 789 | var file_col = self.col_offset + self.cx; 790 | 791 | switch (@as(Key, @enumFromInt(c))) { 792 | .arrow_left => { 793 | if (self.cx == 0) { 794 | if (self.col_offset > 0) { 795 | self.col_offset -= 1; 796 | } else { 797 | if (file_row > 0) { 798 | self.cy -= 1; 799 | self.cx = self.rows.items[file_row - 1].src.len; 800 | if (self.cx > self.screencols - 1) { 801 | self.col_offset = self.cx - self.screencols + 1; 802 | self.cx = self.screencols - 1; 803 | } 804 | } 805 | } 806 | } else { 807 | self.cx -= 1; 808 | } 809 | }, 810 | .arrow_right => { 811 | if (file_row < self.rows.items.len) { 812 | const row = self.rows.items[file_row]; 813 | 814 | if (file_col < row.src.len) { 815 | if (self.cx == self.screencols - 1) self.col_offset += 1 else self.cx += 1; 816 | } else if (file_col == row.src.len) { 817 | self.cx = 0; 818 | self.col_offset = 0; 819 | 820 | if (self.cy == self.screenrows - 1) self.row_offset += 1 else self.cy += 1; 821 | } 822 | } 823 | }, 824 | .arrow_up => { 825 | if (self.cy == 0) { 826 | if (self.row_offset > 0) self.row_offset -= 1; 827 | } else { 828 | self.cy -= 1; 829 | } 830 | }, 831 | .arrow_down => { 832 | if (file_row < self.rows.items.len) { 833 | if (self.cy == self.screenrows - 1) self.row_offset += 1 else self.cy += 1; 834 | } 835 | }, 836 | else => unreachable, 837 | } 838 | 839 | file_row = self.row_offset + self.cy; 840 | file_col = self.col_offset + self.cx; 841 | const row_len: usize = if (file_row >= self.rows.items.len) 0 else self.rows.items[file_row].src.len; 842 | if (file_col > row_len) { 843 | self.cx -= file_col - row_len; 844 | 845 | if (self.cx < 0) { 846 | self.col_offset += self.cx; 847 | self.cx = 0; 848 | } 849 | } 850 | } 851 | 852 | fn getCursorPosition(self: *Self) !void { 853 | var buf: [32]u8 = undefined; 854 | 855 | const tmp = "\x1b[6n"; 856 | _ = os.linux.write(os.linux.STDOUT_FILENO, tmp, tmp.len); 857 | 858 | for (0..buf.len - 1) |i| { 859 | _ = os.linux.read(os.linux.STDIN_FILENO, &buf, 1); 860 | if (buf[i] == 'R') break; 861 | } 862 | 863 | if (buf[0] != '\x1b' or buf[1] != '[') return error.CursorError; 864 | _ = try self.readKey(); 865 | } 866 | 867 | fn setStatusMessage(self: *Self, comptime format: []const u8, args: anytype) !void { 868 | self.status_message.clearRetainingCapacity(); 869 | const buf = try std.fmt.allocPrint(self.allocator, format, args); 870 | try self.status_message.appendSlice(buf); 871 | } 872 | }; 873 | 874 | pub fn main() !void { 875 | var args = std.process.args(); 876 | _ = args.next(); // ignore self, then read file name 877 | const file_path = args.next() orelse { 878 | std.debug.print("Usage: gram [file_name]\n\n", .{}); 879 | return error.NoFileName; 880 | }; 881 | var gpa = std.heap.GeneralPurposeAllocator(.{}){}; 882 | const allocator = gpa.allocator(); 883 | defer _ = gpa.deinit(); 884 | 885 | var editor = try Editor.init(allocator); 886 | defer editor.deinit(); 887 | try editor.updateWindowSize(); 888 | try editor.open(file_path); 889 | 890 | try editor.enableRawMode(); 891 | try editor.setStatusMessage("HELP: Ctrl-S = save | Ctrl-Q = quit | Ctrl-F = find", .{}); 892 | while (true) { 893 | try editor.refreshScreen(); 894 | try editor.processKeypress(); 895 | } 896 | } 897 | --------------------------------------------------------------------------------