Code:nodes.c

From the change wiki

Writing content sucks. Knowledge is complex and interconnected, but language is linear. No matter how much time you spend editing, it will never flow as well as it could.

That's why I decided to make a new format for content:

  • Everything is a directed graph.
  • Nodes contain short pieces of text
    • In the next versions, I plan to also have image nodes and calculator nodes.
  • Think of it like a gigantic flowchart or mindmap, but you can fit more content than you ever could on a page.

I hope to turn this program into a web app, so that no one has to download & compile code just to view some content. For now, it's just a small experimental desktop app. It's very unfinished and I will probably change a lot in future versions. I haven't even written the usage instructions yet.

Author: User:Elie

Dependencies

Code

Data

Installed programs

This program only runs on Linux.

Code

/***
 This is my new graph nodes program. I don't know what to call it yet.
 
 To compile:
    gcc nodes.c -o nodes -ffast-math -O -lm -lGL -lglut -lpthread

 This program has runtime dependencies:
    File in working directory: "font.data-uint8-1004x19"
    Installed packages: 'zenity' and 'leafpad'

 Copyright 2022, Elie Goldman Smith

 This program is FREE SOFTWARE: you can redistribute it and/or modify
 it under the terms of the GNU General Public License as published by
 the Free Software Foundation, either version 3 of the License, or
 (at your option) any later version.

 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 this program.  If not, see <https://www.gnu.org/licenses/>.
***/
#include "fullscreen_main.h"
#include "text-quads.h"

#include <pthread.h>
#include <string.h>
#include <sys/stat.h>
#include <unistd.h>

#define MAXTEXTLEVELS 7
const float TEXT_BOX_SIZES[MAXTEXTLEVELS] = {3, 4, 5, 7, 9, 11, 14}; // in 'em' units

typedef struct {
 float x,y;
 float dx,dy;
 float size;
 unsigned char r,g,b,a;
 char *text;
 int nTextLevels;
 TQ_Drawable textRenders[MAXTEXTLEVELS];
} Node;
#define MAXNODES 4096
int nNodes=0;
Node nodes[MAXNODES];

typedef struct {
 int from, to;
} Link;
#define MAXLINKS 4096
int nLinks=0;
Link links[MAXLINKS];

int focus = 0; // index of node that is in focus (selected)



void addNodeFrom(int id) {
 if (nNodes >= MAXNODES) return;
 if (nLinks >= MAXLINKS) return;
 //focus=nNodes;
 nodes[nNodes].x = _mouse_x;
 nodes[nNodes].y = _mouse_y;
 nodes[nNodes].size = nodes[id].size + RND()*0.02f;
 int lum;
 lum = nodes[id].r + (rand()&255)-128; if(lum<0)lum=0; if(lum>255)lum=255; nodes[nNodes].r = lum;
 lum = nodes[id].g + (rand()&255)-128; if(lum<0)lum=0; if(lum>255)lum=255; nodes[nNodes].g = lum;
 lum = nodes[id].b + (rand()&255)-128; if(lum<0)lum=0; if(lum>255)lum=255; nodes[nNodes].b = lum;
 nodes[nNodes].a = 255;
 links[nLinks].from = id;
 links[nLinks].to   = nNodes;
 nLinks++;
 nNodes++;
}

void toggleLink(int from, int to) {
 if (to==from) return;
 for (int i=0; i<nLinks; i++) {
  if (links[i].to==to && links[i].from==from) { links[i] = links[--nLinks]; return; } // delete link   XXX: gotta update toMoveLink in some rare cases
  if (links[i].to==from && links[i].from==to) { links[i].to=to; links[i].from=from; return; } // flip direction
 }
 if (nLinks >= MAXLINKS) return;
 links[nLinks].to   = to;
 links[nLinks].from = from;
 nLinks++;
}

int nodeNearest(float x, float y) {
 int which = 0;
 float lowest = 1e36;
 for (int i=0; i<nNodes; i++) {
  float dsq = (nodes[i].x - x)*(nodes[i].x - x) + (nodes[i].y - y)*(nodes[i].y - y);
  if (dsq < lowest) { lowest=dsq; which=i; }
 }
 return which;
}

