commit e714a12f36b44d6b67fcad122c858af6b903bdb2
parent 59e4368e073cc9c2a99bfb26f928e120502c5ce5
Author: lumidify <nobody@lumidify.org>
Date:   Sun, 27 Aug 2023 21:55:58 +0200
Add basic support for external line editor
Diffstat:
15 files changed, 404 insertions(+), 45 deletions(-)
diff --git a/.ltk/ltk.cfg b/.ltk/ltk.cfg
@@ -1,9 +1,9 @@
 [general]
 explicit-focus = true
 all-activatable = true
+line-editor = "st -e vi %f"
 # In future:
 # text-editor = ...
-# line-editor = ...
 
 [key-binding:widget]
 # In future:
@@ -44,6 +44,7 @@ bind-keypress expand-selection-right sym right mods shift
 bind-keypress selection-to-clipboard text c mods ctrl
 bind-keypress paste-clipboard text v mods ctrl
 bind-keypress switch-selection-side text o mods alt
+bind-keypress edit-external text E mods ctrl
 
 # default mapping (just to silence warnings)
 [key-mapping]
diff --git a/Makefile b/Makefile
@@ -109,7 +109,7 @@ src/ltkd: $(OBJ)
 	$(CC) -o $@ $(OBJ) $(LTK_LDFLAGS)
 
 src/ltkc: src/ltkc.o src/util.o src/memory.o
-	$(CC) -o $@ src/ltkc.o src/util.o src/memory.o $(LTK_LDFLAGS)
+	$(CC) -o $@ src/ltkc.o src/util.o src/memory.o src/txtbuf.o $(LTK_LDFLAGS)
 
 $(OBJ) : $(HDR)
 
diff --git a/src/config.c b/src/config.c
@@ -435,6 +435,7 @@ destroy_config(ltk_config *c) {
 		}
 		ltk_free(c->mappings[i].mappings);
 	}
+	ltk_free(c->general.line_editor);
 	ltk_free(c->mappings);
 	ltk_free(c);
 }
