Home

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;
}