void eraseNodeText(int id) {
 free(nodes[id].text);
 nodes[id].text = NULL;
 for (int tl=0; tl < nodes[id].nTextLevels; tl++) tq_delete(&nodes[id].textRenders[tl]);
}

void deleteNode(int id) {
 eraseNodeText(id);
 nodes[id] = nodes[--nNodes];
 memset(&nodes[nNodes], 0, sizeof(Node));
 for (int i=0; i<nLinks; i++) {
  if (links[i].to==id || links[i].from==id) links[i--] = links[--nLinks];
  else {
   if (links[i].to == nNodes) links[i].to = id;
   if (links[i].from==nNodes) links[i].from=id;
  }
 } // XXX: also need to update references (toConnect, toPull, toMove, monitorEditNode, anything else?). If it's set to id, set to -1. If it's set to nNodes, set to id.
}

void genNodeTextRenders(int id) {
 int tl=0;
 while (tl<MAXTEXTLEVELS) {
  nodes[id].textRenders[tl] = tq_centered_fitted(nodes[id].text, TEXT_BOX_SIZES[tl], TEXT_BOX_SIZES[tl]);
  if (nodes[id].textRenders[tl++].isComplete) break;
 }
 nodes[id].nTextLevels = tl;
}



void saveToFile(const char *filename) {
 FILE *f = fopen(filename,"w");
 if (!f) return; // TODO: handle error case
 fprintf(f, "view:\nf=%d\nnodes:\n", focus);
 for (int i=0; i<nNodes; i++) {
  fprintf(f, "i=%d c=%02X%02X%02X t=\"", i, (int)nodes[i].r, (int)nodes[i].g, (int)nodes[i].b);
  char *p = nodes[i].text;
  if (p) {
   while (*p) {
    if     (*p=='\n') fputs("\\n", f);
    else if(*p=='\"') fputs("\\\"",f);
    else fputc(*p, f);
    p++;
   }
  }fprintf(f,"\"\n");
 }
 fprintf(f,"connections:\n");
 for (int i=0; i<nLinks; i++) fprintf(f, "a=%d b=%d\n", links[i].from, links[i].to);
 fclose(f);
 printf("Saved to file %s\n", filename);
}


void loadFile(const char *filename) { // TODO: respond more robustly (i.e. to avoid segfault when trying to load an invalid file)
 int success = 0;
 FILE *f = fopen(filename, "r");
 if (f) {
  if (fscanf(f,"view:\nf=%d\nnodes:\n",&focus)>0) {
   // clear existing data
   for (int i=0; i<nNodes; i++) {
    eraseNodeText(i);
    nodes[i].x = RND();
    nodes[i].y = RND();
   }
   nNodes = nLinks = 0;
   // read nodes from file
   for (int i=0; i<MAXNODES; i++) {
    int id; int r,g,b; char c;
    if (fscanf(f, "i=%d c=%02X%02X%02X t=\"", &id, &r, &g, &b)>0) {
     if (id >= 0 && id < MAXNODES) {
      nodes[id].r=r; nodes[id].g=g; nodes[id].b=b; nodes[i].a=255;
      if (nNodes <= id) nNodes = id+1;
      size_t size;
      FILE *ss = open_memstream(&nodes[id].text, &size);
      while (1) {
       c = fgetc(f);
       if (c == '\"' || c == EOF) break;
       if (c == '\\') {
        c = fgetc(f);
        if      (c == EOF) break;
        if      (c == '\"') fputc('\"',ss);
        else if (c == 'n' ) fputc('\n',ss);
        else if (c == '\\') fputc('\\',ss);
        else {
         fputc('\\',ss);
         fputc(c, ss);
        }
       } else fputc(c, ss);
      } fclose(ss);
     }
    } else i=MAXNODES; // reached the line "connections:" so end this for-loop
    while ((c=fgetc(f)) != '\n' && c != EOF); // skip to the next line
   }
   // read the list of connections from file
   for (int i=0; i<MAXLINKS; i++, nLinks++) {
    int a,b;
    if (fscanf(f,"a=%d b=%d\n",&a,&b)==2) {
     links[i].from = a;
     links[i].to   = b;
    } else break;
   } success=1;
  }
  fclose(f);
  if (success) {
   for (int i=0; i<nNodes; i++) genNodeTextRenders(i); // this is done at the end because it might need a lot of memory (fclose() would have freed some)
   printf("Opened file %s\n", filename);
  } else printf("Invalid file %s\n", filename);
 } else perror(filename);
}