@@ -494,6 +495,7 @@ load_from_text(
 	config->mappings_alloc = config->mappings_len = 0;
 	config->general.explicit_focus = 0;
 	config->general.all_activatable = 0;
+	config->general.line_editor = NULL;
 
 	struct lexstate s = {filename, file_contents, len, 0, 1, 0};
 	struct token tok = next_token(&s);
@@ -548,6 +550,8 @@ load_from_text(
 							msg = "Invalid boolean setting";
 							goto error;
 						}
+					} else if (str_array_equal("line-editor", prev2tok.text, prev2tok.len)) {
+						config->general.line_editor = ltk_strndup(tok.text, tok.len);
 					} else {
 						msg = "Invalid setting";
 						goto error;
@@ -658,9 +662,10 @@ ltk_config_parsefile(
     keyrelease_binding_handler release_handler,
     char **errstr) {
 	unsigned long len = 0;
-	char *file_contents = ltk_read_file(filename, &len);
+	char *ferrstr = NULL;
+	char *file_contents = ltk_read_file(filename, &len, &ferrstr);
 	if (!file_contents) {
-		*errstr = ltk_print_fmt("Unable to open file \"%s\"", filename);
+		*errstr = ltk_print_fmt("Unable to open file \"%s\": %s", filename, ferrstr);
 		return 1;
 	}
 	int ret = load_from_text(filename, file_contents, len, press_handler, release_handler, errstr);
diff --git a/src/config.h b/src/config.h
@@ -36,6 +36,7 @@ typedef struct {
 } ltk_language_mapping;
 
 typedef struct {
+	char *line_editor;
 	char explicit_focus;
 	char all_activatable;
 } ltk_general_config;
diff --git a/src/entry.c b/src/entry.c
@@ -14,7 +14,7 @@
  * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
  */
 
-/* FIXME: allow opening text in external program */
+/* FIXME: mouse actions for expanding selection (shift+click) */
 /* FIXME: cursors jump weirdly with bidi text
    (need to support strong/weak cursors in pango backend) */
 /* FIXME: set imspot - needs to be standardized so widgets don't all do their own thing */
@@ -58,6 +58,7 @@ static int ltk_entry_mouse_release(ltk_widget *self, ltk_button_event *event);
 static int ltk_entry_motion_notify(ltk_widget *self, ltk_motion_event *event);
 static int ltk_entry_mouse_enter(ltk_widget *self, ltk_motion_event *event);
 static int ltk_entry_mouse_leave(ltk_widget *self, ltk_motion_event *event);
+static void ltk_entry_cmd_return(ltk_widget *self, char *text, size_t len);
 
 /* FIXME: also allow binding key release, not just press */
 typedef void (*cb_func)(ltk_entry *, ltk_key_event *);
@@ -78,9 +79,10 @@ static void paste_clipboard(ltk_entry *entry, ltk_key_event *event);
 static void select_all(ltk_entry *entry, ltk_key_event *event);
 static void delete_char_backwards(ltk_entry *entry, ltk_key_event *event);
 static void delete_char_forwards(ltk_entry *entry, ltk_key_event *event);
+static void edit_external(ltk_entry *entry, ltk_key_event *event);
 static void recalc_ideal_size(ltk_entry *entry);
 static void ensure_cursor_shown(ltk_entry *entry);
-static void insert_text(ltk_entry *entry, char *text, size_t len);
+static void insert_text(ltk_entry *entry, char *text, size_t len, int move_cursor);
 
 struct key_cb {
 	char *text;
@@ -94,6 +96,7 @@ static struct key_cb cb_map[] = {
 	{"cursor-to-end", &cursor_to_end},
 	{"delete-char-backwards", &delete_char_backwards},
 	{"delete-char-forwards", &delete_char_forwards},
+	{"edit-external", &edit_external},
 	{"expand-selection-left", &expand_selection_left},
 	{"expand-selection-right", &expand_selection_right},
 	{"paste-clipboard", &paste_clipboard},
@@ -170,6 +173,7 @@ static struct ltk_widget_vtable vtable = {
 	.motion_notify = <k_entry_motion_notify,
 	.mouse_leave = <k_entry_mouse_leave,
 	.mouse_enter = <k_entry_mouse_enter,
+	.cmd_return = <k_entry_cmd_return,
 	.change_state = NULL,
 	.get_child_at_pos = NULL,
 	.resize = NULL,
@@ -425,7 +429,7 @@ paste_primary(ltk_entry *entry, ltk_key_event *event) {
 	(void)event;
 	txtbuf *buf = ltk_clipboard_get_primary_text(entry->widget.window->clipboard);
 	if (buf)
-		insert_text(entry, buf->text, buf->len);
+		insert_text(entry, buf->text, buf->len, 1);
 }
 
 static void
@@ -433,7 +437,7 @@ paste_clipboard(ltk_entry *entry, ltk_key_event *event) {
 	(void)event;
 	txtbuf *buf = ltk_clipboard_get_clipboard_text(entry->widget.window->clipboard);
 	if (buf)
-		insert_text(entry, buf->text, buf->len);
+		insert_text(entry, buf->text, buf->len, 1);
 }
 
 static void
@@ -529,7 +533,7 @@ ensure_cursor_shown(ltk_entry *entry) {
 
 /* FIXME: maybe make this a regular key binding with wildcard text like in ledit? */
 static void
-insert_text(ltk_entry *entry, char *text, size_t len) {
+insert_text(ltk_entry *entry, char *text, size_t len, int move_cursor) {
 	size_t num = 0;
 	/* FIXME: this is ugly and there are probably a lot of other
 	   cases that need to be handled */
@@ -558,7 +562,8 @@ insert_text(ltk_entry *entry, char *text, size_t len) {
 		if (text[i] != '\n' && text[i] != '\r')
 			entry->text[j++] = text[i];
 	}
-	entry->pos += reallen;
+	if (move_cursor)
+		entry->pos += reallen;
 	entry->text[entry->len] = '\0';
 	ltk_text_line_set_text(entry->tl, entry->text, 0);
 	recalc_ideal_size(entry);
@@ -567,6 +572,29 @@ insert_text(ltk_entry *entry, char *text, size_t len) {
 	ltk_window_invalidate_widget_rect(entry->widget.window, &entry->widget);
 }
 
+static void
+ltk_entry_cmd_return(ltk_widget *self, char *text, size_t len) {
+	ltk_entry *e = (ltk_entry *)self;
+	wipe_selection(e);
+	e->len = e->pos = 0;
+	insert_text(e, text, len, 0);
+}
+
+static void
+edit_external(ltk_entry *entry, ltk_key_event *event) {
+	(void)event;
+	ltk_config *config = ltk_config_get();
+	/* FIXME: allow arguments to key mappings - this would allow to have different key mappings
+	   for different editors instead of just one command */
+	if (!config->general.line_editor) {
+		ltk_warn("Unable to run external editing command: line editor not configured\n");
+	} else {
+		/* FIXME: somehow show that there was an error if this returns 1? */
+		/* FIXME: change interface to not require length of cmd */
+		ltk_window_call_cmd(entry->widget.window, &entry->widget, config->general.line_editor, strlen(config->general.line_editor), entry->text, entry->len);
+	}
+}
+
 static int
 ltk_entry_key_press(ltk_widget *self, ltk_key_event *event) {
 	ltk_entry *entry = (ltk_entry *)self;
@@ -590,7 +618,7 @@ ltk_entry_key_press(ltk_widget *self, ltk_key_event *event) {
 		/* FIXME: properly handle everything */
 		if (event->text[0] == '\n' || event->text[0] == '\r' || event->text[0] == 0x1b)
 			return 0;
-		insert_text(entry, event->text, strlen(event->text));
+		insert_text(entry, event->text, strlen(event->text), 1);
 		return 1;
 	}
 	return 0;
@@ -751,6 +779,7 @@ ltk_entry_destroy(ltk_widget *self, int shallow) {
 		ltk_warn("Tried to destroy NULL entry.\n");
 		return;
 	}
+	ltk_free(entry->text);
 	ltk_surface_cache_release_key(entry->key);
 	ltk_text_line_destroy(entry->tl);
 	ltk_free(entry);
diff --git a/src/ltk.h b/src/ltk.h
@@ -59,6 +59,14 @@ struct ltk_window {
 	ltk_widget *active_widget;
 	ltk_widget *pressed_widget;
 	void (*other_event) (struct ltk_window *, ltk_event *event);
+
+	/* PID of external command called e.g. by text widget to edit text.
+	   ON exit, cmd_caller->vtable->cmd_return is called with the text
+	   the external command wrote to a file. */
+	int cmd_pid;
+	char *cmd_tmpfile;
+	char *cmd_caller;
+
 	ltk_rect rect;
 	ltk_window_theme *theme;
 	ltk_rect dirty_rect;
@@ -83,6 +91,7 @@ struct ltk_window_theme {
 	ltk_color bg;
 };
 
+int ltk_window_call_cmd(ltk_window *window, ltk_widget *caller, const char *cmd, size_t cmdlen, const char *text, size_t textlen);
 void ltk_window_invalidate_rect(ltk_window *window, ltk_rect rect);
 void ltk_queue_event(ltk_window *window, ltk_userevent_type type, const char *id, const char *data);
 void ltk_window_set_hover_widget(ltk_window *window, ltk_widget *widget, ltk_motion_event *event);
diff --git a/src/ltkd.c b/src/ltkd.c
@@ -4,7 +4,7 @@
 /* FIXME: parsing doesn't work properly with bs? */
 /* FIXME: strip whitespace at end of lines in socket format */
 /*
- * Copyright (c) 2016, 2017, 2018, 2020, 2021, 2022 lumidify <nobody@lumidify.org>
+ * Copyright (c) 2016-2018, 2020-2023 lumidify <nobody@lumidify.org>
  *
  * Permission to use, copy, modify, and/or distribute this software for any
  * purpose with or without fee is hereby granted, provided that the above
@@ -34,6 +34,8 @@
 
 #include <sys/un.h>
 #include <sys/stat.h>
+#include <sys/wait.h>
+#include <sys/types.h>
 #include <sys/select.h>
 #include <sys/socket.h>
 
@@ -441,7 +443,34 @@ ltk_mainloop(ltk_window *window) {
 	ltk_generate_keyboard_event(window->renderdata, &event);
 	ltk_handle_event(window, &event);
 
+	int pid = -1;
+	int wstatus = 0;
 	while (running) {
+		if (window->cmd_caller && (pid = waitpid(window->cmd_pid, &wstatus, WNOHANG)) > 0) {
+			ltk_error err;
+			ltk_widget *cmd_caller = ltk_get_widget(window->cmd_caller, LTK_WIDGET_ANY, &err);
+			/* FIXME: should commands be split into read/write and block write commands during external editing? */
+			/* FIXME: what if a new widget with same id was created in meantime? */
+			if (!cmd_caller) {
+				ltk_warn("Widget '%s' disappeared while text was being edited in external program\n", window->cmd_caller);
+			} else if (cmd_caller->vtable->cmd_return) {
+				size_t file_len = 0;
+				char *errstr = NULL;
+				char *contents = ltk_read_file(window->cmd_tmpfile, &file_len, &errstr);
+				if (!contents) {
+					ltk_warn("Unable to read file '%s' written by external command: %s\n", window->cmd_tmpfile, errstr);
+				} else {
+					cmd_caller->vtable->cmd_return(cmd_caller, contents, file_len);
+					ltk_free(contents);
+				}
+			}
+			ltk_free(window->cmd_caller);
+			window->cmd_caller = NULL;
+			window->cmd_pid = -1;
+			unlink(window->cmd_tmpfile);
+			ltk_free(window->cmd_tmpfile);
+			window->cmd_tmpfile = NULL;
+		}
 		rfds = sock_state.rallfds;
 		wfds = sock_state.wallfds;
 		/* separate these because the writing fds are usually
@@ -1030,6 +1059,40 @@ handle_keyrelease_binding(const char *widget_name, size_t wlen, const char *name
 	return 1;
 }
 
+int
+ltk_window_call_cmd(ltk_window *window, ltk_widget *caller, const char *cmd, size_t cmdlen, const char *text, size_t textlen) {
+	if (window->cmd_caller) {
+		/* FIXME: allow multiple programs? */
+		ltk_warn("External program to edit text is already being run\n");
+		return 1;
+	}
+	/* FIXME: support environment variable $TMPDIR */
+	ltk_free(window->cmd_tmpfile);
+	window->cmd_tmpfile = ltk_strdup("/tmp/ltk.XXXXXX");
+	int fd = mkstemp(window->cmd_tmpfile);
+	if (fd == -1) {
+		ltk_warn("Unable to create temporary file while trying to run command '%.*s'\n", (int)cmdlen, cmd);
+		return 1;
+	}
+	close(fd);
+	/* FIXME: give file descriptor directly to modified version of ltk_write_file */
+	char *errstr = NULL;
+	if (ltk_write_file(window->cmd_tmpfile, text, textlen, &errstr)) {
+		ltk_warn("Unable to write to file '%s' while trying to run command '%.*s': %s\n", window->cmd_tmpfile, (int)cmdlen, cmd, errstr);
+		unlink(window->cmd_tmpfile);
+		return 1;
+	}
+	int pid = -1;
+	if ((pid = ltk_parse_run_cmd(cmd, cmdlen, window->cmd_tmpfile)) <= 0) {
+		ltk_warn("Unable to run command '%.*s'\n", (int)cmdlen, cmd);
+		unlink(window->cmd_tmpfile);
+		return 1;
+	}
+	window->cmd_pid = pid;
+	window->cmd_caller = ltk_strdup(caller->id);
+	return 0;
+}
+
 static ltk_window *
 ltk_create_window(const char *title, int x, int y, unsigned int w, unsigned int h) {
 	char *theme_path;
@@ -1070,6 +1133,10 @@ ltk_create_window(const char *title, int x, int y, unsigned int w, unsigned int 
 	window->active_widget = NULL;
 	window->pressed_widget = NULL;
 
+	window->cmd_pid = -1;
+	window->cmd_tmpfile = NULL;
+	window->cmd_caller = NULL;
+
 	window->surface_cache = ltk_surface_cache_create(window->renderdata);
 
 	window->other_event = <k_window_other_event;
@@ -1092,6 +1159,7 @@ ltk_create_window(const char *title, int x, int y, unsigned int w, unsigned int 
 
 static void
 ltk_destroy_window(ltk_window *window) {
+	ltk_free(window->cmd_tmpfile);
 	ltk_clipboard_destroy(window->clipboard);
 	ltk_text_context_destroy(window->text_context);
 	if (window->popups)
diff --git a/src/text_pango.c b/src/text_pango.c
@@ -166,6 +166,8 @@ ltk_text_line_add_attr_bg(ltk_text_line *tl, size_t start, size_t end, ltk_color
 	PangoAttribute *attr = pango_attr_background_new(c.red, c.green, c.blue);
 	attr->start_index = start;
 	attr->end_index = end;
+	/* FIXME: this is sketchy - if add_attr_bg/fg is called multiple times,
+	   pango_layout_set_attributes will probably ref the same AttrList multiple times */
 	pango_attr_list_insert(tl->attrs, attr);
 	pango_layout_set_attributes(tl->layout, tl->attrs);
 }
@@ -355,7 +357,7 @@ ltk_text_line_move_cursor_visually(ltk_text_line *tl, size_t pos, int movement, 
 void
 ltk_text_line_destroy(ltk_text_line *tl) {
 	if (tl->attrs)
-		g_object_unref(tl->attrs);
+		pango_attr_list_unref(tl->attrs);
 	g_object_unref(tl->layout);
 	ltk_free(tl->text);
 	ltk_free(tl);
diff --git a/src/text_stb.c b/src/text_stb.c
@@ -331,9 +331,10 @@ static ltk_font *
 ltk_create_font(char *path, uint16_t id, int index) {
 	unsigned long len;
 	ltk_font *font = ltk_malloc(sizeof(ltk_font));
-	char *contents = ltk_read_file(path, &len);
+	char *errstr = NULL;
+	char *contents = ltk_read_file(path, &len, &errstr);
 	if (!contents)
-		ltk_fatal_errno("Unable to read font file %s\n", path);
+		ltk_fatal_errno("Unable to read font file %s: %s\n", path, errstr);
 	int offset = stbtt_GetFontOffsetForIndex((unsigned char *)contents, index);
 	font->info.data = NULL;
 	if (!stbtt_InitFont(&font->info, (unsigned char *)contents, offset))
diff --git a/src/theme.c b/src/theme.c
@@ -46,6 +46,7 @@ ltk_theme_handle_value(ltk_window *window, char *debug_name, const char *prop, c
 		}
 		break;
 	case THEME_STRING:
+		/* FIXME: check if already set? */
 		*(entry->ptr.str) = ltk_strdup(value);
 		entry->initialized = 1;
 		break;
@@ -102,6 +103,8 @@ int
 ltk_theme_fill_defaults(ltk_window *window, char *debug_name, ltk_theme_parseinfo *parseinfo, size_t len) {
 	for (size_t i = 0; i < len; i++) {
 		ltk_theme_parseinfo *e = &parseinfo[i];
+		if (e->initialized)
+			continue;
 		switch (e->type) {
 		case THEME_INT:
 			*(e->ptr.i) = e->defaultval.i;
@@ -143,7 +146,7 @@ ltk_theme_uninitialize(ltk_window *window, ltk_theme_parseinfo *parseinfo, size_
 			continue;
 		switch (e->type) {
 		case THEME_STRING:
-			free(*(e->ptr.str));
+			ltk_free(*(e->ptr.str));
 			e->initialized = 0;
 			break;
 		case THEME_COLOR:
diff --git a/src/txtbuf.c b/src/txtbuf.c
@@ -17,7 +17,7 @@ txtbuf_new(void) {
 }
 
 txtbuf *
-txtbuf_new_from_char(char *str) {
+txtbuf_new_from_char(const char *str) {
 	txtbuf *buf = ltk_malloc(sizeof(txtbuf));
 	buf->text = ltk_strdup(str);
 	buf->len = strlen(str);
@@ -26,7 +26,7 @@ txtbuf_new_from_char(char *str) {
 }
 
 txtbuf *
-txtbuf_new_from_char_len(char *str, size_t len) {
+txtbuf_new_from_char_len(const char *str, size_t len) {
 	txtbuf *buf = ltk_malloc(sizeof(txtbuf));
 	buf->text = ltk_strndup(str, len);
 	buf->len = len;
@@ -35,7 +35,7 @@ txtbuf_new_from_char_len(char *str, size_t len) {
 }
 
 void
-txtbuf_fmt(txtbuf *buf, char *fmt, ...) {
+txtbuf_fmt(txtbuf *buf, const char *fmt, ...) {
 	va_list args;
 	va_start(args, fmt);
 	int len = vsnprintf(buf->text, buf->cap, fmt, args);
@@ -52,12 +52,12 @@ txtbuf_fmt(txtbuf *buf, char *fmt, ...) {
 }
 
 void
-txtbuf_set_text(txtbuf *buf, char *text) {
+txtbuf_set_text(txtbuf *buf, const char *text) {
 	txtbuf_set_textn(buf, text, strlen(text));
 }
 
 void
-txtbuf_set_textn(txtbuf *buf, char *text, size_t len) {
+txtbuf_set_textn(txtbuf *buf, const char *text, size_t len) {
 	txtbuf_resize(buf, len);
 	buf->len = len;
 	memmove(buf->text, text, len);
@@ -65,7 +65,7 @@ txtbuf_set_textn(txtbuf *buf, char *text, size_t len) {
 }
 
 void
-txtbuf_append(txtbuf *buf, char *text) {
+txtbuf_append(txtbuf *buf, const char *text) {
 	txtbuf_appendn(buf, text, strlen(text));
 }
 
@@ -73,7 +73,7 @@ txtbuf_append(txtbuf *buf, char *text) {
    space so a buffer that will be filled up anyways doesn't have to be
    constantly resized */
 void
-txtbuf_appendn(txtbuf *buf, char *text, size_t len) {
+txtbuf_appendn(txtbuf *buf, const char *text, size_t len) {
 	/* FIXME: overflow protection here and everywhere else */
 	txtbuf_resize(buf, buf->len + len);
 	memmove(buf->text + buf->len, text, len);
@@ -116,6 +116,11 @@ txtbuf_dup(txtbuf *src) {
 	return dst;
 }
 
+char *
+txtbuf_get_textcopy(txtbuf *buf) {
+	return buf->text ? ltk_strndup(buf->text, buf->len) : ltk_strdup("");
+}
+
 /* FIXME: proper "normalize" function to add nul-termination if needed */
 int
 txtbuf_cmp(txtbuf *buf1, txtbuf *buf2) {
diff --git a/src/txtbuf.h b/src/txtbuf.h
@@ -24,39 +24,39 @@ txtbuf *txtbuf_new(void);
  * Create a new txtbuf, initializing it with the nul-terminated
  * string 'str'. The input string is copied.
  */
-txtbuf *txtbuf_new_from_char(char *str);
+txtbuf *txtbuf_new_from_char(const char *str);
 
 /*
  * Create a new txtbuf, initializing it with the string 'str'
  * of length 'len'. The input string is copied.
  */
-txtbuf *txtbuf_new_from_char_len(char *str, size_t len);
+txtbuf *txtbuf_new_from_char_len(const char *str, size_t len);
 
 /*
  * Replace the stored text in 'buf' with the text generated by
  * 'snprintf' when called with the given format string and args.
  */
-void txtbuf_fmt(txtbuf *buf, char *fmt, ...);
+void txtbuf_fmt(txtbuf *buf, const char *fmt, ...);
 
 /*
  * Replace the stored text in 'buf' with 'text'.
  */
-void txtbuf_set_text(txtbuf *buf, char *text);
+void txtbuf_set_text(txtbuf *buf, const char *text);
 
 /*
  * Same as txtbuf_set_text, but with explicit length for 'text'.
  */
-void txtbuf_set_textn(txtbuf *buf, char *text, size_t len);
+void txtbuf_set_textn(txtbuf *buf, const char *text, size_t len);
 
 /*
  * Append 'text' to the text stored in 'buf'.
  */
-void txtbuf_append(txtbuf *buf, char *text);
+void txtbuf_append(txtbuf *buf, const char *text);
 
 /*
  * Same as txtbuf_append, but with explicit length for 'text'.
  */
-void txtbuf_appendn(txtbuf *buf, char *text, size_t len);
+void txtbuf_appendn(txtbuf *buf, const char *text, size_t len);
 
 /*
  * Compare the text of two txtbuf's like 'strcmp'.
@@ -91,6 +91,12 @@ void txtbuf_copy(txtbuf *dst, txtbuf *src);
 txtbuf *txtbuf_dup(txtbuf *src);
 
 /*
+ * Get copy of text stored in 'buf'.
+ * The returned text belongs to the caller and needs to be freed.
+ */
+char *txtbuf_get_textcopy(txtbuf *buf);
+
+/*
  * Clear the text, but do not reduce the internal capacity
  * (for efficiency if it will be filled up again anyways).
  */
diff --git a/src/util.c b/src/util.c
@@ -1,5 +1,5 @@
 /*
- * Copyright (c) 2021 lumidify <nobody@lumidify.org>
+ * Copyright (c) 2021, 2023 lumidify <nobody@lumidify.org>
  *
  * Permission to use, copy, modify, and/or distribute this software for any
  * purpose with or without fee is hereby granted, provided that the above
@@ -15,6 +15,7 @@
  */
 
 #include <pwd.h>
+#include <ctype.h>
 #include <errno.h>
 #include <stdio.h>
 #include <stdlib.h>
@@ -24,25 +25,250 @@
 #include <sys/stat.h>
 
 #include "util.h"
+#include "array.h"
 #include "memory.h"
+#include "txtbuf.h"
 
 /* FIXME: Should these functions really fail on memory error? */
-/* FIXME: *len should be long, not unsigned long! */
+
 char *
-ltk_read_file(const char *path, unsigned long *len) {
-	FILE *f;
+ltk_read_file(const char *filename, size_t *len_ret, char **errstr_ret) {
+	long len;
 	char *file_contents;
-	f = fopen(path, "rb");
-	if (!f) return NULL;
-	fseek(f, 0, SEEK_END);
-	*len = ftell(f);
-	fseek(f, 0, SEEK_SET);
-	file_contents = ltk_malloc(*len + 1);
-	fread(file_contents, 1, *len, f);
-	file_contents[*len] = '\0';
-	fclose(f);
+	FILE *file;
 
+	/* FIXME: https://wiki.sei.cmu.edu/confluence/display/c/FIO19-C.+Do+not+use+fseek()+and+ftell()+to+compute+the+size+of+a+regular+file */
+	file = fopen(filename, "r");
+	if (!file) goto error;
+	if (fseek(file, 0, SEEK_END)) goto errorclose;
+	len = ftell(file);
+	if (len < 0) goto errorclose;
+	if (fseek(file, 0, SEEK_SET)) goto errorclose;
+	file_contents = ltk_malloc((size_t)len + 1);
+	clearerr(file);
+	fread(file_contents, 1, (size_t)len, file);
+	if (ferror(file)) goto errorclose;
+	file_contents[len] = '\0';
+	if (fclose(file)) goto error;
+	*len_ret = (size_t)len;
 	return file_contents;
+error:
+	if (errstr_ret)
+		*errstr_ret = strerror(errno);
+	return NULL;
+errorclose:
+	if (errstr_ret)
+		*errstr_ret = strerror(errno);
+	fclose(file);
+	return NULL;
+}
+
+/* FIXME: not sure if errno actually is set usefully after all these functions */
+int
+ltk_write_file(const char *path, const char *data, size_t len, char **errstr_ret) {
+	FILE *file = fopen(path, "w");
+	if (!file) goto error;
+	clearerr(file);
+	if (fwrite(data, 1, len, file) < len) goto errorclose;
+	if (fclose(file)) goto error;
+	return 0;
+error:
+	if (errstr_ret)
+		*errstr_ret = strerror(errno);
+	return 1;
+errorclose:
+	if (errstr_ret)
+		*errstr_ret = strerror(errno);
+	fclose(file);
+	return 1;
+}
+
+/* FIXME: maybe have a few standard array types defined somewhere else */
+LTK_ARRAY_INIT_DECL_STATIC(cmd, char *)
+LTK_ARRAY_INIT_IMPL_STATIC(cmd, char *)
+
+static void
+free_helper(char *ptr) {
+	ltk_free(ptr);
+}
+
+/* FIXME: this is really ugly */
+/* FIXME: parse command only once in beginning instead of each time it is run? */
+/* FIXME: this handles double-quote, but the config parser already uses that, so
+   it's kind of weird because it's parsed twice (also backslashes are parsed twice). */
+int
+ltk_parse_run_cmd(const char *cmdtext, size_t len, const char *filename) {
+	int bs = 0;
+	int in_sqstr = 0;
+	int in_dqstr = 0;
+	int in_ws = 1;
+	char c;
+	size_t cur_start = 0;
+	int offset = 0;
+	txtbuf *cur_arg = txtbuf_new();
+	ltk_array(cmd) *cmd = ltk_array_create(cmd, 4);
+	char *cmdcopy = ltk_strndup(cmdtext, len);
+	for (size_t i = 0; i < len; i++) {
+		c = cmdcopy[i];
+		if (c == '\\') {
+			if (bs) {
+				offset++;
+				bs = 0;
+			} else {
+				bs = 1;
+			}
+		} else if (isspace(c)) {
+			if (!in_sqstr && !in_dqstr) {
+				if (bs) {
+					if (in_ws) {
+						in_ws = 0;
+						cur_start = i;
+						offset = 0;
+					} else {
+						offset++;
+					}
+					bs = 0;
+				} else if (!in_ws) {
+					/* FIXME: shouldn't this be < instead of <=? */
+					if (cur_start <= i - offset)
+						txtbuf_appendn(cur_arg, cmdcopy + cur_start, i - cur_start - offset);
+					/* FIXME: cmd is named horribly */
+					ltk_array_append(cmd, cmd, txtbuf_get_textcopy(cur_arg));
+					txtbuf_clear(cur_arg);
+					in_ws = 1;
+					offset = 0;
+				}
+			/* FIXME: parsing weird here - bs just ignored */
+			} else if (bs) {
+				bs = 0;
+			}
+		} else if (c == '%') {
+			if (bs) {
+				if (in_ws) {
+					cur_start = i;
+					offset = 0;
+				} else {
+					offset++;
+				}
+				bs = 0;
+			} else if (!in_sqstr && filename && i < len - 1 && cmdcopy[i + 1] == 'f') {
+				if (!in_ws && cur_start < i - offset)
+					txtbuf_appendn(cur_arg, cmdcopy + cur_start, i - cur_start - offset);
+				txtbuf_append(cur_arg, filename);
+				i++;
+				cur_start = i + 1;
+				offset = 0;
+			} else if (in_ws) {
+				cur_start = i;
+				offset = 0;
+			}
+			in_ws = 0;
+		} else if (c == '"') {
+			if (in_sqstr) {
+				bs = 0;
+			} else if (bs) {
+				if (in_ws) {
+					cur_start = i;
+					offset = 0;
+				} else {
+					offset++;
+				}
+				bs = 0;
+			} else if (in_dqstr) {
+				offset++;
+				in_dqstr = 0;
+				continue;
+			} else {
+				in_dqstr = 1;
+				if (in_ws) {
+					cur_start = i + 1;
+					offset = 0;
+				} else {
+					offset++;
+					continue;
+				}
+			}
+			in_ws = 0;
+		} else if (c == '\'') {
+			if (in_dqstr) {
+				bs = 0;
+			} else if (bs) {
+				if (in_ws) {
+					cur_start = i;
+					offset = 0;
+				} else {
+					offset++;
+				}
+				bs = 0;
+			} else if (in_sqstr) {
+				offset++;
+				in_sqstr = 0;
+				continue;
+			} else {
+				in_sqstr = 1;
+				if (in_ws) {
+					cur_start = i + 1;
+					offset = 0;
+				} else {
+					offset++;
+					continue;
+				}
+			}
+			in_ws = 0;
+		} else if (bs) {
+			if (!in_sqstr && !in_dqstr) {
+				if (in_ws) {
+					cur_start = i;
+					offset = 0;
+				} else {
+					offset++;
+				}
+			}
+			bs = 0;
+			in_ws = 0;
+		} else {
+			if (in_ws) {
+				cur_start = i;
+				offset = 0;
+			}
+			in_ws = 0;
+		}
+		cmdcopy[i - offset] = cmdcopy[i];
+	}
+	if (in_sqstr || in_dqstr) {
+		ltk_warn("Unterminated string in command\n");
+		goto error;
+	}
+	if (!in_ws) {
+		if (cur_start <= len - offset)
+			txtbuf_appendn(cur_arg, cmdcopy + cur_start, len - cur_start - offset);
+		ltk_array_append(cmd, cmd, txtbuf_get_textcopy(cur_arg));
+	}
+	if (cmd->len == 0) {
+		ltk_warn("Empty command\n");
+		goto error;
+	}
+	ltk_array_append(cmd, cmd, NULL); /* necessary for execvp */
+	int fret = -1;
+	if ((fret = fork()) < 0) {
+		ltk_warn("Unable to fork\n");
+		goto error;
+	} else if (fret == 0) {
+		if (execvp(cmd->buf[0], cmd->buf) == -1) {
+			/* FIXME: what to do on error here? */
+			exit(1);
+		}
+	} else {
+		ltk_free(cmdcopy);
+		txtbuf_destroy(cur_arg);
+		ltk_array_destroy_deep(cmd, cmd, &free_helper);
+		return fret;
+	}
+error:
+	ltk_free(cmdcopy);
+	txtbuf_destroy(cur_arg);
+	ltk_array_destroy_deep(cmd, cmd, &free_helper);
+	return -1;
 }
 
 /* If `needed` is larger than `*alloc_size`, resize `*str` to
diff --git a/src/util.h b/src/util.h
@@ -1,5 +1,5 @@
 /*
- * Copyright (c) 2021 lumidify <nobody@lumidify.org>
+ * Copyright (c) 2021, 2023 lumidify <nobody@lumidify.org>
  *
  * Permission to use, copy, modify, and/or distribute this software for any
  * purpose with or without fee is hereby granted, provided that the above
@@ -25,7 +25,9 @@ long long ltk_strtonum(
     long long maxval, const char **errstrp
 );
 
-char *ltk_read_file(const char *path, unsigned long *len);
+char *ltk_read_file(const char *filename, size_t *len_ret, char **errstr_ret);
+int ltk_write_file(const char *path, const char *data, size_t len, char **errstr_ret);
+int ltk_parse_run_cmd(const char *cmdtext, size_t len, const char *filename);
 void ltk_grow_string(char **str, int *alloc_size, int needed);
 char *ltk_setup_directory(void);
 char *ltk_strcat_useful(const char *str1, const char *str2);
diff --git a/src/widget.h b/src/widget.h
@@ -128,6 +128,7 @@ struct ltk_widget_vtable {
 	int (*mouse_enter)(struct ltk_widget *, ltk_motion_event *);
 	int (*press)(struct ltk_widget *);
 	int (*release)(struct ltk_widget *);
+	void (*cmd_return)(struct ltk_widget *self, char *text, size_t len);
 
 	void (*resize)(struct ltk_widget *);
 	void (*hide)(struct ltk_widget *);