esc
Externally Scriptable Editor
git clone git://mccd.space/esc
| Log | Files | Refs | README |
commit dec7fd5d28f3b78b405271ac978d1fb4975f54b4 parent 4464a7a4c89da905009dd867cd42c55e7ffa4ef8 Author: Marc Coquand <marc@coquand.email> Date: Sun, 22 Feb 2026 19:40:42 +0100 * Diffstat:
| M | editor.c | | | 181 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------ |
| M | editor.h | | | 27 | +++++++++++++++++++++++---- |
| M | main.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();