void drawCircle(float x, float y, float radius) {
 glBegin(GL_LINE_LOOP);
 float da=(float)M_PI/16.f;
 for (float a = da*0.5f; a < (float)M_PI*2.f; a += da) glVertex2f(x + cosf(a)*radius, y + sinf(a)*radius);
 glEnd();
}



const    char *          monitorFileName = "/tmp/edit-text-node";
volatile struct timespec monitorFileTime = {0};
volatile int             monitorEditNode = -1;

void editTextNode(int id) {
 const char *editor_names[] = {"leafpad","defaulttexteditor","gedit","geany","notepad++","wordpad","notepad","nano","vim","vi","emacs",NULL};
 FILE *f = fopen(monitorFileName, "w");
 if (f) {
  if (nodes[id].text) fputs(nodes[id].text, f);
  fclose(f);
  struct stat st;
  stat(monitorFileName, &st);
  monitorFileTime = st.st_mtim;
  monitorEditNode = id;
  if (fork()==0) {
   for (int i=0; editor_names[i]; i++) execlp(editor_names[0], editor_names[0], monitorFileName, (char*)NULL);
   printf("Can't edit node: No text editor found.\n");
  }
 }
 else {} // TODO: handle error case
}

void *fileMonitor(void *ptr) { // pthread
 while (1) {
  if (monitorEditNode >= 0) {
   struct stat st;
   stat(monitorFileName, &st);
   if (st.st_mtim.tv_sec != monitorFileTime.tv_sec || st.st_mtim.tv_nsec != monitorFileTime.tv_nsec) {
    char *str = malloc(st.st_size+1);
    if (!str) return NULL;        // TODO: handle error case
    FILE *f = fopen(monitorFileName, "r");
    if(!f){free(str);return NULL;}// TODO: handle error case
    int n = fread(str, 1, st.st_size, f);
    str[n]=0;
    fclose(f);
    eraseNodeText(monitorEditNode);
    nodes[monitorEditNode].text = str;
    genNodeTextRenders(monitorEditNode);
    monitorFileTime = st.st_mtim;
    monitorEditNode = -1;
   }
  } sleep(1);
 }
}









void init() {
 tq_init(); glDisable(GL_TEXTURE_2D);
 show_mouse();
 memset(nodes, 0, sizeof(nodes)); // this also initializes any pointers to NULL, so it's safe to call free() on them at any time
 memset(links, -1,sizeof(links)); // -1 is safe, will be interpereted as 'not a link'
 if (_global_argc==2) loadFile(_global_argv[1]);
 if (nNodes < 1) {
  // root node
  nodes[nNodes].size = 0.04f;
  nodes[nNodes].r = 255;
  nodes[nNodes].g = 255;
  nodes[nNodes].b = 255;
  nodes[nNodes].a = 255;
  nNodes++;
 }
 pthread_t fm; // file monitor thread (for editing a node)
 pthread_create(&fm,NULL,fileMonitor,NULL);
 #ifdef USE_MULTISAMPLING
 glLineWidth(1.5f);
 #endif
}


