File: edit9.c
/* edit9 - tiny modal text editor
* Copyright (C) 2025 Scott Urnikis
*
* This program is free software: you can redistribute it and/or modify it under the terms of
* the GNU General Public License version 3 as published by the Free Software Foundation.
*
* This program is distributed in the hope that it will be useful, but WITHOUT ANY
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
* PARTICULAR PURPOSE. See the GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License along with edit9. If
* not, see <https://www.gnu.org/licenses/>.
*/
#include <termios.h> /* tcgetattr, tcsetattr, struct termios */
#include <sys/ioctl.h> /* ioctl, TIOCGWINSZ, struct winsize */
#include <sys/stat.h> /* stat */
#include <unistd.h> /* read, close, write */
#include <fcntl.h> /* open, specifically for O_RDONLY|O_CREAT */
#include <signal.h> /* signal, SIGWINCH */
#include <errno.h> /* errno */
#include <stdlib.h> /* atexit */
#include <string.h> /* strlen */
#include <stdio.h> /* snprintf */
#include <stdarg.h> /* for variadic arguments in dodie */
#include <ctype.h> /* isprint */
#define SYSCOLOR "\x1b[4;33;44m"
#define MESSAGECOLOR "\x1b[4;37;44m"
#define COMMANDCOLOR "\x1b[4;30;41m"
#define INSERTCOLOR "\x1b[4;30;43m"
#define MARKCOLOR "\x1b[4;30;46m"
#define REGIONCOLOR "\x1b[7m"
#define WSCOLOR "\x1b[4;30;42m"
#define TEXTCOLOR "\x1b[0m"
#define LINEWRAP '>'
#define TABWIDTH 3
#define TABDRAW " "
#define GAPINCR 16
#define SAVEMESSAGE "saved file"
#define ENDMESSAGE "end of file"
#define BEGINMESSAGE "beginning of file"
#define COPYMESSAGE "copied region"
#define CUTMESSAGE "cut region"
#define EOFMARKER "eof"
#define PROMPTBUFFERSIZE 128
#define NEWFILEMODE (S_IRUSR|S_IWUSR|S_IRGRP|S_IROTH)
#define die(...) dodie(__LINE__, __FILE__, __VA_ARGS__)
#define ABS(X) ((X) < 0 ? -(X) : (X))
struct bufferiterator { char *c; unsigned tx, ty, gx, gy; int linewrap; int done; };
struct termios t0, t1;
unsigned w, h, toprow, rows;
char in;
struct { unsigned tx, ty, gx, gy, cachedgx; int isupdatingcache; char *c; } cursor;
struct { const char *name; char *start, *gapstart, *gapend, *end; } buffer;
struct { int isactive; unsigned tx, ty; char *c; } mark;
enum { COMMANDMODE, INSERTMODE } mode;
int ws;
size_t gapallocsize = GAPINCR;
int running = 1;
const char *message;
char *clip;
int dodie(int line, const char *file, const char *msg, ...)
{
va_list ap;
va_start(ap, msg);
fprintf(stderr, "die called at %s:%d: ", file, line);
vfprintf(stderr, msg, ap);
putc('\n', stderr);
fflush(stderr);
exit(EXIT_FAILURE); return 0;
}
void size(void)
{
struct winsize ws;
ioctl(1, TIOCGWINSZ, &ws);
w = ws.ws_col;
h = ws.ws_row;
}
void movepen(unsigned x, unsigned y)
{
printf("\x1b[%d;%dH", y + 1, x + 1);
}
int isutf8cont(unsigned char c)
{
return c >> 6 == 0x2;
}
unsigned utf8prefixlen(unsigned char c)
{
return c >> 7 == 0 ? 1 : (c >> 5 == 0x6 ? 2 : (c >> 4 == 0xe ? 3 : (c >> 3 == 0x1e ? 4 : 0)));
}
int isgap(const char *c)
{
return c >= buffer.gapstart && c < buffer.gapend;
}
int bufferiteratorinit(struct bufferiterator *iter)
{
iter->c = buffer.start;
iter->gx = iter->gy = 0; iter->tx = iter->ty = 1;
iter->linewrap = iter->done = 0;
while (isgap(iter->c) || isutf8cont(*iter->c)) iter->c++;
iter->done = iter->c >= buffer.end;
return !iter->done;
}
int bufferiteratornext(struct bufferiterator *iter)
{
if (iter->done) return 0;
const char *prev = iter->c;
do { iter->c++; } while (iter->c <= buffer.end && (isgap(iter->c) || isutf8cont(*iter->c)));
if (iter->c > buffer.end) { iter->done = 1; return 0; }
if (*prev == '\n') { iter->tx = 1; iter->ty++; } else { iter->tx++; }
if (isprint(*prev) || utf8prefixlen(*prev) > 1)
iter->gx++;
else if (*prev == '\n')
iter->gx = 0, iter->gy++;
else if (*prev == '\t')
iter->gx += TABWIDTH;
iter->linewrap = 0;
if (iter->gx >= w - 1) iter->gx = 0, iter->gy++, iter->linewrap = 1;
return 1;
}
int isvisible(unsigned gx, unsigned gy)
{
return gy >= toprow && gy < toprow + h - 1;
}
int inregion(const char *c)
{
return mark.isactive && (c >= mark.c && c < buffer.gapend || c < mark.c && c >= buffer.gapend);
}
void draw(void)
{
struct bufferiterator iter;
printf(TEXTCOLOR "\x1b[2J"); /* text color on, clear the screen */
if (bufferiteratorinit(&iter)) {
do {
unsigned plen = utf8prefixlen(*iter.c);
if (iter.linewrap && isvisible(w - 1, iter.gy - 1)) {
printf(SYSCOLOR "%c" TEXTCOLOR, LINEWRAP);
}
if (isvisible(iter.gx, iter.gy) && (isprint(*iter.c) || isspace(*iter.c) || *iter.c == '\t' || plen > 1)) {
char outbuf[8] = {0}, *out = outbuf;
memcpy(out, iter.c, plen);
const char *style = inregion(iter.c) ? REGIONCOLOR : (ws && isspace(out[0]) ? WSCOLOR : TEXTCOLOR);
if (ws) switch (out[0]) { case ' ': out = "."; break; case '\t': out = ">"; break; case '\n': out = "$"; break; }
movepen(iter.gx, iter.gy + 1 - toprow);
out = out[0] == '\t' ? TABDRAW : out;
if (isprint(out[0]) || plen > 1) printf("%s%s", style, out);
}
} while (bufferiteratornext(&iter));
}
iter.gy++;
if (isvisible(0, iter.gy)) {
movepen(0, iter.gy + 1 - toprow);
printf(TEXTCOLOR SYSCOLOR "%s" TEXTCOLOR, EOFMARKER);
}
if (isvisible(cursor.gx, cursor.gy)) {
char outbuf[8] = {0}, *out = outbuf;
unsigned plen = utf8prefixlen(*cursor.c);
memcpy(out, cursor.c, plen);
movepen(cursor.gx, cursor.gy + 1 - toprow);
out = isprint(out[0]) || plen > 1 ? out : " ";
printf("%s%s" TEXTCOLOR, mark.isactive ? MARKCOLOR : (mode == COMMANDMODE ? COMMANDCOLOR : INSERTCOLOR), out);
}
movepen(0, 0); /* draw filename at top */
printf(SYSCOLOR); /* menu color on */
for (int i = 0; i < w; i++) putchar(' ');
movepen(0, 0);
if (message) {
printf(MESSAGECOLOR "%s" TEXTCOLOR, message);
message = NULL;
}
else {
const char *n = buffer.name ? buffer.name : "new file";
const char *l = buffer.name ? "\"" : "";
const char *m = mode ? "i" : "c";
const char *r = mark.isactive ? "m" : "_";
printf("-*- editing %s%s%s -*- <%s,%s> L%d C%d", l, n, l, m, r, cursor.ty, cursor.tx);
}
fflush(stdout);
}
void resize(int i)
{
size();
draw();
}
void dosignal(int i)
{
exit(1);
}
void reset(void)
{
printf("\x1b[2J");
printf("\x1b[?1049l");
printf("\x1b[?25h");
tcsetattr(1, TCSANOW, &t0); /* reset term to initial config */
fflush(stdout);
}
void init(const char *name)
{
size_t buffersize = gapallocsize + 1;
buffer.name = name;
int fd = 0;
printf("\x1b[?1049h");
printf("\x1b[?25l");
tcgetattr(1, &t0);
t1 = t0;
t1.c_lflag &= (~ECHO & ~ICANON);
tcsetattr(1, TCSANOW, &t1);
if (buffer.name) {
struct stat st;
fd = open(name, O_RDONLY|O_CREAT, NEWFILEMODE);
fd != -1 || die("cannot open file %s\nopen: %s", name, strerror(errno));
fstat(fd, &st) != 1 || die("cannot find file %s size\nstat: %s", name, strerror(errno));
buffersize += st.st_size;
}
buffer.start = malloc(buffersize); memset(buffer.start, '\0', buffersize);
buffer.start || die("unable to allocate %d bytes for in-memory file editing", buffersize);
buffer.end = buffer.start + buffersize - 1; buffer.end[0] = '\0';
buffer.gapstart = buffer.start;
buffer.gapend = buffer.gapstart + GAPINCR;
if (fd) {
char *current = buffer.gapend;
ssize_t saw = 0;
while (current += saw, saw = read(fd, current, buffer.end - current), saw && saw != -1);
saw != -1 || die("cannot read file %s\nread: %s", name, strerror(errno));
close(fd);
}
size();
atexit(reset);
signal(SIGWINCH, resize);
signal(SIGTERM, dosignal);
signal(SIGINT, dosignal);
}
int movegap(const char *c)
{
char *side = ABS(c - buffer.gapstart) < ABS(c - buffer.gapend) ? buffer.gapstart : buffer.gapend;
int step = c - side < 0 ? -1 : 1;
int amount = (c - side) * step;
if (!amount) return 0;
if (step > 0) {
while (amount--) {
if (buffer.gapend == buffer.end) return 0;
*buffer.gapstart = *buffer.gapend;
buffer.gapstart++;
buffer.gapend++;
}
}
else {
while (amount--) {
if (buffer.gapstart == buffer.start) return 0;
buffer.gapstart--;
buffer.gapend--;
*buffer.gapend = *buffer.gapstart;
}
}
return 1;
}
void centercursor(void)
{
int target = cursor.gy - (int)(h / 2);
if (target < 0) target = 0; else if (target > rows) target = rows;
toprow = target;
}
void moveline(unsigned desiredx, unsigned desiredy, int isgraph)
{
struct bufferiterator iter;
const char *before = NULL;
cursor.isupdatingcache = 0;
if (bufferiteratorinit(&iter)) {
do {
unsigned x, y; if (isgraph) x = iter.gx, y = iter.gy; else x = iter.tx, y = iter.ty;
if (x == desiredx && y == desiredy) {
movegap(iter.c);
return;
}
else if (y > desiredy && before) {
movegap(before);
return;
}
before = iter.c;
} while (bufferiteratornext(&iter));
}
if (before) movegap(before);
}
void previousline(void)
{
if (cursor.gy == 0) message = BEGINMESSAGE;
else moveline(cursor.cachedgx, cursor.gy - 1, 1);
}
void nextline(void)
{
if (cursor.gy == rows) message = ENDMESSAGE;
else moveline(cursor.cachedgx, cursor.gy + 1, 1);
}
void grow(void)
{
gapallocsize += GAPINCR;
size_t gapoffset = buffer.gapstart - buffer.start;
size_t oldsize = buffer.end - buffer.start;
size_t newsize = oldsize + gapallocsize + 1;
buffer.start = realloc(buffer.start, newsize);
buffer.start || die("unable to allocate %d bytes for in-memory file editing", newsize);
buffer.gapstart = buffer.start + gapoffset;
buffer.gapend = buffer.gapstart + gapallocsize;
buffer.end = buffer.start + newsize - 1; buffer.end[0] = '\0';
memmove(buffer.gapend, buffer.gapstart, oldsize - gapoffset);
}
const char *prompt(const char *p)
{
static char promptbuffer[PROMPTBUFFERSIZE];
memset(promptbuffer, 0, PROMPTBUFFERSIZE);
unsigned i = 0;
for (;;) {
unsigned start = (p ? strlen(p) : 0) + 1;
movepen(0, 0);
printf(SYSCOLOR);
for (int i = 0; i < w; i++) putchar(' ');
movepen(1, 0);
printf("%s", p ? p : "");
movepen(start, 0);
printf("%s" INSERTCOLOR " " TEXTCOLOR, promptbuffer);
fflush(stdout);
read(1, &in, 1);
if (in == '\x7f') promptbuffer[i == 0 ? i : --i] = '\0';
else if (i < PROMPTBUFFERSIZE - 1 && isprint(in)) promptbuffer[i++] = in;
else if (in == '\n' || in == '\r' || in == '\x1b') return in == '\x1b' ? NULL : promptbuffer;
}
}
void insert(char c)
{
if (buffer.gapstart == buffer.gapend) grow();
*buffer.gapstart++ = c;
}
char *prevchar(char *here)
{
while (here != buffer.start && --here > buffer.start && (isutf8cont(*here) || isgap(here)));
return here;
}
void save(void)
{
const char *name;
if (name = buffer.name ? buffer.name : prompt("file name: ")) {
int fd = open(name, O_WRONLY|O_CREAT|O_TRUNC, NEWFILEMODE);
fd != -1 || die("cannot open file %s\nopen: %s", name, strerror(errno));
if (*prevchar(buffer.end) != '\n') { char *r = buffer.gapstart; movegap(buffer.end); insert('\n'); movegap(r); }
char *current = buffer.start;
ssize_t written = 0;
while (current += written, written = write(fd, current, buffer.gapstart - current), written && written != -1);
written != -1 || die("cannot write file %s\nwrite: %s", name, strerror(errno));
current = buffer.gapend;
written = 0;
while (current += written, written = write(fd, current, buffer.end - current), written && written != -1);
written != -1 || die("cannot write file %s\nwrite: %s", name, strerror(errno));
message = SAVEMESSAGE;
buffer.name = name;
}
}
char *spantocstr(char *p, char *a, char *b)
{
if (b < a) { char *t = a; a = b; b = t; }
(p = realloc(p, b - a + 1)) || die("unable to allocate %d bytes for copying span of text", b - a + 1);
memcpy(p, a, b - a); p[b - a] = '\0';
return p;
}
void copyregion()
{
clip = mark.isactive ? spantocstr(clip, mark.c, mark.c <= buffer.gapstart ? buffer.gapstart : buffer.gapend) : clip;
}
void layout()
{
struct bufferiterator iter;
cursor.gx = cursor.gy = cursor.tx = cursor.ty = 0; cursor.c = " ";
if (bufferiteratorinit(&iter)) {
do {
if (iter.c == buffer.gapend) {
cursor.gx = iter.gx;
cursor.gy = iter.gy;
cursor.tx = iter.tx;
cursor.ty = iter.ty;
cursor.c = iter.c;
if (cursor.isupdatingcache) cursor.cachedgx = cursor.gx;
}
if (mark.isactive && mark.tx == iter.tx && mark.ty == iter.ty) {
mark.c = iter.c;
}
} while (bufferiteratornext(&iter));
}
rows = iter.gy;
if (!isvisible(cursor.gx, cursor.gy)) centercursor();
}
void deleteto(char *to)
{
char **from = (to <= buffer.gapstart) ? &buffer.gapstart : &buffer.gapend;
to = to < buffer.start ? buffer.start : (to > buffer.end ? buffer.end : to);
int step = *from < to ? 1 : -1;
while (isutf8cont(*to) && to != buffer.start && to != buffer.end) to += step;
*from = to;
}
int promptu(const char *p, unsigned base, unsigned *resp)
{
char *end; long r; const char *s = prompt(p);
r = strtol(s = s ? s : "", &end, base);
if (*s == '\0' || *end != '\0' || r < 0) return 0;
*resp = r; return 1;
}
void insertutf8(void)
{
unsigned cpi; if (!promptu("enter unicode codepoint: U+", 16, &cpi) || cpi > 0x10ffff) return;
if (cpi <= 0x7f) {
insert(cpi);
}
else if (cpi <= 0x7ff) {
insert(0xc0 | (cpi >> 6));
insert(0x80 | (cpi & 0x3f));
}
else if (cpi <= 0xffff) {
insert(0xe0 | (cpi >> 12));
insert(0x80 | ((cpi >> 6) & 0x3f));
insert(0x80 | (cpi & 0x3f));
}
else if (cpi <= 0x10ffff) {
insert(0xf0 | (cpi >> 18));
insert(0x80 | ((cpi >> 12) & 0x3f));
insert(0x80 | ((cpi >> 6) & 0x3f));
insert(0x80 | (cpi & 0x3f));
}
}
void interpret()
{
cursor.isupdatingcache = 1;
in = in == '\r' ? '\n' : in;
if (mode == COMMANDMODE) {
switch (in) {
case 'i': mode = INSERTMODE; mark.isactive = 0; break;
case 'Q': running = 0; break;
case 'f': while (movegap(buffer.gapend + 1) && isutf8cont(*buffer.gapend)); break;
case 'b': while (movegap(buffer.gapstart - 1) && isutf8cont(*buffer.gapstart - 1)); break;
case 'd': deleteto(mark.isactive ? mark.c : buffer.gapend + 1); mark.isactive = 0; break;
case '\x7f': deleteto(mark.isactive ? mark.c : buffer.gapstart - 1); mark.isactive = 0; break;
case 'p': previousline(); break;
case 'n': nextline(); break;
case 'g': { unsigned dty; if (promptu("enter line number: ", 10, &dty)) moveline(cursor.tx, dty, 0); } break;
case 'e': while (*buffer.gapend != '\n' && movegap(buffer.gapend + 1)); break;
case 'a': while (*prevchar(buffer.gapstart) != '\n' && movegap(buffer.gapstart - 1)); break;
case 'l': centercursor(); break;
case 'm': if (mark.isactive = !mark.isactive) mark.tx = cursor.tx, mark.ty = cursor.ty; break;
case 's': save(); break;
case 'c': if (mark.isactive) { copyregion(); mark.isactive = 0; message = COPYMESSAGE; } break;
case 'x': if (mark.isactive) { copyregion(); deleteto(mark.c); mark.isactive = 0; message = CUTMESSAGE; } break;
case 'y': if (clip) { char *c = clip; while (*c) insert(*c++); } break;
case 'w': ws = !ws; break;
case '>': movegap(buffer.end); break;
case '<': movegap(buffer.start); break;
case 'u': insertutf8(); break;
default: if (in == '\n' || in == ' ' || in == '\t') insert(in); break;
}
}
else {
switch (in) {
case '\x1b': mode = COMMANDMODE; break;
case '\x7f': if (buffer.gapstart > buffer.start) buffer.gapstart--; break;
default:
if (isprint(in) || in == '\n' || in == '\t') {
insert(in);
}
}
}
}
int main(int argc, char **argv)
{
argc <= 2 || die("unknown argument \"%s\"", argv[2]);
init(argc == 2 ? argv[1] : NULL);
while (running) {
layout();
draw();
read(1, &in, 1);
interpret();
}
return 0;
}