commit 5a0a7594e75569bc17cc48a0ec0e5975ec51d54d
parent d0faf9b6f4464428cac5de55e56cd1a0a92b45ef
Author: lumidify <nobody@lumidify.org>
Date:   Mon,  6 May 2024 23:33:09 +0200
Add basic combobox; improve external command handling
The combobox is very hacky and doesn't behave properly
in all circumstances.
Diffstat:
23 files changed, 1214 insertions(+), 318 deletions(-)
diff --git a/Makefile b/Makefile
@@ -59,6 +59,7 @@ OBJ_LTK = \
 	src/ltk/button.o \
 	src/ltk/checkbutton.o \
 	src/ltk/radiobutton.o \
+	src/ltk/combobox.o \
 	src/ltk/graphics_xlib.o \
 	src/ltk/surface_cache.o \
 	src/ltk/event_xlib.o \
@@ -98,6 +99,7 @@ HDR_LTK = \
 	src/ltk/button.h \
 	src/ltk/checkbutton.h \
 	src/ltk/radiobutton.h \
+	src/ltk/combobox.h \
 	src/ltk/color.h \
 	src/ltk/label.h \
 	src/ltk/rect.h \
diff --git a/config.example/ltk.cfg b/config.example/ltk.cfg
@@ -1,7 +1,28 @@
 [general]
 explicit-focus = true
 all-activatable = true
+
+# FIXME: document weird parsing for commands (quotes, backslashes)
+# FIXME: actually test all of these options...
+# Options for commands:
+# %f: combined input/output file
+# %i: input file
+# %o: output file
+# If %i is specified but %o is not specified,
+# output is read from stdout (and vice versa).
+# If %f is specified, %i and %o are not allowed.
+# If no files are specified, input is written to
+# stdin and output is read from stdout.
+
+# line-editor is given the contents of a line entry
+# and must return the edited text. Newlines are
+# stripped from the returning text.
 line-editor = "st -e vi %f"
+# option-chooser is given several options, one on
+# each line, and must return one of them. If the
+# result contains newlines, only the part before
+# the first newline is used.
+option-chooser = dmenu
 mixed-dpi = true
 fixed-dpi = 96
 dpi-scale = 1.0
@@ -45,7 +66,11 @@ fg-disabled = "#292929"
 # bind edit-text-external ...
 # bind edit-line-external ...
 bind-keypress move-next sym tab
-bind-keypress move-prev sym tab mods shift
+# FIXME: how should this be handled? it's a bit weird because
+# shift+tab causes left tab + shift under X11, but that
+# requires shift to be given here, so maybe that should be
+# abstracted away in the backend?
+bind-keypress move-prev sym left-tab mods shift
 bind-keypress move-next text n
 bind-keypress move-prev text p
 bind-keypress move-left sym left
@@ -81,6 +106,9 @@ 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
 
+[key-binding:combobox]
+bind-keypress choose-external text E mods ctrl
+
 # default mapping (just to silence warnings)
 [key-mapping]
 language = "English (US)"
diff --git a/examples/ltk/test.c b/examples/ltk/test.c
@@ -11,6 +11,7 @@
 #include <ltk/box.h>
 #include <ltk/checkbutton.h>
 #include <ltk/radiobutton.h>