void draw() {
 //==User interface==

 // select node (Left click)
 if (_mouse_button_map[0]==KEY_FRESHLY_PRESSED) focus = nodeNearest(_mouse_x, _mouse_y);

 // drag node (Right click)
 static int toPull = -1;
 if (_mouse_button_map[2]==KEY_FRESHLY_PRESSED) toPull = nodeNearest(_mouse_x, _mouse_y);
 if (_mouse_button_map[2] && toPull >= 0) { nodes[toPull].x = _mouse_x; nodes[toPull].y = _mouse_y; } else toPull = -1;

 // new node (N)
 if (keymap['N']==KEY_FRESHLY_PRESSED) addNodeFrom(focus);

 // edit node text (E)
 if (keymap['E']==KEY_FRESHLY_PRESSED) editTextNode(focus);

 // connect nodes (C, and then Enter or Space)
 static int toConnect = -1;
 if (keymap['C']==KEY_FRESHLY_PRESSED) {
  if (toConnect < 0) toConnect = focus;
  else toConnect = -1;
 }
 if (toConnect >= 0) {
  if (keymap[13]==KEY_FRESHLY_PRESSED) { toggleLink(toConnect, focus); toConnect = -1; }
  if (keymap[' ']==KEY_FRESHLY_PRESSED){ toggleLink(focus, toConnect); toConnect = -1; }
 }
 
 // move node (M, and then Enter or Space)
 static int toMove = -1;
 static int toMoveLink = -1;
 if (keymap['M']==KEY_FRESHLY_PRESSED) {
  if (toMove != focus) {
   toMove = focus;
   toMoveLink = -1;
  }
  while (++toMoveLink < nLinks) { if (links[toMoveLink].to == toMove) break; }
  if (toMoveLink >= nLinks) toMoveLink = toMove = -1;
 }
 if (toMove >= 0 && toMove != focus) {
  if (keymap[13]==KEY_FRESHLY_PRESSED) { links[toMoveLink].from=focus; toMoveLink=toMove=-1; }
  if (keymap[' ']==KEY_FRESHLY_PRESSED){ links[toMoveLink].from=toMove; links[toMoveLink].to=focus; toMoveLink=toMove=-1; }  
 }

 // delete node (Delete)
 if (keymap[127]==KEY_FRESHLY_PRESSED && nNodes>1) { deleteNode(focus); focus = nodeNearest(0,0); }

 // help (F1)
 if (special_keymap[GLUT_KEY_F1]==KEY_FRESHLY_PRESSED) {} // TODO: implement. First have to write the help info.

 // back button (Backspace)
 if (keymap[8] == KEY_FRESHLY_PRESSED) {} // TODO: implement. Need stack and need to figure out what to do in case of deleted nodes

 // save file (Ctrl-S or F5)
 if (keymap[19] ==KEY_FRESHLY_PRESSED || (special_keymap[114] && keymap['S']==KEY_FRESHLY_PRESSED) || special_keymap[GLUT_KEY_F5]==KEY_FRESHLY_PRESSED) {
  char filename[4096];
  FILE *f = popen("zenity --file --title 'Save as...' --save --confirm-overwrite 'Overwrite existing file?' --maximized --close-on-unfocus", "r");
  if (f) {
   char *c = fgets(filename,4096,f);
   if (pclose(f)==0) {
    int len=strlen(filename);
    if (len>1) {
     filename[len-1] = 0; // to remove the newline
     saveToFile(filename);
    } else puts("Empty filename");
   } else puts("No filename selected");
  }
 }
 // load file (Ctrl-O or F6)
 if (keymap[15] ==KEY_FRESHLY_PRESSED || (special_keymap[114] && keymap['O']==KEY_FRESHLY_PRESSED) || special_keymap[GLUT_KEY_F6]==KEY_FRESHLY_PRESSED) {
  char filename[4096];
  FILE *f = popen("zenity --file --title 'Open graph file...' --maximized --close-on-unfocus", "r"); // or could also use --on-top
  if (f) {
   char *c = fgets(filename,4096,f);
   if (pclose(f)==0) {
    int len=strlen(filename);
    if (len>1) {
     filename[len-1] = 0; // to remove the newline
     loadFile(filename);
    } else puts("Empty filename");
   } else puts("No filename selected");
  } // XXX: need to decide: which messages should be in stdout vs stderr
 }


 //==Physics==
 // center the graph
 if (!_mouse_button_map[2]) {
  static float dx=0; dx *= 0.875f; dx += nodes[focus].x / -128;
  static float dy=0; dy *= 0.875f; dy += nodes[focus].y / -128;
  for (int i=0; i<nNodes; i++) {
   nodes[i].x += dx;
   nodes[i].y += dy;
  }
 }
 // establish which nodes are "relevant" aka potentially onscreen
 static Node* r[MAXNODES];
 int nRelevant=0;
 for (int i=0; i<nNodes; i++) {
  if (nodes[i].x*nodes[i].x + nodes[i].y*nodes[i].y < 4.f) {
   r[nRelevant++] = &nodes[i];
   nodes[i].size=0.5f;
  }
 }
 // apply bond forces
 for (int i=0; i<nLinks; i++) {
  float dx = nodes[links[i].to].x - nodes[links[i].from].x;
  float dy = nodes[links[i].to].y - nodes[links[i].from].y;
  float inv = 0.002f / sqrtf(dx*dx+dy*dy+1.f);
  dx *= inv; dy *= inv;      // dy += 0.0002f; // bias to make the graph flow in one direction slightly
  nodes[links[i].to  ].dx -= dx;
  nodes[links[i].to  ].dy -= dy;
  nodes[links[i].from].dx += dx;
  nodes[links[i].from].dy += dy;
 }
 // apply repel forces
 for (int h = 0; h<nRelevant; h++) {
  for (int i=h+1; i<nRelevant; i++) {
   float dx = r[i]->x - r[h]->x;
   float dy = r[i]->y - r[h]->y;
   float maxsize = fabs(dx) > fabs(dy) ? fabs(dx) : fabs(dy); maxsize *= 0.44f;
   if (r[h]->size > maxsize) r[h]->size = maxsize;
   if (r[i]->size > maxsize) r[i]->size = maxsize;
   if (maxsize < 0.0001f) { r[i]->x += RND()*0.0001f; r[i]->y += RND()*0.0001f; }
   float dsq = dx*dx+dy*dy;
   float inv = 1.f/sqrtf(dsq+0.01f); // for normalizing
   inv *= 0.00001f*inv*inv; // for inverse square law
   float falloff = 0.142857f*(7.f - r[i]->x*r[i]->x - r[i]->y*r[i]->y - r[h]->x*r[h]->x - r[h]->y*r[h]->y);
   if (falloff < 0.f) falloff = 0.f;
   inv *= falloff*falloff; // for clustering in distance
   dx *= inv; dy *= inv;
   r[h]->dx -= dx;
   r[h]->dy -= dy;
   r[i]->dx += dx;
   r[i]->dy += dy;
  }
 }
 // update positions
 for (int i=0; i<nNodes; i++) {
  nodes[i].x += nodes[i].dx;
  nodes[i].y += nodes[i].dy;
  nodes[i].dx *= 0.9375f;
  nodes[i].dy *= 0.9375f;
 }


 //==Rendering==
 glClear(GL_DEPTH_BUFFER_BIT | GL_COLOR_BUFFER_BIT);

 // this projection matrix gives us "aspect-ratio-independent" normalized coordinates instead of the standard "normalized device coordinates"
 glMatrixMode(GL_PROJECTION);
 glLoadIdentity();
 glScalef((GLfloat)_screen_size/(GLfloat)_screen_x, (GLfloat)_screen_size/(GLfloat)_screen_y, 1.f);

 // draw the links
 glBegin(GL_LINES);
 glColor3f(1.f,1.f,1.f);
 for (int i=0; i<nLinks; i++) {
  if (links[i].to >= 0 && links[i].from >= 0) {
   // main line
   glVertex2f(nodes[links[i].to  ].x, nodes[links[i].to  ].y);
   glVertex2f(nodes[links[i].from].x, nodes[links[i].from].y);
   // add subtle chevron at the midpoint of the line, to indicate direction
   float mx =(nodes[links[i].to].x + nodes[links[i].from].x)*0.5f;
   float my =(nodes[links[i].to].y + nodes[links[i].from].y)*0.5f;
   float dx = nodes[links[i].to].x - nodes[links[i].from].x;
   float dy = nodes[links[i].to].y - nodes[links[i].from].y;
   float norm = 0.007f / sqrtf(dx*dx + dy*dy);
   dx *= norm;
   dy *= norm;
   glVertex2f(mx-dy-dx, my+dx-dy);
   glVertex2f(mx   +dx, my   +dy);
   glVertex2f(mx   +dx, my   +dy);
   glVertex2f(mx+dy-dx, my-dx-dy);
  }
 }
 glEnd();

 // show new connection to be added (if there is one pending)
 if (toConnect >= 0) {
  glColor3f(0.7f,0.f,0.f);
  glBegin(GL_LINES);
  glVertex2f(nodes[toConnect].x, nodes[toConnect].y);
  glVertex2f(nodes[focus].x,     nodes[focus].y);
  glEnd();
 }

 // show connection that may be moved (if there is one pending)
 if (toMoveLink >= 0) {
  glColor3f(0.0f,0.5f,0.5f);
  glBegin(GL_LINES);
  glVertex2f(nodes[links[toMoveLink].from].x, nodes[links[toMoveLink].from].y);
  glVertex2f(nodes[links[toMoveLink].to  ].x, nodes[links[toMoveLink].to  ].y);
  glEnd();
 }

 // precalculate screen boundaries
 float bx = _screen_x / _screen_size;
 float by = _screen_y / _screen_size;

 // draw the nodes
 for (int i=0; i<nRelevant; i++) { // this implementation uses Immediate Mode. XXX: instead of this, maybe use a vertex array with GL_POINTS, use point sprites with a shader that makes the rounded square shape?
  if (r[i]->size > 0) {
   float x1 = r[i]->x-r[i]->size;
   float y1 = r[i]->y-r[i]->size;
   float x2 = r[i]->x+r[i]->size;
   float y2 = r[i]->y+r[i]->size;
   float c  = r[i]->size*0.57f; if (c>0.01f) c=0.01f; // corner size
   if (x1<bx && x2>-bx && y1<by && y2>-by) {
    glBegin(GL_POLYGON);
    glColor3ub(r[i]->r, r[i]->g, r[i]->b);
    glVertex2f(x2-c, y2  );
    glVertex2f(x1+c, y2  );
    glVertex2f(x1  , y2-c);
    glVertex2f(x1  , y1+c);
    glVertex2f(x1+c, y1  );
    glVertex2f(x2-c, y1  );
    glVertex2f(x2  , y1+c);
    glVertex2f(x2  , y2-c);
    glEnd();
   }
  }
 }
 
 // draw the text on the nodes
 glPushAttrib(GL_ENABLE_BIT); tq_mode();
 glColor3ub(255,255,255);
 for (int i=0; i<nRelevant; i++) {
  // filter out nodes with nothing to show
  if (r[i]->textRenders[0].n <= 0) continue;
  if (r[i]->x - r[i]->size  >  bx) continue;
  if (r[i]->x + r[i]->size  < -bx) continue;
  if (r[i]->y - r[i]->size  >  by) continue;
  if (r[i]->y + r[i]->size  < -by) continue;
  // decide which textRender to use, if any.
  const float FONT_SIZE = 0.015f; // (nominal minimum)
  int tl = r[i]->nTextLevels-1;
  while(tl >= 0 && TEXT_BOX_SIZES[tl]*FONT_SIZE*0.5f > r[i]->size) tl--;
  if   (tl >= 0) {
   // decide whether to make the text black or white
   if (0.2126f*r[i]->r + 0.7152f*r[i]->g + 0.0722f*r[i]->b > 144) glBlendEquation(GL_FUNC_REVERSE_SUBTRACT); else glBlendEquation(GL_FUNC_ADD);
   // render
   glPushMatrix();
   glTranslatef(r[i]->x, r[i]->y, 0.f);
   float scale = r[i]->size * 2.f / TEXT_BOX_SIZES[tl];
   glScalef(scale, scale, 1.f);
   tq_draw(r[i]->textRenders[tl]);
   tq_draw(r[i]->textRenders[tl]); // TODO: instead of drawing twice, make a higher-contrast shader in text-quads.h
   glPopMatrix();
  }
 }
 glPopAttrib();

 // highlight focused node
 glColor3f(1.f,1.f,0.f);
 drawCircle(nodes[focus].x, nodes[focus].y, nodes[focus].size*(float)M_SQRT2);

 // highlight node to be connected
 glColor3f(1.f,0.f,0.f);
 drawCircle(nodes[toConnect].x, nodes[toConnect].y, nodes[toConnect].size*(float)M_SQRT2);

 // highlight node being edited
 glColor3f(0.f,0.f,1.f);
 drawCircle(nodes[monitorEditNode].x, nodes[monitorEditNode].y, nodes[monitorEditNode].size*(float)M_SQRT2);

 // highlight node to be moved
 glColor3f(0.0f,0.5f,0.5f);
 drawCircle(nodes[toMove].x, nodes[toMove].y, nodes[toMove].size*(float)M_SQRT2);
}


void done() {
 for (int i=0; i<nNodes; i++) eraseNodeText(i);
 tq_done();
}