esc

Externally Scriptable Editor

git clone git://mccd.space/esc

commit dec7fd5d28f3b78b405271ac978d1fb4975f54b4
parent 4464a7a4c89da905009dd867cd42c55e7ffa4ef8
Author: Marc Coquand <marc@coquand.email>
Date:   Sun, 22 Feb 2026 19:40:42 +0100

*

Diffstat:
Meditor.c | 181+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------
Meditor.h | 27+++++++++++++++++++++++----
Mmain.c | 253++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------------
3 files changed, 367 insertions(+), 94 deletions(-)
diff --git a/editor.c b/editor.c
@@ -1,5 +1,6 @@
 #include "editor.h"
 #include "strbuf.h"
+#include <SDL3/SDL_stdinc.h>
 #include <ctype.h>
 #include <stdio.h>
 #include <stdlib.h>
@@ -57,15 +58,62 @@ bool editor_write_file(Editor *ed, const char *filename) {
 	}
 	return strbuf_write_file(&ed->text, filename);
 }
+static int utf8_char_to_byte_idx(const char *text, int char_idx) {
+	int byte_pos = 0;
+	int current_char = 0;
+	while (text[byte_pos] != '\0' && current_char < char_idx) {
+		// In UTF-8, leading bytes are not of the form 10xxxxxx (0x80)
+		if ((text[byte_pos] & 0xC0) != 0x80) {
+			current_char++;
+		}
+		byte_pos++;
+		// Skip over the rest of the multi-byte character
+		while (text[byte_pos] != '\0' &&
+		       (text[byte_pos] & 0xC0) == 0x80) {
+			byte_pos++;
+		}
+	}
+	return byte_pos;
+}
+void editor_add_formatting_range(Editor *ed, int start_char, int end_char,
+				 RangeFormat data) {
+	int start = utf8_char_to_byte_idx(ed->text.data, start_char);
+	int end = utf8_char_to_byte_idx(ed->text.data, end_char);
+
+	if (ed->ranges_count >= ed->ranges_capacity) {
+		ed->ranges_capacity =
+		    ed->ranges_capacity == 0 ? 16 : ed->ranges_capacity * 2;
+		ed->ranges = realloc(ed->ranges,
+				     ed->ranges_capacity * sizeof(EditorRange));
+	}
+
+	EditorRange r = {
+	    .start_byte = start, .end_byte = end, .type = RANGE_FORMAT};
+	r.data.format = data;
+
+	// Insert in sorted order
+	size_t i = ed->ranges_count;
+	while (i > 0 && ed->ranges[i - 1].start_byte > start) {
+		ed->ranges[i] = ed->ranges[i - 1];
+		i--;
+	}
+	ed->ranges[i] = r;
+	ed->ranges_count++;
+}
+
+void editor_add_replace_range(Editor *ed, int start_char, int end_char,
+			      int visual_cols) {
+	int start = utf8_char_to_byte_idx(ed->text.data, start_char);
+	int end = utf8_char_to_byte_idx(ed->text.data, end_char);
 
-void editor_add_range(Editor *ed, int start, int end, int visual_cols) {
 	if (ed->ranges_count >= ed->ranges_capacity) {
 		ed->ranges_capacity =
 		    ed->ranges_capacity == 0 ? 16 : ed->ranges_capacity * 2;
 		ed->ranges = realloc(ed->ranges,
 				     ed->ranges_capacity * sizeof(EditorRange));
 	}
-	EditorRange r = {start, end, visual_cols};
+	EditorRange r = {start, end, RANGE_REPLACEMENT,
+			 .data.replacement = visual_cols};
 	// Insert in sorted order to allow binary search
 	size_t i = ed->ranges_count;
 	while (i > 0 && ed->ranges[i - 1].start_byte > start) {
@@ -227,20 +275,61 @@ void editor_delete_forward(Editor *ed) {
 	}
 }
 
+EditorRange *editor_get_replacement_at(Editor *ed, int byte_idx) {
+	for (size_t i = 0; i < ed->ranges_count; i++) {
+		EditorRange *r = &ed->ranges[i];
+		if (r->type == RANGE_REPLACEMENT && byte_idx >= r->start_byte &&
+		    byte_idx < r->end_byte) {
+			return r;
+		}
+	}
+	return NULL;
+}
+
+RangeFormat editor_get_format_at(Editor *ed, int byte_idx) {
+	RangeFormat fmt = {.bold = false, .italic = false};
+	for (size_t i = 0; i < ed->ranges_count; i++) {
+		EditorRange *r = &ed->ranges[i];
+		if (r->type == RANGE_FORMAT && byte_idx >= r->start_byte &&
+		    byte_idx < r->end_byte) {
+			if (r->data.format.bold)
+				fmt.bold = true;
+			if (r->data.format.italic)
+				fmt.italic = true;
+		}
+	}
+	return fmt;
+}
+
 void editor_cursor_left(Editor *ed) {
 	if (ed->cursor_idx > 0) {
-		ed->cursor_idx--;
-		while (ed->cursor_idx > 0 &&
-		       (ed->text.data[ed->cursor_idx] & 0xC0) == 0x80)
-			ed->cursor_idx--;
+		// Are we standing at the end of a replacement?
+		// We need to look backwards to see if the previous byte is
+		// inside a replacement.
+		EditorRange *r =
+		    editor_get_replacement_at(ed, ed->cursor_idx - 1);
+		if (r && ed->cursor_idx == r->end_byte) {
+			ed->cursor_idx = r->start_byte; // Jump back over it
+		} else {
+			const char *ptr = ed->text.data;
+			const char *cur = ptr + ed->cursor_idx;
+			SDL_StepBackUTF8(ptr, &cur);
+			ed->cursor_idx = (int)(cur - ptr);
+		}
 	}
 }
 
 void editor_cursor_right(Editor *ed) {
 	if (ed->cursor_idx < (int)ed->text.len) {
-		const char *next = ed->text.data + ed->cursor_idx;
-		SDL_StepUTF8(&next, NULL);
-		ed->cursor_idx = (int)(next - ed->text.data);
+		// Are we standing at the start of a replacement?
+		EditorRange *r = editor_get_replacement_at(ed, ed->cursor_idx);
+		if (r && ed->cursor_idx == r->start_byte) {
+			ed->cursor_idx = r->end_byte; // Jump over it
+		} else {
+			const char *ptr = ed->text.data + ed->cursor_idx;
+			SDL_StepUTF8(&ptr, NULL);
+			ed->cursor_idx = (int)(ptr - ed->text.data);
+		}
 	}
 }
 
@@ -329,10 +418,9 @@ void editor_set_cursor_from_coords(Editor *ed, float mx, float my,
 
 		last_ptr = ptr;
 
-		// --- NEW: Check for ranges first ---
 		EditorRange *r = editor_get_range_at(ed, current_idx);
-		if (r) {
-			cur_c += r->visual_cols;
+		if (r && r->type == RANGE_REPLACEMENT) {
+			cur_c += r->data.replacement.visual_cols;
 			ptr = ed->text.data + r->end_byte; // Skip pointer ahead
 			continue;
 		}
@@ -439,3 +527,72 @@ char *editor_get_selection(Editor *ed) {
 	result[len] = '\0';
 	return result;
 }
+
+static int byte_to_utf8_char_idx(const char *text, int byte_limit) {
+    int char_idx = 0;
+    for (int i = 0; i < byte_limit && text[i] != '\0'; i++) {
+        if ((text[i] & 0xC0) != 0x80) char_idx++;
+    }
+    return char_idx;
+}
+
+void editor_parse_ansi_codes(Editor *ed) {
+    editor_clear_ranges(ed); 
+
+    const char *data = ed->text.data;
+    size_t len = ed->text.len;
+    
+    RangeFormat current_fmt = { .bold = false, .italic = false };
+    int last_segment_start_byte = 0;
+
+    for (int i = 0; i < (int)len; i++) {
+        if (data[i] == '\x1b' && i + 1 < len && data[i + 1] == '[') {
+            
+            if (i > last_segment_start_byte) {
+                if (current_fmt.bold || current_fmt.italic) {
+                    editor_add_formatting_range(ed, 
+                        byte_to_utf8_char_idx(data, last_segment_start_byte),
+                        byte_to_utf8_char_idx(data, i), 
+                        current_fmt);
+                }
+            }
+
+            int seq_start = i;
+            int seq_end = i + 2;
+            while (seq_end < len && data[seq_end] != 'm') {
+                seq_end++;
+            }
+
+            if (seq_end < len && data[seq_end] == 'm') {
+                for (int p = seq_start + 2; p < seq_end; ) {
+                    int val = atoi(&data[p]);
+                    if (val == 0) { current_fmt.bold = false; current_fmt.italic = false; }
+                    else if (val == 1) { current_fmt.bold = true; }
+                    else if (val == 3) { current_fmt.italic = true; }
+                    else if (val == 22) { current_fmt.bold = false; }
+                    else if (val == 23) { current_fmt.italic = false; }
+                    
+                    while (p < seq_end && (data[p] >= '0' && data[p] <= '9')) p++;
+                    if (p < seq_end && data[p] == ';') p++;
+                }
+
+                editor_add_replace_range(ed, 
+                    byte_to_utf8_char_idx(data, seq_start), 
+                    byte_to_utf8_char_idx(data, seq_end + 1), 
+                    0);
+
+                i = seq_end; 
+                last_segment_start_byte = i + 1;
+            }
+        }
+    }
+
+    if (last_segment_start_byte < len) {
+        if (current_fmt.bold || current_fmt.italic) {
+            editor_add_formatting_range(ed, 
+                byte_to_utf8_char_idx(data, last_segment_start_byte),
+                byte_to_utf8_char_idx(data, len), 
+                current_fmt);
+        }
+    }
+}
diff --git a/editor.h b/editor.h
@@ -2,18 +2,33 @@
 #define EDITOR_H
 #define TAB_SIZE 8
 
+#include "strbuf.h"
 #include <SDL3/SDL.h>
 #include <stdbool.h>
-#include "strbuf.h"
+
+typedef enum { RANGE_REPLACEMENT, RANGE_FORMAT } EditorRangeType;
+
+typedef struct {
+	int visual_cols; 
+} RangeReplacement;
+
+typedef struct {
+	bool bold;
+	bool italic;
+} RangeFormat;
 
 typedef struct {
 	int start_byte;
 	int end_byte;
-	int visual_cols; // 0 for invisible, N for replacements
+	EditorRangeType type;
+	union {
+		RangeReplacement replacement;
+		RangeFormat format;
+	} data;
 } EditorRange;
 
 typedef struct {
-    	StrBuf text;
+	StrBuf text;
 	int cursor_idx;
 	int selection_anchor; // Where the selection started
 
@@ -40,7 +55,11 @@ void editor_clear_selection(Editor *ed);
 void editor_delete_range(Editor *ed, int start, int end);
 bool editor_load_file(Editor *ed, const char *filename);
 bool editor_write_file(Editor *ed, const char *filename);
-void editor_add_range(Editor *ed, int start, int end, int visual_cols);
+void editor_add_formatting_range(Editor *ed, int start, int end, RangeFormat format);
+void editor_add_replace_range(Editor *ed, int start, int end, int visual_cols); 
+EditorRange* editor_get_replacement_at(Editor *ed, int byte_idx); 
+RangeFormat editor_get_format_at(Editor *ed, int byte_idx);
+void editor_parse_ansi_codes(Editor *ed);
 
 // Cursor Movement
 void editor_cursor_left(Editor *ed);
diff --git a/main.c b/main.c
@@ -210,8 +210,8 @@ void handle_events(Editor *ed, SDL_Renderer *renderer, float *scroll_x,
 }
 
 void render_editor(SDL_Renderer *renderer, Editor *ed, TTF_Font *font,
-		   int char_width, float line_height, float cursor_height,
-		   float scroll_x, float scroll_y) {
+		   TTF_Font *bold_font, int char_width, float line_height,
+		   float cursor_height, float scroll_x, float scroll_y) {
 	int render_w, render_h;
 	SDL_GetRenderOutputSize(renderer, &render_w, &render_h);
 
@@ -236,7 +236,7 @@ void render_editor(SDL_Renderer *renderer, Editor *ed, TTF_Font *font,
 
 			EditorRange *r =
 			    editor_get_range_at(ed, start_of_char_idx);
-			if (r) {
+			if (r && r->type == RANGE_REPLACEMENT) {
 				if (start_of_char_idx >= sel_min &&
 				    start_of_char_idx < sel_max) {
 					SDL_FRect sel_rect = {
@@ -244,12 +244,13 @@ void render_editor(SDL_Renderer *renderer, Editor *ed, TTF_Font *font,
 						scroll_x,
 					    20.0f + (cur_row * line_height) -
 						scroll_y,
-					    (float)(r->visual_cols *
+					    (float)(r->data.replacement
+							.visual_cols *
 						    char_width),
 					    cursor_height};
 					SDL_RenderFillRect(renderer, &sel_rect);
 				}
-				cur_col += r->visual_cols;
+				cur_col += r->data.replacement.visual_cols;
 				p = ed->text.data + r->end_byte;
 				if ((int)(p - ed->text.data) >= sel_max)
 					break;
@@ -291,87 +292,180 @@ void render_editor(SDL_Renderer *renderer, Editor *ed, TTF_Font *font,
 		const char *line_start = ed->text.data;
 		while (line_start != NULL && *line_start != '\0') {
 			const char *line_end = strchr(line_start, '\n');
-			size_t len = line_end ? (size_t)(line_end - line_start)
+			size_t line_len = line_end
+					      ? (size_t)(line_end - line_start)
 					      : strlen(line_start);
 
-			if (len > 0 && current_y + line_height > 0 &&
+			if (line_len >= 0 && current_y + line_height > 0 &&
 			    current_y < render_h) {
-				char *temp_line = malloc(
-				    len * TAB_SIZE +
-				    256); // Added padding for range visuals
-				int t_idx = 0;
+				float current_x = 20.0f - scroll_x;
 				int vis_col = 0;
 
-				for (size_t i = 0; i < len; i++) {
+				size_t i = 0;
+				while (i < line_len) {
 					size_t abs_idx =
 					    (line_start - ed->text.data) + i;
-					EditorRange *r = editor_get_range_at(
-					    ed, (int)abs_idx);
-
-					if (r) {
-						for (int v = 0;
-						     v < r->visual_cols; v++) {
-							temp_line[t_idx++] =
-							    ' ';
-						}
-						vis_col += r->visual_cols;
-
-						// Safety handling if the fold
-						// bounds across the new line
-						if (r->end_byte >
-						    (line_start - ed->text.data) +
-							len) {
-							const char
-							    *next_line_start =
-								ed->text.data +
-								r->end_byte;
-							line_end =
-							    next_line_start - 1;
-							break;
-						} else {
-							i = (size_t)(r->end_byte -
-								     (line_start -
-								      ed->text.data)) -
-							    1;
-						}
-						continue;
-					}
 
-					if (line_start[i] == '\t') {
+					// 1. Check for replacements first (they
+					// hide text, so they take priority)
+					EditorRange *rep =
+					    editor_get_replacement_at(
+						ed, (int)abs_idx);
+					size_t chunk_end = i;
+
+					if (rep) {
+						// Chunk is the entire
+						// replacement (clamped to line
+						// end)
+						size_t range_relative_end =
+						    (size_t)(rep->end_byte -
+							     (line_start -
+							      ed->text.data));
+						chunk_end =
+						    (range_relative_end <
+						     line_len)
+							? range_relative_end
+							: line_len;
+
+						// Draw replacement spaces
 						int spaces =
-						    TAB_SIZE -
-						    (vis_col % TAB_SIZE);
-						for (int s = 0; s < spaces;
-						     s++) {
-							temp_line[t_idx++] =
-							    ' ';
+						    rep->data.replacement
+							.visual_cols;
+						if (spaces > 0) {
+							current_x +=
+							    (spaces *
+							     char_width);
+							vis_col += spaces;
 						}
-						vis_col += spaces;
 					} else {
-						if ((line_start[i] & 0xC0) !=
-						    0x80)
-							vis_col++;
-						temp_line[t_idx++] =
-						    line_start[i];
+						// 2. It's regular text. Get the
+						// combined format for this
+						// starting byte.
+						RangeFormat current_format =
+						    editor_get_format_at(
+							ed, (int)abs_idx);
+						chunk_end = i + 1;
+
+						// Scan forward to find where
+						// the formatting changes
+						while (chunk_end < line_len) {
+							size_t next_abs_idx =
+							    (line_start -
+							     ed->text.data) +
+							    chunk_end;
+
+							// Stop if a replacement
+							// range begins
+							if (editor_get_replacement_at(
+								ed,
+								(int)
+								    next_abs_idx)) {
+								break;
+							}
+
+							// Stop if the combined
+							// formatting changes
+							RangeFormat next_format =
+							    editor_get_format_at(
+								ed,
+								(int)
+								    next_abs_idx);
+							if (next_format.bold !=
+								current_format
+								    .bold ||
+							    next_format
+								    .italic !=
+								current_format
+								    .italic) {
+								break;
+							}
+
+							chunk_end++;
+						}
+
+						// 3. Render the chunk using the
+						// appropriate font (If you add
+						// an italic font later, you'd
+						// select it here too)
+						TTF_Font *active_font =
+						    current_format.bold
+							? bold_font
+							: font;
+
+						// Extract and process tab/UTF-8
+						// for this chunk
+						char *chunk_text = malloc(
+						    (chunk_end - i) * TAB_SIZE +
+						    1);
+						int ct_idx = 0;
+						for (size_t j = i;
+						     j < chunk_end; j++) {
+							if (line_start[j] ==
+							    '\t') {
+								int spaces =
+								    TAB_SIZE -
+								    (vis_col %
+								     TAB_SIZE);
+								for (int s = 0;
+								     s < spaces;
+								     s++)
+									chunk_text
+									    [ct_idx++] =
+										' ';
+								vis_col +=
+								    spaces;
+							} else {
+								if ((line_start
+									 [j] &
+								     0xC0) !=
+								    0x80)
+									vis_col++;
+								chunk_text
+								    [ct_idx++] =
+									line_start
+									    [j];
+							}
+						}
+						chunk_text[ct_idx] = '\0';
+
+						if (ct_idx > 0) {
+							SDL_Surface *surf =
+							    TTF_RenderText_Blended(
+								active_font,
+								chunk_text,
+								ct_idx, black);
+							if (surf) {
+								SDL_Texture *tex =
+								    SDL_CreateTextureFromSurface(
+									renderer,
+									surf);
+								SDL_FRect dst =
+								    {current_x,
+								     current_y,
+								     (float)surf
+									 ->w,
+								     (float)surf
+									 ->h};
+								SDL_RenderTexture(
+								    renderer,
+								    tex, NULL,
+								    &dst);
+								current_x +=
+								    surf->w;
+								SDL_DestroyTexture(
+								    tex);
+								SDL_DestroySurface(
+								    surf);
+							}
+						}
+						free(chunk_text);
 					}
+
+					// Move to the start of the next chunk
+					i = chunk_end;
 				}
-				temp_line[t_idx] = '\0';
-				SDL_Surface *surf = TTF_RenderText_Blended(
-				    font, temp_line, t_idx, black);
-				free(temp_line);
-
-				if (surf) {
-					SDL_Texture *tex =
-					    SDL_CreateTextureFromSurface(
-						renderer, surf);
-					SDL_FRect dst = {
-					    20.0f - scroll_x, current_y,
-					    (float)surf->w, (float)surf->h};
-					SDL_RenderTexture(renderer, tex, NULL,
-							  &dst);
-					SDL_DestroyTexture(tex);
-					SDL_DestroySurface(surf);
-				}
+				// Reset font style for safety
+				TTF_SetFontStyle(font, TTF_STYLE_NORMAL);
 			}
 			current_y += line_height;
 			line_start = line_end ? line_end + 1 : NULL;
@@ -382,10 +476,10 @@ void render_editor(SDL_Renderer *renderer, Editor *ed, TTF_Font *font,
 	const char *ptr = ed->text.data;
 	while (ptr < ed->text.data + ed->cursor_idx) {
 		int current_idx = (int)(ptr - ed->text.data);
-		EditorRange *r = editor_get_range_at(ed, current_idx);
+		EditorRange *r = editor_get_replacement_at(ed, current_idx);
 
 		if (r) {
-			cur_col += r->visual_cols;
+			cur_col += r->data.replacement.visual_cols;
 			ptr = ed->text.data + r->end_byte;
 			continue;
 		}
@@ -429,6 +523,8 @@ int main(int argc, char *argv[]) {
 					 SDL_LOGICAL_PRESENTATION_DISABLED);
 
 	TTF_Font *font = TTF_OpenFont("IosevkaTermSS13-Extended.ttf", 24.0f);
+	TTF_Font *bold_font =
+	    TTF_OpenFont("IosevkaTermSS13-ExtendedBold.ttf", 24.0f);
 	int char_width;
 	TTF_GetGlyphMetrics(font, 'A', NULL, NULL, NULL, NULL, &char_width);
 	float line_height = (float)TTF_GetFontHeight(font) * 1.5;
@@ -436,6 +532,7 @@ int main(int argc, char *argv[]) {
 	Editor *ed = editor_create(char_width, line_height);
 	if (argc > 1) {
 		editor_load_file(ed, argv[1]);
+		editor_parse_ansi_codes(ed); 
 	}
 
 	float scroll_x = 0.0f;
@@ -445,16 +542,16 @@ int main(int argc, char *argv[]) {
 
 	SDL_StartTextInput(window);
 
-
 	while (running) {
 		handle_events(ed, renderer, &scroll_x, &scroll_y, line_height,
 			      &running, &is_dragging);
-		render_editor(renderer, ed, font, char_width, line_height,
-			      cursor_height, scroll_x, scroll_y);
+		render_editor(renderer, ed, font, bold_font, char_width,
+			      line_height, cursor_height, scroll_x, scroll_y);
 	}
 
 	editor_destroy(ed);
 	TTF_CloseFont(font);
+	TTF_CloseFont(bold_font);
 	SDL_DestroyRenderer(renderer);
 	SDL_DestroyWindow(window);
 	TTF_Quit();