+#include <ltk/combobox.h>
 
 int
 quit(ltk_widget *self, ltk_callback_arglist args, ltk_callback_arg data) {
@@ -93,7 +94,14 @@ main(int argc, char *argv[]) {
 	ltk_box_add(box, LTK_CAST_WIDGET(rbtn1), LTK_STICKY_LEFT);
 	ltk_box_add(box, LTK_CAST_WIDGET(rbtn2), LTK_STICKY_LEFT);
 
-	ltk_grid_add(grid, LTK_CAST_WIDGET(menu), 0, 0, 1, 2, LTK_STICKY_LEFT|LTK_STICKY_RIGHT);
+	ltk_combobox *combo = ltk_combobox_create(window);
+	ltk_combobox_add_option(combo, "Option 1");
+	ltk_combobox_add_option(combo, "Option 2");
+	ltk_combobox_add_option(combo, "Option 3");
+	ltk_combobox_add_option(combo, "Option 4");
+
+	ltk_grid_add(grid, LTK_CAST_WIDGET(menu), 0, 0, 1, 1, LTK_STICKY_LEFT|LTK_STICKY_RIGHT);
+	ltk_grid_add(grid, LTK_CAST_WIDGET(combo), 0, 1, 1, 1, LTK_STICKY_LEFT);
 	ltk_grid_add(grid, LTK_CAST_WIDGET(button), 1, 0, 1, 1, LTK_STICKY_LEFT);
 	ltk_grid_add(grid, LTK_CAST_WIDGET(button1), 1, 1, 1, 1, LTK_STICKY_RIGHT);
 	ltk_grid_add(grid, LTK_CAST_WIDGET(label), 2, 0, 1, 1, LTK_STICKY_RIGHT);
@@ -102,7 +110,7 @@ main(int argc, char *argv[]) {
 	ltk_grid_add(grid, LTK_CAST_WIDGET(box), 4, 0, 1, 2, LTK_STICKY_LEFT|LTK_STICKY_RIGHT|LTK_STICKY_TOP|LTK_STICKY_BOTTOM);
 	ltk_window_set_root_widget(window, LTK_CAST_WIDGET(grid));
 	ltk_widget_register_signal_handler(LTK_CAST_WIDGET(button), LTK_BUTTON_SIGNAL_PRESSED, &quit, LTK_ARG_VOID);
-	ltk_widget_register_signal_handler(LTK_CAST_WIDGET(e4), LTK_BUTTON_SIGNAL_PRESSED, &quit, LTK_ARG_VOID);
+	ltk_widget_register_signal_handler(LTK_CAST_WIDGET(e4), LTK_MENUENTRY_SIGNAL_PRESSED, &quit, LTK_ARG_VOID);
 	ltk_widget_register_signal_handler(LTK_CAST_WIDGET(button1), LTK_BUTTON_SIGNAL_PRESSED, &printstuff, LTK_MAKE_ARG_INT(5));
 	ltk_widget_register_signal_handler(LTK_CAST_WIDGET(window), LTK_WINDOW_SIGNAL_CLOSE, &quit, LTK_ARG_VOID);
 	ltk_widget_register_signal_handler(LTK_CAST_WIDGET(button1), LTK_WIDGET_SIGNAL_CHANGE_STATE, &printstate, LTK_ARG_VOID);
diff --git a/src/ltk/combobox.c b/src/ltk/combobox.c
@@ -0,0 +1,596 @@
+/*
+ * Copyright (c) 2024 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
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+#include <stdio.h>
+
+#include "config.h"
+#include "combobox.h"
+#include "color.h"
+#include "graphics.h"
+#include "ltk.h"
+#include "memory.h"
+#include "rect.h"
+#include "text.h"
+#include "util.h"
+#include "widget.h"
+#include "menu.h"
+#include "widget_internal.h"
+
+#define MAX_COMBOBOX_BORDER_WIDTH 10000
+#define MAX_COMBOBOX_PADDING 50000
+#define MAX_COMBOBOX_ARROW_SIZE 50000
+
+static void ltk_combobox_draw(ltk_widget *self, ltk_surface *draw_surf, int x, int y, ltk_rect clip);
+static int ltk_combobox_release(ltk_widget *self);
+static void ltk_combobox_destroy(ltk_widget *self, int shallow);
+static void ltk_combobox_recalc_ideal_size(ltk_widget *self);
+static int ltk_combobox_remove_child(ltk_widget *self, ltk_widget *widget);
+static ltk_widget *ltk_combobox_get_child(ltk_widget *self);
+static ltk_widget *ltk_combobox_nearest_child(ltk_widget *self, ltk_rect rect);
+static int ltk_combobox_key_press(ltk_widget *self, ltk_key_event *event);
+static int choose_external(ltk_widget *self, ltk_key_event *event);
+static void ltk_combobox_cmd_return(ltk_widget *self, char *text, size_t len);
+
+static struct ltk_widget_vtable vtable = {
+	.key_press = <k_combobox_key_press,
+	.key_release = NULL,
+	.mouse_press = NULL,
+	.mouse_release = NULL,
+	.release = <k_combobox_release,
+	.motion_notify = NULL,
+	.mouse_leave = NULL,
+	.mouse_enter = NULL,
+	.change_state = NULL,
+	.get_child_at_pos = NULL,
+	.cmd_return = <k_combobox_cmd_return,
+	.resize = NULL,
+	.hide = NULL,
+	.draw = <k_combobox_draw,
+	.destroy = <k_combobox_destroy,
+	.child_size_change = NULL,
+	.remove_child = <k_combobox_remove_child,
+	.first_child = <k_combobox_get_child,
+	.last_child = <k_combobox_get_child,
+        .nearest_child = <k_combobox_nearest_child,
+	.recalc_ideal_size = <k_combobox_recalc_ideal_size,
+	.type = LTK_WIDGET_COMBOBOX,
+	.flags = LTK_NEEDS_REDRAW | LTK_ACTIVATABLE_ALWAYS,
+	.invalid_signal = LTK_COMBOBOX_SIGNAL_INVALID,
+};
+
+static struct {
+	ltk_color *border;
+	ltk_color *border_pressed;
+	ltk_color *border_hover;
+	ltk_color *border_active;
+	ltk_color *border_disabled;
+	ltk_color *fill;
+	ltk_color *fill_pressed;
+	ltk_color *fill_hover;
+	ltk_color *fill_active;
+	ltk_color *fill_disabled;
+	ltk_color *text;
+	ltk_color *text_pressed;
+	ltk_color *text_hover;
+	ltk_color *text_active;
+	ltk_color *text_disabled;
+
+	char *font;
+	ltk_size arrow_size;
+	ltk_size border_width;
+	ltk_size pad;
+	ltk_size font_size;
+	int compress_borders;
+} theme;
+
+static ltk_theme_parseinfo parseinfo[] = {
+	{"border", THEME_COLOR, {.color = &theme.border}, {.color = "#339999"}, 0, 0, 0},
+	{"border-hover", THEME_COLOR, {.color = &theme.border_hover}, {.color = "#FFFFFF"}, 0, 0, 0},
+	{"border-active", THEME_COLOR, {.color = &theme.border_active}, {.color = "#FFFFFF"}, 0, 0, 0},
+	{"border-disabled", THEME_COLOR, {.color = &theme.border_disabled}, {.color = "#FFFFFF"}, 0, 0, 0},
+	{"border-pressed", THEME_COLOR, {.color = &theme.border_pressed}, {.color = "#FFFFFF"}, 0, 0, 0},
+	{"fill", THEME_COLOR, {.color = &theme.fill}, {.color = "#113355"}, 0, 0, 0},
+	{"fill-hover", THEME_COLOR, {.color = &theme.fill_hover}, {.color = "#738194"}, 0, 0, 0},
+	{"fill-active", THEME_COLOR, {.color = &theme.fill_active}, {.color = "#113355"}, 0, 0, 0},
+	{"fill-disabled", THEME_COLOR, {.color = &theme.fill_disabled}, {.color = "#292929"}, 0, 0, 0},
+	{"fill-pressed", THEME_COLOR, {.color = &theme.fill_pressed}, {.color = "#113355"}, 0, 0, 0},
+	{"text", THEME_COLOR, {.color = &theme.text}, {.color = "#FFFFFF"}, 0, 0, 0},
+	{"text-hover", THEME_COLOR, {.color = &theme.text_hover}, {.color = "#FFFFFF"}, 0, 0, 0},
+	{"text-active", THEME_COLOR, {.color = &theme.text_active}, {.color = "#FFFFFF"}, 0, 0, 0},
+	{"text-disabled", THEME_COLOR, {.color = &theme.text_disabled}, {.color = "#FFFFFF"}, 0, 0, 0},
+	{"text-pressed", THEME_COLOR, {.color = &theme.text_pressed}, {.color = "#FFFFFF"}, 0, 0, 0},
+
+	{"arrow-size", THEME_SIZE, {.size = &theme.arrow_size}, {.size = {.val = 250, .unit = LTK_UNIT_MM}}, 0, MAX_COMBOBOX_ARROW_SIZE, 0},
+	{"border-width", THEME_SIZE, {.size = &theme.border_width}, {.size = {.val = 50, .unit = LTK_UNIT_MM}}, 0, MAX_COMBOBOX_BORDER_WIDTH, 0},
+	{"pad", THEME_SIZE, {.size = &theme.pad}, {.size = {.val = 100, .unit = LTK_UNIT_MM}}, 0, MAX_COMBOBOX_PADDING, 0},
+	{"compress-borders", THEME_BOOL, {.b = &theme.compress_borders}, {.b = 0}, 0, MAX_COMBOBOX_PADDING, 0},
+	{"font", THEME_STRING, {.str = &theme.font}, {.str = "Monospace"}, 0, 0, 0},
+	{"font-size", THEME_SIZE, {.size = &theme.font_size}, {.size = {.val = 1200, .unit = LTK_UNIT_PT}}, 0, 20000, 0},
+};
+
+void
+ltk_combobox_get_theme_parseinfo(ltk_theme_parseinfo **p, size_t *len) {
+	*p = parseinfo;
+	*len = LENGTH(parseinfo);
+}
+
+static ltk_keybinding_cb cb_map[] = {
+	{"choose-external", &choose_external},
+};
+
+static ltk_array(keypress) *keypresses = NULL;
+
+void
+ltk_combobox_get_keybinding_parseinfo(
+	ltk_keybinding_cb **press_cbs_ret, size_t *press_len_ret,
+	ltk_keybinding_cb **release_cbs_ret, size_t *release_len_ret,
+	ltk_array(keypress) **presses_ret, ltk_array(keyrelease) **releases_ret
+) {
+	*press_cbs_ret = cb_map;
+	*press_len_ret = LENGTH(cb_map);
+	*release_cbs_ret = NULL;
+	*release_len_ret = 0;
+	if (!keypresses)
+		keypresses = ltk_array_create(keypress, 1);
+	*presses_ret = keypresses;
+	*releases_ret = NULL;
+}
+
+void
+ltk_combobox_cleanup(void) {
+	ltk_keypress_bindings_destroy(keypresses);
+	keypresses = NULL;
+}
+
+/* FIXME: a lot more theme settings */
+static void
+ltk_combobox_draw(ltk_widget *self, ltk_surface *draw_surf, int x, int y, ltk_rect clip) {
+	ltk_combobox *combobox = LTK_CAST_COMBOBOX(self);
+	ltk_rect lrect = self->lrect;
+	ltk_rect clip_final = ltk_rect_intersect(clip, (ltk_rect){0, 0, lrect.w, lrect.h});
+	if (clip_final.w <= 0 || clip_final.h <= 0)
+		return;
+
+	int arrow_size = ltk_size_to_pixel(theme.arrow_size, self->last_dpi);
+	int pad = ltk_size_to_pixel(theme.pad, self->last_dpi);
+	int bw = ltk_size_to_pixel(theme.border_width, self->last_dpi);
+	ltk_color *border = NULL, *fill = NULL, *text = NULL;
+	if (self->state & LTK_DISABLED) {
+		border = theme.border_disabled;
+		fill = theme.fill_disabled;
+		text = theme.text_disabled;
+	} else if (self->state & LTK_PRESSED) {
+		border = theme.border_pressed;
+		fill = theme.fill_pressed;
+		text = theme.text_pressed;
+	} else if (self->state & LTK_HOVER) {
+		border = theme.border_hover;
+		fill = theme.fill_hover;
+		text = theme.text_hover;
+	} else if (self->state & LTK_ACTIVE) {
+		border = theme.border_active;
+		fill = theme.fill_active;
+		text = theme.text_active;
+	} else {
+		border = theme.border;
+		fill = theme.fill;
+		text = theme.text;
+	}
+	ltk_rect draw_rect = {x, y, lrect.w, lrect.h};
+	ltk_rect draw_clip = {x + clip_final.x, y + clip_final.y, clip_final.w, clip_final.h};
+	ltk_surface_fill_rect(draw_surf, fill, draw_clip);
+	if (bw > 0) {
+		ltk_surface_draw_border_clipped(
+			draw_surf, border, draw_rect, bw, LTK_BORDER_ALL, draw_clip
+		);
+	}
+	int text_w, text_h;
+	ltk_text_line_get_size(combobox->tl, &text_w, &text_h);
+	int text_x = x + pad;
+	int text_y = y + (lrect.h - text_h) / 2;
+	ltk_text_line_draw_clipped(combobox->tl, draw_surf, text, text_x, text_y, draw_clip);
+
+	ltk_point arrow_points[] = {
+	    {x + lrect.w - pad - bw - arrow_size, y + lrect.h / 2 - arrow_size / 2},
+	    {x + lrect.w - pad - bw, y + lrect.h / 2 - arrow_size / 2},
+	    {x + lrect.w - pad - bw - arrow_size / 2, y + lrect.h / 2 + arrow_size / 2}
+	};
+	ltk_surface_fill_polygon_clipped(draw_surf, text, arrow_points, LENGTH(arrow_points), draw_clip);
+	self->dirty = 0;
+}
+
+/* FIXME: this is kind of ugly because it uses a lot of internal knowledge about menus */
+static void
+popup_dropdown(ltk_combobox *combobox) {
+	if (!combobox->dropdown || ltk_menu_get_num_entries(combobox->dropdown) == 0)
+		return;
+	ltk_rect combo_rect = LTK_CAST_WIDGET(combobox)->lrect;
+	ltk_point combo_global = ltk_widget_pos_to_global(LTK_CAST_WIDGET(combobox), 0, 0);
+
+	int win_w = LTK_CAST_WIDGET(combobox)->window->rect.w;
+	int win_h = LTK_CAST_WIDGET(combobox)->window->rect.h;
+	ltk_menu *dropdown = combobox->dropdown;
+	ltk_widget_recalc_ideal_size(LTK_CAST_WIDGET(dropdown));
+	int ideal_w = dropdown->widget.ideal_w;
+	int ideal_h = dropdown->widget.ideal_h;
+	int x_final = 0, y_final = 0, w_final = ideal_w, h_final = ideal_h;
+	int combo_bw = ltk_size_to_pixel(theme.border_width, LTK_CAST_WIDGET(combobox)->last_dpi);
+
+	int space_top = combo_global.y;
+	int space_bottom = win_h - (combo_global.y + combo_rect.h);
+	int y_top = combo_global.y - ideal_h;
+	int y_bottom = combo_global.y + combo_rect.h;
+	if (theme.compress_borders) {
+		y_top += combo_bw;
+		y_bottom -= combo_bw;
+	}
+	if (space_top > space_bottom) {
+		y_final = y_top;
+		if (y_final < 0) {
+			y_final = 0;
+			h_final = combo_rect.y;
+		}
+	} else {
+		y_final = y_bottom;
+		if (space_bottom < ideal_h)
+			h_final = space_bottom;
+	}
+	/* FIXME: maybe threshold so there's always at least a part of
+	   the menu contents shown (instead of maybe just a few pixels) */
+	/* pathological case where window is way too small */
+	if (h_final <= 0) {
+		y_final = 0;
+		h_final = win_h;
+	}
+	x_final = combo_global.x;
+	if (x_final + ideal_w > win_w)
+		x_final = win_w - ideal_w;
+	if (x_final < 0) {
+		x_final = 0;
+		w_final = win_w;
+	}
+
+	/* reset everything just in case */
+	dropdown->x_scroll_offset = dropdown->y_scroll_offset = 0;
+	dropdown->scroll_top_hover = dropdown->scroll_bottom_hover = 0;
+	dropdown->scroll_left_hover = dropdown->scroll_right_hover = 0;
+	dropdown->widget.lrect.x = x_final;
+	dropdown->widget.lrect.y = y_final;
+	dropdown->widget.lrect.w = w_final;
+	dropdown->widget.lrect.h = h_final;
+	dropdown->widget.crect = LTK_CAST_WIDGET(dropdown)->lrect;
+	dropdown->widget.dirty = 1;
+	dropdown->widget.hidden = 0;
+	dropdown->popup_submenus = 0;
+	dropdown->unpopup_submenus_on_hide = 1;
+	ltk_widget_resize(LTK_CAST_WIDGET(dropdown));
+	ltk_window_register_popup(LTK_CAST_WIDGET(combobox)->window, LTK_CAST_WIDGET(dropdown));
+	ltk_window_invalidate_widget_rect(LTK_CAST_WIDGET(dropdown)->window, LTK_CAST_WIDGET(dropdown));
+}
+
+static void
+unpopup_dropdown(ltk_combobox *combobox) {
+	if (combobox->dropdown && !LTK_CAST_WIDGET(combobox->dropdown)->hidden) {
+		ltk_widget_hide(LTK_CAST_WIDGET(combobox->dropdown));
+	}
+}
+
+/* FIXME: set ideal width to ideal width of submenu */
+/* FIXME: disable button when no options */
+
+static int
+ltk_combobox_release(ltk_widget *self) {
+	ltk_combobox *combo = LTK_CAST_COMBOBOX(self);
+	if (!combo->dropdown)
+		return 0;
+	if (combo->dropdown->widget.hidden)
+		popup_dropdown(combo);
+	else
+		unpopup_dropdown(combo);
+	return 1;
+}
+
+#define MAX(a, b) ((a) > (b) ? (a) : (b))
+
+static void
+recalc_ideal_size(ltk_combobox *combobox) {
+	int text_w, text_h;
+	ltk_text_line_get_size(combobox->tl, &text_w, &text_h);
+	int arrow_size = ltk_size_to_pixel(theme.arrow_size, LTK_CAST_WIDGET(combobox)->last_dpi);
+	int pad = ltk_size_to_pixel(theme.pad, LTK_CAST_WIDGET(combobox)->last_dpi);
+	combobox->widget.ideal_w = text_w + pad * 3 + arrow_size;
+	combobox->widget.ideal_h = MAX(text_h, arrow_size) + pad * 2;
+}
+
+static void
+ltk_combobox_recalc_ideal_size(ltk_widget *self) {
+	ltk_combobox *combobox = LTK_CAST_COMBOBOX(self);
+	int font_size = ltk_size_to_pixel(theme.font_size, self->last_dpi);
+	ltk_text_line_set_font_size(combobox->tl, font_size);
+	recalc_ideal_size(combobox);
+}
+
+static void
+combobox_set_active(ltk_combobox *combo, size_t idx, const char *text) {
+	combo->cur_active = idx;
+	ltk_text_line_set_const_text(combo->tl, text);
+	recalc_ideal_size(combo);
+	if (combo->widget.parent && combo->widget.parent->vtable->child_size_change) {
+		combo->widget.parent->vtable->child_size_change(combo->widget.parent, LTK_CAST_WIDGET(combo));
+	}
+	ltk_widget_emit_signal(LTK_CAST_WIDGET(combo), LTK_COMBOBOX_SIGNAL_CHANGED, LTK_EMPTY_ARGLIST);
+}
+
+static int
+handle_entry_pressed(ltk_widget *self, ltk_callback_arglist args, ltk_callback_arg data) {
+	(void)args;
+	ltk_menuentry *e = LTK_CAST_MENUENTRY(self);
+	ltk_combobox *combo = LTK_CAST_COMBOBOX(LTK_CAST_ARG_WIDGET(data));
+	if (!combo->dropdown) /* shouldn't be possible */
+		return 1;
+	size_t idx = ltk_menu_get_entry_index(combo->dropdown, e);
+	if (idx == SIZE_MAX) /* shouldn't be possible */
+		return 1;
+	combobox_set_active(combo, idx, ltk_menuentry_get_text(e));
+	return 1;
+}
+
+static void
+ltk_combobox_cmd_return(ltk_widget *self, char *text, size_t len) {
+	ltk_combobox *combo = LTK_CAST_COMBOBOX(self);
+	if (!combo->dropdown)
+		return;
+	/* need to copy since it's not nul-terminated */
+	char *textcopy = ltk_strndup(text, len);
+	char *nl = strchr(textcopy, '\n');
+	/* only take text until first newline into account */
+	if (nl)
+		*nl = '\0';
+	for (size_t i = 0; i < ltk_menu_get_num_entries(combo->dropdown); i++) {
+		if (!strcmp(textcopy, ltk_menuentry_get_text(ltk_menu_get_entry(combo->dropdown, i)))) {
+			combobox_set_active(combo, i, textcopy);
+			break;
+		}
+	}
+	ltk_free(textcopy);
+}
+
+static int
+choose_external(ltk_widget *self, ltk_key_event *event) {
+	(void)event;
+	ltk_combobox *combo = LTK_CAST_COMBOBOX(self);
+	if (!combo->dropdown || ltk_menu_get_num_entries(combo->dropdown) == 0)
+		return 0;
+	ltk_general_config *config = ltk_config_get_general();
+	/* FIXME: allow arguments to key mappings - this would allow to have different key mappings
+	   for different editors instead of just one command */
+	if (!config->option_chooser) {
+		ltk_warn("Unable to run external option choosing command: option chooser not configured.");
+	} else {
+		/* FIXME: somehow show that there was an error if this returns 1? */
+		/* FIXME: change interface to not require length of cmd */
+		txtbuf *tmpbuf = txtbuf_new();
+		for (size_t i = 0; i < ltk_menu_get_num_entries(combo->dropdown); i++) {
+			txtbuf_append(tmpbuf, ltk_menuentry_get_text(ltk_menu_get_entry(combo->dropdown, i)));
+			txtbuf_append(tmpbuf, "\n");
+		}
+		ltk_call_cmd(self, config->option_chooser, txtbuf_get_text(tmpbuf), txtbuf_len(tmpbuf));
+		txtbuf_destroy(tmpbuf);
+	}
+	return 0;
+}
+
+static int
+ltk_combobox_key_press(ltk_widget *self, ltk_key_event *event) {
+	ltk_keypress_binding b;
+	for (size_t i = 0; i < ltk_array_len(keypresses); i++) {
+		b = ltk_array_get(keypresses, i).b;
+		if ((b.mods == event->modmask && b.sym != LTK_KEY_NONE && b.sym == event->sym) ||
+		    (b.mods == (event->modmask & ~LTK_MOD_SHIFT) &&
+		     ((b.text && event->mapped && !strcmp(b.text, event->mapped)) ||
+		      (b.rawtext && event->text && !strcmp(b.rawtext, event->text))))) {
+			ltk_array_get(keypresses, i).cb.func(self, event);
+			self->dirty = 1;
+			ltk_window_invalidate_widget_rect(self->window, self);
+			return 1;
+		}
+	}
+	return 0;
+}
+
+const char *
+ltk_combobox_get_text(ltk_combobox *combo) {
+	if (!combo->dropdown)
+		return NULL;
+	ltk_menuentry *e = ltk_menu_get_entry(combo->dropdown, combo->cur_active);
+	if (!e)
+		return NULL;
+	return ltk_menuentry_get_text(e);
+}
+
+size_t
+ltk_combobox_get_index(ltk_combobox *combo) {
+	return combo->cur_active;
+}
+
+/* FIXME: this is really hacky - it was added to remove some weird effects when moving
+   around with keyboard shortcuts */
+/* FIXME: movement is still weird, for instance when pressing left on the dropdown,
+   focus moves to the combobox, not to the widget to the left - maybe there needs to be
+   another widget flag so the combobox is activatable but isn't taken into account when
+   moving back up from the child to the parent */
+/* FIXME: maybe just have a dedicated dropdown instead of reusing a menu in order to fix
+   these weirdnesses? */
+static int
+handle_dropdown_change_state(ltk_widget *self, ltk_callback_arglist args, ltk_callback_arg data) {
+	(void)args;
+	ltk_menu *menu = LTK_CAST_MENU(self);
+	ltk_combobox *combo = LTK_CAST_COMBOBOX(LTK_CAST_ARG_WIDGET(data));
+	if (menu != combo->dropdown) /* should never happen */
+		return 0;
+	if (!(menu->widget.state & LTK_ACTIVE) && !menu->widget.hidden)
+		ltk_widget_hide(self);
+	return 0;
+}
+
+int
+ltk_combobox_insert_option(ltk_combobox *combobox, const char *option, size_t idx) {
+	unpopup_dropdown(combobox); /* just to avoid weird effects */
+	if (!combobox->dropdown) {
+		combobox->dropdown = ltk_submenu_create(LTK_CAST_WIDGET(combobox)->window);
+		LTK_CAST_WIDGET(combobox->dropdown)->parent = LTK_CAST_WIDGET(combobox);
+		ltk_widget_register_signal_handler(
+			LTK_CAST_WIDGET(combobox->dropdown), LTK_WIDGET_SIGNAL_CHANGE_STATE,
+			&handle_dropdown_change_state, LTK_MAKE_ARG_WIDGET(LTK_CAST_WIDGET(combobox))
+		);
+	}
+	ltk_menuentry *e = ltk_menuentry_create(LTK_CAST_WIDGET(combobox)->window, option);
+	if (ltk_menu_insert_entry(combobox->dropdown, e, idx)) {
+		ltk_widget_destroy(LTK_CAST_WIDGET(e), 0);
+		return 1;
+	}
+	size_t num = ltk_menu_get_num_entries(combobox->dropdown);
+	if (num == 1) {
+		combobox_set_active(combobox, 0, option);
+	} else if (idx <= combobox->cur_active && combobox->cur_active < num) {
+		combobox->cur_active++;
+	}
+	ltk_widget_register_signal_handler(
+		LTK_CAST_WIDGET(e), LTK_MENUENTRY_SIGNAL_PRESSED,
+		&handle_entry_pressed, LTK_MAKE_ARG_WIDGET(LTK_CAST_WIDGET(combobox))
+	);
+	return 0;
+}
+
+int
+ltk_combobox_add_option(ltk_combobox *combobox, const char *option) {
+	/* it's easier to just completely ban options with newlines instead of
+	   dealing with weird cases where the external option-chooser splits
+	   options at newlines */
+	/* FIXME: should any other chars be banned? */
+	if (strchr(option, '\n'))
+		return 1;
+	unpopup_dropdown(combobox); /* just to avoid weird effects */
+	if (!combobox->dropdown) {
+		combobox->dropdown = ltk_submenu_create(LTK_CAST_WIDGET(combobox)->window);
+		LTK_CAST_WIDGET(combobox->dropdown)->parent = LTK_CAST_WIDGET(combobox);
+		ltk_widget_register_signal_handler(
+			LTK_CAST_WIDGET(combobox->dropdown), LTK_WIDGET_SIGNAL_CHANGE_STATE,
+			&handle_dropdown_change_state, LTK_MAKE_ARG_WIDGET(LTK_CAST_WIDGET(combobox))
+		);
+	}
+	ltk_menuentry *e = ltk_menuentry_create(LTK_CAST_WIDGET(combobox)->window, option);
+	/* this should never fail */
+	ltk_menu_add_entry(combobox->dropdown, e);
+	size_t num = ltk_menu_get_num_entries(combobox->dropdown);
+	if (num == 1) {
+		combobox_set_active(combobox, 0, option);
+	}
+	ltk_widget_register_signal_handler(
+		LTK_CAST_WIDGET(e), LTK_MENUENTRY_SIGNAL_PRESSED,
+		&handle_entry_pressed, LTK_MAKE_ARG_WIDGET(LTK_CAST_WIDGET(combobox))
+	);
+	return 0;
+}
+
+int
+ltk_combobox_remove_option_index(ltk_combobox *combobox, size_t idx) {
+	if (!combobox->dropdown)
+		return 1;
+	unpopup_dropdown(combobox); /* just to avoid weird effects */
+	ltk_menuentry *e = ltk_menu_remove_entry_index(combobox->dropdown, idx);
+	if (!e) return 1;
+	ltk_widget_destroy(LTK_CAST_WIDGET(e), 0);
+	if (idx == combobox->cur_active) {
+		size_t num = ltk_menu_get_num_entries(combobox->dropdown);
+		if (num == 0) {
+			combobox_set_active(combobox, SIZE_MAX, "");
+		} else {
+			e = ltk_menu_get_entry(combobox->dropdown, combobox->cur_active);
+			if (!e) ltk_fatal("Unable to get menu entry. This should not happen.");
+			combobox_set_active(combobox, idx >= num ? num - 1 : idx, ltk_menuentry_get_text(e));
+		}
+	}
+	return 0;
+}
+
+void
+ltk_combobox_remove_all_options(ltk_combobox *combobox) {
+	if (!combobox->dropdown)
+		return;
+	unpopup_dropdown(combobox); /* just to avoid weird effects */
+	ltk_menu_remove_all_entries(combobox->dropdown);
+	combobox_set_active(combobox, SIZE_MAX, "");
+}
+
+/* NOTE: This should never be called since the dropdown is managed
+   completely by the combobox, but it's here just in case. */
+static int
+ltk_combobox_remove_child(ltk_widget *self, ltk_widget *widget) {
+	ltk_combobox *combo = LTK_CAST_COMBOBOX(self);
+	if (widget != LTK_CAST_WIDGET(combo->dropdown))
+		return 1;
+	widget->parent = NULL;
+	combo->dropdown = NULL;
+	return 0;
+}
+
+static ltk_widget *
+ltk_combobox_get_child(ltk_widget *self) {
+	ltk_combobox *combo = LTK_CAST_COMBOBOX(self);
+	if (combo->dropdown && !combo->dropdown->widget.hidden)
+		return LTK_CAST_WIDGET(combo->dropdown);
+	return NULL;
+}
+
+static ltk_widget *
+ltk_combobox_nearest_child(ltk_widget *self, ltk_rect rect) {
+	(void)rect;
+	return ltk_combobox_get_child(self);
+}
+
+ltk_combobox *
+ltk_combobox_create(ltk_window *window) {
+	ltk_combobox *combobox = ltk_malloc(sizeof(ltk_combobox));
+	ltk_fill_widget_defaults(LTK_CAST_WIDGET(combobox), window, &vtable, 0, 0);
+	combobox->dropdown = NULL;
+	combobox->cur_active = SIZE_MAX;
+
+	/* FIXME: only create once text has been added */
+	combobox->tl = ltk_text_line_create_const_text_default(
+		theme.font, ltk_size_to_pixel(theme.font_size, combobox->widget.last_dpi), "", -1
+	);
+	recalc_ideal_size(combobox);
+	combobox->widget.dirty = 1;
+
+	return combobox;
+}
+
+static void
+ltk_combobox_destroy(ltk_widget *self, int shallow) {
+	(void)shallow;
+	ltk_combobox *combo = LTK_CAST_COMBOBOX(self);
+	if (!combo) {
+		ltk_warn("Tried to destroy NULL combobox.\n");
+		return;
+	}
+	ltk_text_line_destroy(combo->tl);
+	if (combo->dropdown) {
+		LTK_CAST_WIDGET(combo->dropdown)->parent = NULL;
+		ltk_widget_destroy(LTK_CAST_WIDGET(combo->dropdown), 0);
+	}
+	ltk_free(combo);
+}
diff --git a/src/ltk/combobox.h b/src/ltk/combobox.h
@@ -0,0 +1,43 @@
+/*
+ * Copyright (c) 2024 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
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+#ifndef LTK_COMBOBOX_H
+#define LTK_COMBOBOX_H
+
+#include "text.h"
+#include "widget.h"
+#include "window.h"
+#include "menu.h"
+
+#define LTK_COMBOBOX_SIGNAL_CHANGED -1
+#define LTK_COMBOBOX_SIGNAL_INVALID -2
+
+typedef struct {
+	ltk_widget widget;
+	ltk_text_line *tl;
+	ltk_menu *dropdown;
+	size_t cur_active;
+} ltk_combobox;
+
+ltk_combobox *ltk_combobox_create(ltk_window *window);
+int ltk_combobox_insert_option(ltk_combobox *combobox, const char *option, size_t idx);
+int ltk_combobox_add_option(ltk_combobox *combobox, const char *option);
+int ltk_combobox_remove_option_index(ltk_combobox *combobox, size_t idx);
+void ltk_combobox_remove_all_options(ltk_combobox *combobox);
+const char *ltk_combobox_get_text(ltk_combobox *combo);
+size_t ltk_combobox_get_index(ltk_combobox *combo);
+
+#endif /* LTK_COMBOBOX_H */
diff --git a/src/ltk/config.c b/src/ltk/config.c
@@ -35,8 +35,11 @@ static ltk_general_config general_config;
 static ltk_language_mapping *mappings = NULL;
 static size_t mappings_alloc = 0, mappings_len = 0;
 
+static ltk_array(cmd) *ltk_parse_cmd(const char *cmdtext, size_t len);
+
 static ltk_theme_parseinfo general_parseinfo[] = {
-	{"line-editor", THEME_STRING, {.str = &general_config.line_editor}, {.str = NULL}, 0, 0, 0},
+	{"line-editor", THEME_CMD, {.cmd = &general_config.line_editor}, {.cmd = NULL}, 0, 0, 0},
+	{"option-chooser", THEME_CMD, {.cmd = &general_config.option_chooser}, {.cmd = NULL}, 0, 0, 0},
 	{"dpi-scale", THEME_DOUBLE, {.d = &general_config.dpi_scale}, {.d = 1.0}, 10, 10000, 0},
 	{"explicit-focus", THEME_BOOL, {.b = &general_config.explicit_focus}, {.b = 0}, 0, 0, 0},
 	{"all-activatable", THEME_BOOL, {.b = &general_config.all_activatable}, {.b = 0}, 0, 0, 0},
@@ -64,6 +67,7 @@ static struct {
 		ltk_array(keypress) **presses_ret, ltk_array(keyrelease) **releases_ret
 	);
 } keybinding_handlers[] = {
+	{"combobox", <k_combobox_get_keybinding_parseinfo},
 	{"entry", <k_entry_get_keybinding_parseinfo},
 	{"window", <k_window_get_keybinding_parseinfo},
 };
@@ -86,6 +90,7 @@ static struct theme_handlerinfo {
 	{"theme:submenuentry", <k_submenuentry_get_theme_parseinfo, "theme:window", 0},
 	{"theme:checkbutton", <k_checkbutton_get_theme_parseinfo, "theme:window", 0},
 	{"theme:radiobutton", <k_radiobutton_get_theme_parseinfo, "theme:window", 0},
+	{"theme:combobox", <k_combobox_get_theme_parseinfo, "theme:window", 0},
 };
 
 GEN_SORT_SEARCH_HELPERS(themehandler, struct theme_handlerinfo, name)
@@ -101,6 +106,39 @@ sort_themehandlers(void) {
 	}
 }
 
+LTK_ARRAY_INIT_FUNC_DECL_STATIC(cmdpiece, struct ltk_cmd_piece)
+LTK_ARRAY_INIT_IMPL_STATIC(cmdpiece, struct ltk_cmd_piece)
+LTK_ARRAY_INIT_FUNC_DECL_STATIC(cmd, ltk_array(cmdpiece) *)
+LTK_ARRAY_INIT_IMPL_STATIC(cmd, ltk_array(cmdpiece) *)
+
+static void
+cmd_piece_free_helper(struct ltk_cmd_piece p) {
+	if (p.text)
+		ltk_free(p.text);
+}
+
+static void
+cmd_free_helper(ltk_array(cmdpiece) *arr) {
+	ltk_array_destroy_deep(cmdpiece, arr, &cmd_piece_free_helper);
+}
+
+static ltk_array(cmd) *
+copy_cmd(ltk_array(cmd) *cmd) {
+	ltk_array(cmd) *cmdcopy = ltk_array_create(cmd, ltk_array_len(cmd));
+	for (size_t i = 0; i < ltk_array_len(cmd); i++) {
+		ltk_array(cmdpiece) *piece = ltk_array_get(cmd, i);
+		ltk_array(cmdpiece) *piececopy = ltk_array_create(cmdpiece, ltk_array_len(piece));
+		for (size_t j = 0; j < ltk_array_len(piece); j++) {
+			struct ltk_cmd_piece p = {NULL, ltk_array_get(piece, j).type};
+			if (ltk_array_get(piece, j).text)
+				p.text = ltk_strdup(ltk_array_get(piece, j).text);
+			ltk_array_append(cmdpiece, piececopy, p);
+		}
+		ltk_array_append(cmd, cmdcopy, piececopy);
+	}
+	return cmdcopy;
+}
+
 /* FIXME: handle '#' or no '#' in color specification */
 static int
 handle_theme_setting(ltk_renderdata *renderdata, ltk_theme_parseinfo *entry, const char *value) {
@@ -170,6 +208,11 @@ handle_theme_setting(ltk_renderdata *renderdata, ltk_theme_parseinfo *entry, con
 			return 1;
 		entry->initialized = 1;
 		break;
+	case THEME_CMD:
+		if (!(*(entry->ptr.cmd) = ltk_parse_cmd(value, strlen(value))))
+			return 1;
+		entry->initialized = 1;
+		break;
 	case THEME_BOOL:
 		if (strcmp(value, "true") == 0) {
 			*(entry->ptr.b) = 1;
@@ -266,11 +309,25 @@ fill_single_theme_defaults(ltk_renderdata *renderdata, struct theme_handlerinfo 
 			if (ep) {
 				if (!(*(e->ptr.color) = ltk_color_copy(renderdata, *(ep->ptr.color))))
 					return 1;
+			} else if (!e->defaultval.color) {
+				return 1; /* colors must always be initialized */
 			} else if (!(*(e->ptr.color) = ltk_color_create(renderdata, e->defaultval.color))) {
 				return 1;
 			}
 			e->initialized = 1;
 			break;
+		case THEME_CMD:
+			if (ep) {
+				/* There is no reason to ever use this, but whatever */
+				if (!(*(e->ptr.cmd) = copy_cmd(*(ep->ptr.cmd))))
+					return 1;
+			} else if (!e->defaultval.cmd) {
+				*(e->ptr.cmd) = NULL;
+			} else if (!(*(e->ptr.cmd) = ltk_parse_cmd(e->defaultval.cmd, strlen(e->defaultval.cmd)))) {
+				return 1;
+			}
+			e->initialized = 1;
+			break;
 		case THEME_BOOL:
 			*(e->ptr.b) = ep ? *(ep->ptr.b) : e->defaultval.b;
 			e->initialized = 1;
@@ -316,6 +373,10 @@ uninitialize_theme(ltk_renderdata *renderdata) {
 				ltk_color_destroy(renderdata, *(e->ptr.color));
 				e->initialized = 0;
 				break;
+			case THEME_CMD:
+				ltk_array_destroy_deep(cmd, *(e->ptr.cmd), &cmd_free_helper);
+				e->initialized = 0;
+				break;
 			case THEME_SIZE:
 			case THEME_INT:
 			case THEME_UINT:
@@ -878,7 +939,7 @@ ltk_config_get_language_mapping(size_t idx) {
 	return &mappings[idx];
 }
 
-int
+static int
 str_array_prefix(const char *str, const char *ar, size_t len) {
 	size_t slen = strlen(str);
 	if (len < slen)
@@ -1086,7 +1147,7 @@ ltk_config_parsefile(ltk_renderdata *renderdata, const char *filename, char **er
 }
 
 /* FIXME: update this */
-const char *default_config = "[general]\n"
+static const char *default_config = "[general]\n"
 "explicit-focus = true\n"
 "all-activatable = true\n"
 "[key-binding:window]\n"
@@ -1179,6 +1240,7 @@ static struct keysym_mapping {
 	{"kp-up", LTK_KEY_KP_UP},
 
 	{"left", LTK_KEY_LEFT},
+	{"left-tab", LTK_KEY_LEFT_TAB},
 	{"linefeed", LTK_KEY_LINEFEED},
 	{"menu", LTK_KEY_MENU},
 	{"mode-switch", LTK_KEY_MODE_SWITCH},
@@ -1218,3 +1280,200 @@ parse_keysym(char *keysym_str, size_t len, ltk_keysym *sym) {
 	*sym = km->keysym;
 	return 0;
 }
+
+/* FIXME: this is really ugly */
+/* 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). */
+static ltk_array(cmd) *
+ltk_parse_cmd(const char *cmdtext, size_t len) {
+	int bs = 0;
+	int in_sqstr = 0;
+	int in_dqstr = 0;
+	int in_ws = 1;
+	int inout_used = 0, input_used = 0, output_used = 0;
+	char c;
+	size_t cur_start = 0;
+	int offset = 0;
+	ltk_array(cmdpiece) *cur_arg = ltk_array_create(cmdpiece, 1);
+	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) {
+						struct ltk_cmd_piece p = {ltk_strndup(cmdcopy + cur_start, i - cur_start - offset), LTK_CMD_TEXT};
+						ltk_array_append(cmdpiece, cur_arg, p);
+					}
+					/* FIXME: cmd is named horribly */
+					ltk_array_append(cmd, cmd, cur_arg);
+					cur_arg = ltk_array_create(cmdpiece, 1);
+					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 && i < len - 1 && (cmdcopy[i + 1] == 'f' || cmdcopy[i + 1] == 'i' || cmdcopy[i + 1] == 'o')) {
+				if (!in_ws && cur_start < i - offset) {
+					struct ltk_cmd_piece p = {ltk_strndup(cmdcopy + cur_start, i - cur_start - offset), LTK_CMD_TEXT};
+					ltk_array_append(cmdpiece, cur_arg, p);
+				}
+				struct ltk_cmd_piece p = {NULL, LTK_CMD_INOUT_FILE};
+				switch (cmdcopy[i + 1]) {
+				case 'f':
+					p.type = LTK_CMD_INOUT_FILE;
+					if (input_used || output_used)
+						goto error;
+					inout_used = 1;
+					break;
+				case 'i':
+					p.type = LTK_CMD_INPUT_FILE;
+					if (inout_used)
+						goto error;
+					input_used = 1;
+					break;
+				case 'o':
+					p.type = LTK_CMD_OUTPUT_FILE;
+					if (inout_used)
+						goto error;
+					output_used = 1;
+					break;
+				default:
+					ltk_fatal("Impossible.");
+				}
+				ltk_array_append(cmdpiece, cur_arg, p);
+				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];
+	}
+	/* FIXME: proper error messages with errstr */
+	if (in_sqstr || in_dqstr) {
+		/*ltk_warn("Unterminated string in command\n");*/
+		goto error;
+	}
+	if (!in_ws) {
+		if (cur_start <= len - offset) {
+			struct ltk_cmd_piece p = {ltk_strndup(cmdcopy + cur_start, len - cur_start - offset), LTK_CMD_TEXT};
+			ltk_array_append(cmdpiece, cur_arg, p);
+		}
+		ltk_array_append(cmd, cmd, cur_arg);
+		cur_arg = NULL;
+	}
+	if (cmd->len == 0) {
+		/*ltk_warn("Empty command\n");*/
+		goto error;
+	}
+	ltk_free(cmdcopy);
+	return cmd;
+error:
+	ltk_free(cmdcopy);
+	if (cur_arg)
+		ltk_array_destroy_deep(cmdpiece, cur_arg, &cmd_piece_free_helper);
+	ltk_array_destroy_deep(cmd, cmd, &cmd_free_helper);
+	return NULL;
+}
diff --git a/src/ltk/config.h b/src/ltk/config.h
@@ -55,8 +55,22 @@ typedef struct {
 	size_t mappings_alloc, mappings_len;
 } ltk_language_mapping;
 
+struct ltk_cmd_piece {
+	char *text;
+	enum {
+		LTK_CMD_TEXT,
+		LTK_CMD_INPUT_FILE,
+		LTK_CMD_OUTPUT_FILE,
+		LTK_CMD_INOUT_FILE,
+	} type;
+};
+
+LTK_ARRAY_INIT_STRUCT_DECL(cmdpiece, struct ltk_cmd_piece)
+LTK_ARRAY_INIT_STRUCT_DECL(cmd, ltk_array(cmdpiece) *)
+
 typedef struct {
-	char *line_editor;
+	ltk_array(cmd) *line_editor;
+	ltk_array(cmd) *option_chooser;
 	double dpi_scale;
 	double fixed_dpi;
 	int mixed_dpi;
@@ -90,6 +104,7 @@ typedef enum {
 	THEME_BORDERSIDES,
 	THEME_SIZE,
 	THEME_DOUBLE,
+	THEME_CMD,
 } ltk_theme_datatype;
 
 typedef struct {
@@ -106,6 +121,7 @@ typedef struct {
 		ltk_border_sides *border;
 		ltk_size *size;
 		double *d;
+		ltk_array(cmd) **cmd;
 	} ptr;
 	/* Note: The default color is also given as a string
 	   because it has to be allocated first (it is only a
@@ -120,6 +136,7 @@ typedef struct {
 		ltk_border_sides border;
 		ltk_size size;
 		double d;
+		char *cmd;
 	} defaultval;
 	/* FIXME: min/max doesn't make too much sense for sizes since they
 	   can use different units, but that shouldn't matter for now because
diff --git a/src/ltk/entry.c b/src/ltk/entry.c
@@ -39,6 +39,7 @@
 #include "util.h"
 #include "widget.h"
 #include "config.h"
+#include "widget_internal.h"
 
 #define MAX_ENTRY_BORDER_WIDTH 10000
 #define MAX_ENTRY_PADDING 50000
@@ -577,7 +578,7 @@ edit_external(ltk_widget *self, ltk_key_event *event) {
 	} else {
 		/* FIXME: somehow show that there was an error if this returns 1? */
 		/* FIXME: change interface to not require length of cmd */
-		ltk_call_cmd(LTK_CAST_WIDGET(entry), config->line_editor, strlen(config->line_editor), entry->text, entry->len);
+		ltk_call_cmd(LTK_CAST_WIDGET(entry), config->line_editor, entry->text, entry->len);
 	}
 	return 0;
 }
diff --git a/src/ltk/event_xlib.c b/src/ltk/event_xlib.c
@@ -622,6 +622,7 @@ static struct keysym_mapping {
 	{XK_space, LTK_KEY_SPACE},
 	{XK_Sys_Req, LTK_KEY_SYS_REQ},
 	{XK_Tab, LTK_KEY_TAB},
+	{XK_ISO_Left_Tab, LTK_KEY_LEFT_TAB},
 	{XK_Up, LTK_KEY_UP},
 	{XK_Undo, LTK_KEY_UNDO},
 };
diff --git a/src/ltk/eventdefs.h b/src/ltk/eventdefs.h
@@ -140,6 +140,7 @@ typedef enum {
 	LTK_KEY_SPACE,
 	LTK_KEY_SYS_REQ,
 	LTK_KEY_TAB,
+	LTK_KEY_LEFT_TAB,
 	LTK_KEY_UP,
 	LTK_KEY_UNDO
 } ltk_keysym;
diff --git a/src/ltk/ltk.c b/src/ltk/ltk.c
@@ -45,8 +45,9 @@
 #include "widget_internal.h"
 
 typedef struct {
-	char *tmpfile;
 	ltk_widget *caller;
+	char *infile;
+	char *outfile;
 	int pid;
 } ltk_cmdinfo;
 
@@ -54,8 +55,8 @@ LTK_ARRAY_INIT_DECL_STATIC(window, ltk_window *)
 LTK_ARRAY_INIT_IMPL_STATIC(window, ltk_window *)
 LTK_ARRAY_INIT_DECL_STATIC(rwindow, ltk_renderwindow *)
 LTK_ARRAY_INIT_IMPL_STATIC(rwindow, ltk_renderwindow *)
-LTK_ARRAY_INIT_DECL_STATIC(cmd, ltk_cmdinfo)
-LTK_ARRAY_INIT_IMPL_STATIC(cmd, ltk_cmdinfo)
+LTK_ARRAY_INIT_DECL_STATIC(cmdinfo, ltk_cmdinfo)
+LTK_ARRAY_INIT_IMPL_STATIC(cmdinfo, ltk_cmdinfo)
 
 static struct {
 	ltk_renderdata *renderdata;
@@ -66,9 +67,8 @@ static struct {
 	/* 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. */
-	/*IMPORTANT: this needs to be checked whenever a widget is destroyed!
-	FIXME: allow option to instead return output of command */
-	ltk_array(cmd) *cmds;
+	/*FIXME: this needs to be checked whenever a widget is destroyed!*/
+	ltk_array(cmdinfo) *cmds;
 	size_t cur_kbd;
 } shared_data = {NULL, NULL, NULL, NULL, NULL, NULL, 0};
 
@@ -97,59 +97,12 @@ typedef struct {
    knows if I'll need them again sometime... */
 static ltk_widget_funcs widget_funcs[] = {
 	{
-		.name = "box",
-		.cleanup = NULL,
-	},
-	{
-		.name = "button",
-		.cleanup = NULL,
-	},
-	{
 		.name = "entry",
 		.cleanup = <k_entry_cleanup,
 	},
 	{
-		.name = "grid",
-		.cleanup = NULL,
-	},
-	{
-		.name = "label",
-		.cleanup = NULL,
-	},
-	{
-		/* FIXME: this is actually image_widget */
-		.name = "image",
-		.cleanup = NULL,
-	},
-	{
-		.name = "menu",
-		.cleanup = NULL,
-	},
-	{
-		.name = "menuentry",
-		.cleanup = NULL,
-	},
-	{
-		.name = "submenu",
-		.cleanup = NULL,
-	},
-	{
-		.name = "submenuentry",
-		.cleanup = NULL,
-		 /*
-		 This "widget" is only needed to have separate styles for regular
-		   menu entries and submenu entries. "submenu" is just an alias for
-		   "menu" in most cases - it's just needed when creating a menu to
-		   decide if it's a submenu or not.
-		   FIXME: is that even necessary? Why can't it just decide if it's
-		   a submenu based on whether it has a parent or not?
-		   -> I guess right-click menus are also just submenus, so they
-		   need to set it explicitly, but wasn't there another reason? 
-		 */
-	},
-	{
-		.name = "scrollbar",
-		.cleanup = NULL,
+		.name = "combobox",
+		.cleanup = <k_combobox_cleanup,
 	},
 	{
 		/* Handler for window theme. */
@@ -202,7 +155,7 @@ ltk_init(void) {
 	ltk_image_init(shared_data.renderdata, 1024 * 1024 * 4);
 	shared_data.windows = ltk_array_create(window, 1);
 	shared_data.rwindows = ltk_array_create(rwindow, 1);
-	shared_data.cmds = ltk_array_create(cmd, 1);
+	shared_data.cmds = ltk_array_create(cmdinfo, 1);
 	return 0; /* FIXME: or maybe 1? */
 }
 
@@ -237,28 +190,37 @@ ltk_mainloop_step(int limit_framerate) {
 	int pid = -1;
 	int wstatus = 0;
 	/* FIXME: kill all children on exit? */
+	/* -> at least unlink any files? */
 	if ((pid = waitpid(-1, &wstatus, WNOHANG)) > 0) {
 		ltk_cmdinfo *info;
 		/* FIXME: should commands be split into read/write and block write commands during external editing? */
 		for (size_t i = 0; i < ltk_array_len(shared_data.cmds); i++) {
 			info = &(ltk_array_get(shared_data.cmds, i));
 			if (info->pid == pid) {
+				/* FIXME: actually NULL this when widgets are destroyed */
 				if (!info->caller) {
 					ltk_warn("Widget disappeared while text was being edited in external program\n");
 				/* FIXME: call overwritten cmd_return! */
 				} else if (info->caller->vtable->cmd_return) {
 					size_t file_len = 0;
 					char *errstr = NULL;
-					char *contents = ltk_read_file(info->tmpfile, &file_len, &errstr);
+					char *filename = info->outfile ? info->outfile : info->infile;
+					char *contents = ltk_read_file(filename, &file_len, &errstr);
 					if (!contents) {
-						ltk_warn("Unable to read file '%s' written by external command: %s\n", info->tmpfile, errstr);
+						ltk_warn("Unable to read file '%s' written by external command: %s\n", filename, errstr);
 					} else {
 						info->caller->vtable->cmd_return(info->caller, contents, file_len);
 						ltk_free0(contents);
 					}
 				}
-				ltk_free0(info->tmpfile);
-				ltk_array_delete(cmd, shared_data.cmds, i, 1);
+				/* FIXME: error checking */
+				unlink(info->infile);
+				ltk_free(info->infile);
+				if (info->outfile) {
+					unlink(info->outfile);
+					ltk_free(info->outfile);
+				}
+				ltk_array_delete(cmdinfo, shared_data.cmds, i, 1);
 				break;
 			}
 		}
@@ -341,9 +303,11 @@ ltk_deinit(void) {
 	if (shared_data.cmds) {
 		for (size_t i = 0; i < ltk_array_len(shared_data.cmds); i++) {
 			/* FIXME: maybe kill child processes? */
-			ltk_free((ltk_array_get(shared_data.cmds, i)).tmpfile);
+			ltk_free((ltk_array_get(shared_data.cmds, i)).infile);
+			if (ltk_array_get(shared_data.cmds, i).outfile)
+				ltk_free((ltk_array_get(shared_data.cmds, i)).outfile);
 		}
-		ltk_array_destroy(cmd, shared_data.cmds);
+		ltk_array_destroy(cmdinfo, shared_data.cmds);
 	}
 	shared_data.cmds = NULL;
 	if (shared_data.windows) {
@@ -462,38 +426,140 @@ ltk_register_timer(long first, long repeat, void (*callback)(ltk_callback_arg da
 	return id;
 }
 
+LTK_ARRAY_INIT_DECL_STATIC(str, char *)
+LTK_ARRAY_INIT_IMPL_STATIC(str, char *)
+
+static void
+str_free_helper(char *elem) {
+	ltk_free(elem);
+}
+
 int
-ltk_call_cmd(ltk_widget *caller, const char *cmd, size_t cmdlen, const char *text, size_t textlen) {
+ltk_call_cmd(ltk_widget *caller, ltk_array(cmd) *cmd, const char *text, size_t textlen) {
+	/* FIXME: maybe support stdin/stdout without temporary files by just piping directly */
 	/* FIXME: support environment variable $TMPDIR */
-	ltk_cmdinfo info = {NULL, NULL, -1};
-	info.tmpfile = ltk_strdup("/tmp/ltk.XXXXXX");
-	int fd = mkstemp(info.tmpfile);
-	if (fd == -1) {
-		ltk_warn_errno("Unable to create temporary file while trying to run command '%.*s'\n", (int)cmdlen, cmd);
-		ltk_free0(info.tmpfile);
-		return 1;
+	ltk_cmdinfo info = {
+		.caller = NULL, .infile = NULL, .outfile = NULL, .pid = -1
+	};
+	ltk_array(str) *cmdstr = ltk_array_create(str, 4);
+	txtbuf *tmpbuf = txtbuf_new();
+	int needs_stdin = 1;
+	int needs_stdout = 1;
+
+	int infd = -1, outfd = -1;
+
+	info.infile = ltk_strdup("/tmp/ltk.XXXXXX");
+	infd = mkstemp(info.infile);
+	if (infd == -1) {
+		ltk_warn_errno("Unable to create temporary input file while trying to run command.");
+		ltk_free(info.infile);
+		info.infile = NULL; /* so it isn't unlinked below */
+		goto error;
 	}
-	close(fd);
 	/* FIXME: give file descriptor directly to modified version of ltk_write_file */
 	char *errstr = NULL;
-	if (ltk_write_file(info.tmpfile, text, textlen, &errstr)) {
-		ltk_warn("Unable to write to file '%s' while trying to run command '%.*s': %s\n", info.tmpfile, (int)cmdlen, cmd, errstr);
-		unlink(info.tmpfile);
-		ltk_free0(info.tmpfile);
-		return 1;
+	if (ltk_write_file(info.infile, text, textlen, &errstr)) {
+		ltk_warn("Unable to write to temporary input file '%s' while trying to run command.", info.infile, errstr);
+		goto error;
 	}
-	int pid = -1;
-	if ((pid = ltk_parse_run_cmd(cmd, cmdlen, info.tmpfile)) <= 0) {
-		/* FIXME: errno */
-		ltk_warn("Unable to run command '%.*s'\n", (int)cmdlen, cmd);
-		unlink(info.tmpfile);
-		ltk_free0(info.tmpfile);
-		return 1;
+
+	for (size_t i = 0; i < ltk_array_len(cmd); i++) {
+		ltk_array(cmdpiece) *pa = ltk_array_get(cmd, i);
+		for (size_t j = 0; j < ltk_array_len(pa); j++) {
+			struct ltk_cmd_piece p = ltk_array_get(pa, j);
+			switch (p.type) {
+			case LTK_CMD_TEXT:
+				txtbuf_append(tmpbuf, p.text);
+				break;
+			case LTK_CMD_INOUT_FILE:
+				needs_stdout = 0;
+				/* fall through */
+			case LTK_CMD_INPUT_FILE:
+				needs_stdin = 0;
+				txtbuf_append(tmpbuf, info.infile);
+				break;
+			case LTK_CMD_OUTPUT_FILE:
+				needs_stdout = 0;
+				if (!info.outfile) {
+					info.outfile = ltk_strdup("/tmp/ltk.XXXXXX");
+					outfd = mkstemp(info.outfile);
+					if (outfd == -1) {
+						ltk_warn_errno("Unable to create temporary output file while trying to run command.");
+						ltk_free(info.outfile);
+						info.outfile = NULL; /* so it isn't unlinked below */
+						goto error;
+					}
+				}
+				txtbuf_append(tmpbuf, info.outfile);
+				break;
+			default:
+				ltk_warn("Invalid command piece type. This should not happen.");
+				goto error;
+			}
+		}
+		ltk_array_append(str, cmdstr, txtbuf_get_textcopy(tmpbuf));
+		txtbuf_clear(tmpbuf);
+	}
+	/* if no output file was specified, we still need to create it for stdout */
+	if (needs_stdout) {
+		info.outfile = ltk_strdup("/tmp/ltk.XXXXXX");
+		outfd = mkstemp(info.outfile);
+		if (outfd == -1) {
+			ltk_warn_errno("Unable to create temporary output file while trying to run command.");
+			ltk_free(info.outfile);
+			info.outfile = NULL; /* so it isn't unlinked below */
+			goto error;
+		}
+	}
+	ltk_array_append(str, cmdstr, NULL); /* necessary for execve */
+	txtbuf_destroy(tmpbuf);
+	tmpbuf = NULL;
+
+	int fret = -1;
+	if ((fret = fork()) < 0) {
+		ltk_warn("Unable to fork\n");
+		goto error;
+	} else if (fret == 0) {
+		if (needs_stdin) {
+			if (dup2(infd, fileno(stdin)) == -1)
+				ltk_fatal("Unable to set up stdin in child process.");
+		}
+		if (needs_stdout) {
+			int fd = outfd == -1 ? infd : outfd;
+			if (dup2(fd, fileno(stdout)) == -1)
+				ltk_fatal("Unable to set up stdout in child process.");
+		}
+		if (execvp(cmdstr->buf[0], cmdstr->buf) == -1)
+			ltk_fatal("Unable to exec external command.");
 	}
-	info.pid = pid;
+	ltk_array_destroy_deep(str, cmdstr, &str_free_helper);
+
+	info.pid = fret;
 	info.caller = caller;
-	ltk_array_append(cmd, shared_data.cmds, info);
+	ltk_array_append(cmdinfo, shared_data.cmds, info);
+
+	if (infd != -1)
+		close(infd); /* FIXME: error checking also on close */
+	if (outfd != -1)
+		close(outfd);
 	return 0;
+error:
+	if (infd != -1)
+		close(infd); /* FIXME: error checking also on close and unlink */
+	if (outfd != -1)
+		close(outfd);
+	if (tmpbuf)
+		txtbuf_destroy(tmpbuf);
+	if (info.infile) {
+		unlink(info.infile);
+		ltk_free(info.infile);
+	}
+	if (info.outfile) {
+		unlink(info.outfile);
+		ltk_free(info.outfile);
+	}
+	ltk_array_destroy_deep(str, cmdstr, &str_free_helper);
+	return 1;
 }
 
 static void
diff --git a/src/ltk/ltk.h b/src/ltk/ltk.h
@@ -43,12 +43,6 @@ int ltk_register_timer(long first, long repeat, void (*callback)(ltk_callback_ar
 ltk_window *ltk_window_create(const char *title, int x, int y, unsigned int w, unsigned int h);
 void ltk_window_destroy(ltk_widget *self, int shallow);
 
-/* FIXME: allow piping text instead of writing to temporary file */
-/* FIXME: how to avoid bad things happening while external program open? maybe store cmd widget somewhere (but could be multiple!) and check if widget to destroy is one of those 
--> alternative: store all widgets in array and only give out IDs, then when returning from cmd, widget is already destroyed and can be ignored
--> first option maybe just set callback, etc. of current cmd to NULL so widget can still be destroyed */
-int ltk_call_cmd(ltk_widget *caller, const char *cmd, size_t cmdlen, const char *text, size_t textlen);
-
 /* convenience function to use the default text context */
 ltk_text_line *ltk_text_line_create_default(const char *font, int font_size, char *text, int take_over_text, int width);
 ltk_text_line *ltk_text_line_create_const_text_default(const char *font, int font_size, const char *text, int width);
diff --git a/src/ltk/memory.h b/src/ltk/memory.h
@@ -17,8 +17,6 @@
 #ifndef LTK_MEMORY_H
 #define LTK_MEMORY_H
 
-/* FIXME: Move ltk_warn, etc. to util.* */
-
 #include <stdlib.h>
 
 #if MEMDEBUG == 1
diff --git a/src/ltk/menu.c b/src/ltk/menu.c
@@ -104,7 +104,6 @@ static int ltk_menu_motion_notify(ltk_widget *self, ltk_motion_event *event);
 static int ltk_menu_mouse_enter(ltk_widget *self, ltk_motion_event *event);
 static int ltk_menu_mouse_leave(ltk_widget *self, ltk_motion_event *event);
 static void shrink_entries(ltk_menu *menu);
-static size_t get_entry(ltk_menu *menu, ltk_menuentry *entry);
 static void ltk_menu_destroy(ltk_widget *self, int shallow);
 
 static ltk_menu *ltk_menu_create_base(ltk_window *window, int is_submenu);
@@ -321,6 +320,11 @@ ltk_menuentry_get_child(ltk_widget *self) {
 	return NULL;
 }
 
+const char *
+ltk_menuentry_get_text(ltk_menuentry *entry) {
+	return ltk_text_line_get_text(entry->text_line);
+}
+
 static void
 ltk_menuentry_draw(ltk_widget *self, ltk_surface *draw_surf, int x, int y, ltk_rect clip) {
 	/* FIXME: figure out how hidden should work */
@@ -731,20 +735,19 @@ popup_active_menu(ltk_menuentry *e) {
 	ltk_rect menu_rect = e->widget.lrect;
 	ltk_point entry_global = ltk_widget_pos_to_global(LTK_CAST_WIDGET(e), 0, 0);
 	ltk_point menu_global;
-	if (e->widget.parent && e->widget.parent->vtable->type == LTK_WIDGET_MENU) {
-		ltk_menu *menu = LTK_CAST_MENU(e->widget.parent);
+	if (LTK_CAST_WIDGET(e)->parent && LTK_CAST_WIDGET(e)->parent->vtable->type == LTK_WIDGET_MENU) {
+		ltk_menu *menu = LTK_CAST_MENU(LTK_CAST_WIDGET(e)->parent);
 		in_submenu = menu->is_submenu;
 		was_opened_left = menu->was_opened_left;
 		menu_rect = menu->widget.lrect;
-		menu_global = ltk_widget_pos_to_global(e->widget.parent, 0, 0);
+		menu_global = ltk_widget_pos_to_global(LTK_CAST_WIDGET(e)->parent, 0, 0);
 	} else {
-		menu_global = ltk_widget_pos_to_global(&e->widget, 0, 0);
+		menu_global = ltk_widget_pos_to_global(LTK_CAST_WIDGET(e), 0, 0);
 	}
 	int win_w = e->widget.window->rect.w;
 	int win_h = e->widget.window->rect.h;
 	ltk_menu *submenu = e->submenu;
 	ltk_widget_recalc_ideal_size(LTK_CAST_WIDGET(submenu));
-	ltk_widget_resize(LTK_CAST_WIDGET(submenu));
 	int ideal_w = submenu->widget.ideal_w;
 	int ideal_h = submenu->widget.ideal_h;
 	int x_final = 0, y_final = 0, w_final = ideal_w, h_final = ideal_h;
@@ -842,14 +845,14 @@ popup_active_menu(ltk_menuentry *e) {
 	submenu->widget.lrect.y = y_final;
 	submenu->widget.lrect.w = w_final;
 	submenu->widget.lrect.h = h_final;
-	submenu->widget.crect = submenu->widget.lrect;
+	submenu->widget.crect = LTK_CAST_WIDGET(submenu)->lrect;
 	submenu->widget.dirty = 1;
 	submenu->widget.hidden = 0;
 	submenu->popup_submenus = 0;
 	submenu->unpopup_submenus_on_hide = 1;
-	ltk_menu_resize(&submenu->widget);
-	ltk_window_register_popup(e->widget.window, (ltk_widget *)submenu);
-	ltk_window_invalidate_widget_rect(submenu->widget.window, &submenu->widget);
+	ltk_widget_resize(LTK_CAST_WIDGET(submenu));
+	ltk_window_register_popup(LTK_CAST_WIDGET(e)->window, LTK_CAST_WIDGET(submenu));
+	ltk_window_invalidate_widget_rect(LTK_CAST_WIDGET(submenu)->window, LTK_CAST_WIDGET(submenu));
 }
 
 static void
@@ -1126,15 +1129,16 @@ shrink_entries(ltk_menu *menu) {
 	}
 }
 
-int
+ltk_menuentry *
 ltk_menu_remove_entry_index(ltk_menu *menu, size_t idx) {
 	if (idx >= menu->num_entries)
-		return 1; /* invalid index */
+		return NULL; /* invalid index */
 	menu->entries[idx]->widget.parent = NULL;
 	/* I don't think this is needed because the entry isn't shown
 	   anywhere. Its size will be recalculated once it is added
 	   to a menu again. */
 	/* ltk_menuentry_recalc_ideal_size_with_notification(menu->entries[idx]); */
+	ltk_menuentry *ret = menu->entries[idx];
 	memmove(
 	    menu->entries + idx,
 	    menu->entries + idx + 1,
@@ -1143,11 +1147,11 @@ ltk_menu_remove_entry_index(ltk_menu *menu, size_t idx) {
 	menu->num_entries--;
 	shrink_entries(menu);
 	recalc_ideal_menu_size_with_notification(LTK_CAST_WIDGET(menu), NULL);
-	return 0;
+	return ret;
 }
 
-static size_t
-get_entry(ltk_menu *menu, ltk_menuentry *entry) {
+size_t
+ltk_menu_get_entry_index(ltk_menu *menu, ltk_menuentry *entry) {
 	for (size_t i = 0; i < menu->num_entries; i++) {
 		if (menu->entries[i] == entry)
 			return i;
@@ -1155,12 +1159,27 @@ get_entry(ltk_menu *menu, ltk_menuentry *entry) {
 	return SIZE_MAX;
 }
 
+size_t
+ltk_menu_get_num_entries(ltk_menu *menu) {
+	return menu->num_entries;
+}
+
+ltk_menuentry *
+ltk_menu_get_entry(ltk_menu *menu, size_t idx) {
+	if (idx >= menu->num_entries)
+		return NULL;
+	return menu->entries[idx];
+}
+
 int
 ltk_menu_remove_entry(ltk_menu *menu, ltk_menuentry *entry) {
-	size_t idx = get_entry(menu, entry);
+	size_t idx = ltk_menu_get_entry_index(menu, entry);
 	if (idx >= menu->num_entries)
 		return 1;
-	return ltk_menu_remove_entry_index(menu, idx);
+	ltk_menuentry *ret = ltk_menu_remove_entry_index(menu, idx);
+	if (!ret) /* shouldn't be possible */
+		return 1;
+	return 0;
 }
 
 static int
@@ -1212,7 +1231,7 @@ ltk_menu_nearest_child(ltk_widget *self, ltk_rect rect) {
    is already at bottom of respective menu - the top-level menu will give the first submenu in
    the current active hierarchy as child widget again, and nearest_child on that submenu will
    (probably) give the bottom widget again, so nothing changes except that all submenus except
-   for the first and second one disappeare */
+   for the first and second one disappear */
 static ltk_widget *
 ltk_menu_nearest_child_left(ltk_widget *self, ltk_widget *widget) {
 	ltk_menu *menu = LTK_CAST_MENU(self);
diff --git a/src/ltk/menu.h b/src/ltk/menu.h
@@ -73,10 +73,14 @@ ltk_menu *ltk_submenu_create(ltk_window *window);
 ltk_menuentry *ltk_menuentry_create(ltk_window *window, const char *text);
 int ltk_menuentry_attach_submenu(ltk_menuentry *e, ltk_menu *submenu);
 int ltk_menuentry_detach_submenu(ltk_menuentry *e);
+const char *ltk_menuentry_get_text(ltk_menuentry *entry);
 int ltk_menu_insert_entry(ltk_menu *menu, ltk_menuentry *entry, size_t idx);
 int ltk_menu_add_entry(ltk_menu *menu, ltk_menuentry *entry);
-int ltk_menu_remove_entry_index(ltk_menu *menu, size_t idx);
+ltk_menuentry *ltk_menu_remove_entry_index(ltk_menu *menu, size_t idx);
 int ltk_menu_remove_entry(ltk_menu *menu, ltk_menuentry *entry);
 void ltk_menu_remove_all_entries(ltk_menu *menu);
+size_t ltk_menu_get_num_entries(ltk_menu *menu);
+ltk_menuentry *ltk_menu_get_entry(ltk_menu *menu, size_t idx);
+size_t ltk_menu_get_entry_index(ltk_menu *menu, ltk_menuentry *entry);
 
 #endif /* LTK_MENU_H */
diff --git a/src/ltk/text.h b/src/ltk/text.h
@@ -40,6 +40,8 @@ void ltk_text_line_get_size(ltk_text_line *tl, int *w, int *h);
 void ltk_text_line_destroy(ltk_text_line *tl);
 /* FIXME: length of text */
 void ltk_text_line_set_text(ltk_text_line *line, char *text, int take_over_text);
+void ltk_text_line_set_const_text(ltk_text_line *line, const char *text);
+const char *ltk_text_line_get_text(ltk_text_line *line);
 
 /* Draw the entire line to a surface. */
 /* FIXME: Some widgets rely on this to not fail when negative coordinates are given or
diff --git a/src/ltk/text_pango.c b/src/ltk/text_pango.c
@@ -102,6 +102,16 @@ ltk_text_line_set_text(ltk_text_line *tl, char *text, int take_over_text) {
 }
 
 void
+ltk_text_line_set_const_text(ltk_text_line *tl, const char *text) {
+	ltk_text_line_set_text(tl, ltk_strdup(text), 1);
+}
+
+const char *
+ltk_text_line_get_text(ltk_text_line *tl) {
+	return tl->text;
+}
+
+void
 ltk_text_line_set_font_size(ltk_text_line *tl, int font_size) {
 	if (font_size == tl->font_size)
 		return;
diff --git a/src/ltk/txtbuf.c b/src/ltk/txtbuf.c
@@ -135,6 +135,16 @@ txtbuf_get_textcopy(txtbuf *buf) {
 	return buf->text ? ltk_strndup(buf->text, buf->len) : ltk_strdup("");
 }
 
+const char *
+txtbuf_get_text(txtbuf *buf) {
+	return buf->text;
+}
+
+size_t
+txtbuf_len(txtbuf *buf) {
+	return buf->len;
+}
+
 /* FIXME: proper "normalize" function to add nul-termination if needed */
 int
 txtbuf_cmp(txtbuf *buf1, txtbuf *buf2) {
diff --git a/src/ltk/txtbuf.h b/src/ltk/txtbuf.h
@@ -113,6 +113,19 @@ txtbuf *txtbuf_dup(txtbuf *src);
 char *txtbuf_get_textcopy(txtbuf *buf);
 
 /*
+ * Get text stored in 'buf'.
+ * The returned text belongs to the txtbuf and must not be changed.
+ * The returned text may be invalidated as soon as any other
+ * functions are called on the txtbuf.
+ */
+const char *txtbuf_get_text(txtbuf *buf);
+
+/*
+ * Get the length of the text stored in 'buf'.
+ */
+size_t txtbuf_len(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/ltk/util.c b/src/ltk/util.c
@@ -85,194 +85,6 @@ errorclose:
 	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
    `max(needed, *alloc_size * 2)`. Aborts program on error. */
 void
diff --git a/src/ltk/widget.h b/src/ltk/widget.h
@@ -47,6 +47,7 @@ typedef enum {
 	LTK_WIDGET_SCROLLBAR,
 	LTK_WIDGET_CHECKBUTTON,
 	LTK_WIDGET_RADIOBUTTON,
+	LTK_WIDGET_COMBOBOX,
 	LTK_NUM_WIDGETS,
 } ltk_widget_type;
 
@@ -188,6 +189,7 @@ typedef struct {
 #define LTK_CAST_BOX(w) (ltk_assert(w->vtable->type == LTK_WIDGET_BOX), (ltk_box *)(w))
 #define LTK_CAST_CHECKBUTTON(w) (ltk_assert(w->vtable->type == LTK_WIDGET_CHECKBUTTON), (ltk_checkbutton *)(w))
 #define LTK_CAST_RADIOBUTTON(w) (ltk_assert(w->vtable->type == LTK_WIDGET_RADIOBUTTON), (ltk_radiobutton *)(w))
+#define LTK_CAST_COMBOBOX(w) (ltk_assert(w->vtable->type == LTK_WIDGET_COMBOBOX), (ltk_combobox *)(w))
 
 /* FIXME: a bit weird because window never gets some of these signals */
 #define LTK_WIDGET_SIGNAL_KEY_PRESS          1
diff --git a/src/ltk/widget_internal.h b/src/ltk/widget_internal.h
@@ -35,6 +35,14 @@ void ltk_submenu_get_theme_parseinfo(ltk_theme_parseinfo **p, size_t *len);
 void ltk_submenuentry_get_theme_parseinfo(ltk_theme_parseinfo **p, size_t *len);
 void ltk_scrollbar_get_theme_parseinfo(ltk_theme_parseinfo **p, size_t *len);
 
+void ltk_combobox_get_theme_parseinfo(ltk_theme_parseinfo **p, size_t *len);
+void ltk_combobox_cleanup(void);
+void ltk_combobox_get_keybinding_parseinfo(
+	ltk_keybinding_cb **press_cbs_ret, size_t *press_len_ret,
+	ltk_keybinding_cb **release_cbs_ret, size_t *release_len_ret,
+	ltk_array(keypress) **presses_ret, ltk_array(keyrelease) **releases_ret
+);
+
 void ltk_entry_get_theme_parseinfo(ltk_theme_parseinfo **p, size_t *len);
 void ltk_entry_cleanup(void);
 void ltk_entry_get_keybinding_parseinfo(
@@ -53,4 +61,9 @@ void ltk_window_get_keybinding_parseinfo(
         ltk_array(keypress) **presses_ret, ltk_array(keyrelease) **releases_ret
 );
 
+/* FIXME: how to avoid bad things happening while external program open? maybe store cmd widget somewhere (but could be multiple!) and check if widget to destroy is one of those
+-> alternative: store all widgets in array and only give out IDs, then when returning from cmd, widget is already destroyed and can be ignored
+-> first option maybe just set callback, etc. of current cmd to NULL so widget can still be destroyed */
+int ltk_call_cmd(ltk_widget *caller, ltk_array(cmd) *cmd, const char *text, size_t textlen);
+
 #endif /* LTK_WIDGET_INTERNAL_H */
diff --git a/src/ltk/window.c b/src/ltk/window.c
@@ -193,19 +193,16 @@ ltk_window_key_press_event(ltk_widget *self, ltk_key_event *event) {
 	if (!keypresses)
 		return 1;
 	ltk_keypress_binding *b = NULL;
+	/* FIXME: move into separate function and share between window, entry, etc. */
 	for (size_t i = 0; i < ltk_array_len(keypresses); i++) {
 		b = <k_array_get(keypresses, i).b;
-		if (b->mods != event->modmask || (!(b->flags & LTK_KEY_BINDING_RUN_ALWAYS) && handled)) {
+		if ((!(b->flags & LTK_KEY_BINDING_RUN_ALWAYS) && handled))
 			continue;
-		} else if (b->text) {
-			if (event->mapped && !strcmp(b->text, event->mapped))
-				handled |= ltk_array_get(keypresses, i).cb.func(LTK_CAST_WIDGET(window), event);
-		} else if (b->rawtext) {
-			if (event->text && !strcmp(b->text, event->text))
-				handled |= ltk_array_get(keypresses, i).cb.func(LTK_CAST_WIDGET(window), event);
-		} else if (b->sym != LTK_KEY_NONE) {
-			if (event->sym == b->sym)
-				handled |= ltk_array_get(keypresses, i).cb.func(LTK_CAST_WIDGET(window), event);
+		if ((b->mods == event->modmask && b->sym != LTK_KEY_NONE && b->sym == event->sym) ||
+		    (b->mods == (event->modmask & ~LTK_MOD_SHIFT) &&
+		     ((b->text && event->mapped && !strcmp(b->text, event->mapped)) ||
+		      (b->rawtext && event->text && !strcmp(b->rawtext, event->text))))) {
+			handled |= ltk_array_get(keypresses, i).cb.func(LTK_CAST_WIDGET(window), event);
 		}
 	}
 	return 1;