commit d3b49ae1320664eeb8629e6c50be99642dc7f25e
parent 33cf30d1cfde0826ff45ce7b876ca2c1d8dbc9b9
Author: lumidify <nobody@lumidify.org>
Date:   Sun, 22 May 2022 17:12:52 +0200
Add menus
Yes, I know a lot of other things were also changed.
Diffstat:
30 files changed, 2518 insertions(+), 234 deletions(-)
diff --git a/Makefile b/Makefile
@@ -4,34 +4,36 @@
 NAME = ltk
 VERSION = -999-prealpha0
 
+# Note: The stb backend should not be used with untrusted font files.
 # FIXME: Using DEBUG here doesn't work because it somehow
 # interferes with a predefined macro, at least on OpenBSD.
-DEV = 1
+DEV = 0
 USE_PANGO = 0
 
-# FIXME: When using _POSIX_C_SOURCE on OpenBSD, strtonum isn't defined anymore -
-# should strtonum just only be used from the local copy?
-
-CFLAGS += -DUSE_PANGO=$(USE_PANGO) -DDEV=$(DEV) -Wall -Wextra -std=c99 `pkg-config --cflags x11 fontconfig xext` -D_POSIX_C_SOURCE=200809L
-LDFLAGS += -lm `pkg-config --libs x11 fontconfig xext`
-
 # Note: this macro magic for debugging and pango rendering seems ugly; it should probably be changed
 
 # debug
-DEV_1 = -g -Wall -Wextra -pedantic
-#-Werror
+DEV_CFLAGS_1 = -fsanitize=address -g -Wall -Wextra -pedantic
+DEV_LDFLAGS_1 = -fsanitize=address
+# don't include default flags when debugging so possible
+# optimization flags don't interfere with it
+DEV_CFLAGS_0 = $(CFLAGS)
+DEV_LDFLAGS_0 = $(LDFLAGS)
 
 # stb rendering
 EXTRA_OBJ_0 = src/stb_truetype.o src/text_stb.o
 
 # pango rendering
 EXTRA_OBJ_1 = src/text_pango.o
-EXTRA_CFLAGS_1 += -DUSE_PANGO `pkg-config --cflags pangoxft`
-EXTRA_LDFLAGS_1 += `pkg-config --libs pangoxft`
+EXTRA_CFLAGS_1 = `pkg-config --cflags pangoxft`
+EXTRA_LDFLAGS_1 = `pkg-config --libs pangoxft`
 
 EXTRA_OBJ = $(EXTRA_OBJ_$(USE_PANGO))
-EXTRA_CFLAGS = $(EXTRA_CFLAGS_$(USE_PANGO)) $(DEV_$(DEV))
-EXTRA_LDFLAGS = $(EXTRA_LDFLAGS_$(USE_PANGO))
+EXTRA_CFLAGS = $(DEV_CFLAGS_$(DEV)) $(EXTRA_CFLAGS_$(USE_PANGO))
+EXTRA_LDFLAGS = $(DEV_LDFLAGS_$(DEV)) $(EXTRA_LDFLAGS_$(USE_PANGO))
+
+LTK_CFLAGS = $(EXTRA_CFLAGS) -DUSE_PANGO=$(USE_PANGO) -DDEV=$(DEV) -std=c99 `pkg-config --cflags x11 fontconfig xext` -D_POSIX_C_SOURCE=200809L
+LTK_LDFLAGS = $(EXTRA_LDFLAGS) -lm `pkg-config --libs x11 fontconfig xext`
 
 OBJ = \
 	src/strtonum.o \
@@ -47,6 +49,7 @@ OBJ = \
 	src/scrollbar.o \
 	src/button.o \
 	src/label.o \
+	src/menu.o \
 	src/graphics_xlib.o \
 	src/surface_cache.o \
 	$(EXTRA_OBJ)
@@ -71,25 +74,24 @@ HDR = \
 	src/stb_truetype.h \
 	src/text.h \
 	src/util.h \
+	src/menu.h \
 	src/graphics.h \
-	src/surface_cache.h
+	src/surface_cache.h \
+	src/macros.h
 #	src/draw.h \
 
-CFLAGS += $(EXTRA_CFLAGS)
-LDFLAGS += $(EXTRA_LDFLAGS)
-
 all: src/ltkd src/ltkc
 
 src/ltkd: $(OBJ)
-	$(CC) -o $@ $(OBJ) $(LDFLAGS)
+	$(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
+	$(CC) -o $@ src/ltkc.o src/util.o src/memory.o $(LTK_LDFLAGS)
 
 $(OBJ) : $(HDR)
 
 .c.o:
-	$(CC) -c -o $@ $< $(CFLAGS)
+	$(CC) -c -o $@ $< $(LTK_CFLAGS)
 
 .PHONY: clean
 
diff --git a/README.md b/README.md
@@ -5,8 +5,7 @@ WILDEST FANTASIES, NOT ACTUAL WORKING CODE.
 
 To build with or without pango: Follow instructions in config.mk.
 
-Note: The basic (non-pango) text doesn't work properly on my i386 machine
-because it's a bit of a hack.
+Note: The basic (non-pango) text doesn't work properly on all systems.
 
 To test:
 
@@ -16,5 +15,8 @@ make
 If you click the top button, it should exit. That's all it does now.
 Also read the comment in './test.sh'.
 
-New:
 ./testbox.sh shows my gopherhole, but most buttons don't actually do anything.
+./test2.sh shows an example with menus.
+
+Note: I know the default theme is butt-ugly at the moment. It is mainly
+to test things, not to look pretty.
diff --git a/src/box.c b/src/box.c
@@ -117,15 +117,21 @@ static void
 ltk_box_destroy(ltk_widget *self, int shallow) {
 	ltk_box *box = (ltk_box *)self;
 	ltk_widget *ptr;
-	if (!shallow) {
-		for (size_t i = 0; i < box->num_widgets; i++) {
-			ptr = box->widgets[i];
+	char *errstr;
+	if (self->parent && self->parent->vtable->remove_child) {
+		self->parent->vtable->remove_child(
+		    self->window, self, self->parent, &errstr
+		);
+	}
+	for (size_t i = 0; i < box->num_widgets; i++) {
+		ptr = box->widgets[i];
+		ptr->parent = NULL;
+		if (!shallow)
 			ptr->vtable->destroy(ptr, shallow);
-		}
 	}
 	ltk_free(box->widgets);
-	ltk_remove_widget(box->widget.id);
-	ltk_free(box->widget.id);
+	ltk_remove_widget(self->id);
+	ltk_free(self->id);
 	box->sc->widget.vtable->destroy((ltk_widget *)box->sc, 0);
 	ltk_free(box);
 }
@@ -341,6 +347,7 @@ ltk_box_mouse_press(ltk_widget *self, XEvent event) {
 					default_handler = widget->vtable->mouse_press(widget, event);
 			}
 		}
+		/* FIXME: configure scrollstep */
 		if (default_handler) {
 			int delta = event.xbutton.button == 4 ? -15 : 15;
 			ltk_scrollbar_scroll((ltk_widget *)box->sc, delta, 0);
diff --git a/src/button.c b/src/button.c
@@ -34,6 +34,9 @@
 #include "graphics.h"
 #include "surface_cache.h"
 
+#define MAX_BUTTON_BORDER_WIDTH 100
+#define MAX_BUTTON_PADDING 500
+
 static void ltk_button_draw(ltk_widget *self, ltk_rect clip);
 static int ltk_button_mouse_release(ltk_widget *self, XEvent event);
 static ltk_button *ltk_button_create(ltk_window *window,
@@ -76,61 +79,57 @@ void
 ltk_button_setup_theme_defaults(ltk_window *window) {
 	theme.border_width = 2;
 	theme.pad = 5;
-	ltk_color_create(window->dpy, window->screen, window->cm,
-	    "#FFFFFF", &theme.text_color);
-	ltk_color_create(window->dpy, window->screen, window->cm,
-	    "#339999", &theme.border);
-	ltk_color_create(window->dpy, window->screen, window->cm,
-	    "#113355", &theme.fill);
-	ltk_color_create(window->dpy, window->screen, window->cm,
-	    "#FFFFFF", &theme.border_pressed);
-	ltk_color_create(window->dpy, window->screen, window->cm,
-	    "#113355", &theme.fill_pressed);
-	ltk_color_create(window->dpy, window->screen, window->cm,
-	    "#FFFFFF", &theme.border_active);
-	ltk_color_create(window->dpy, window->screen, window->cm,
-	    "#738194", &theme.fill_active);
-	ltk_color_create(window->dpy, window->screen, window->cm,
-	    "#FFFFFF", &theme.border_disabled);
-	ltk_color_create(window->dpy, window->screen, window->cm,
-	    "#292929", &theme.fill_disabled);
+	ltk_color_create(window->dpy, window->screen, window->cm, "#FFFFFF", &theme.text_color);
+	ltk_color_create(window->dpy, window->screen, window->cm, "#339999", &theme.border);
+	ltk_color_create(window->dpy, window->screen, window->cm, "#113355", &theme.fill);
+	ltk_color_create(window->dpy, window->screen, window->cm, "#FFFFFF", &theme.border_pressed);
+	ltk_color_create(window->dpy, window->screen, window->cm, "#113355", &theme.fill_pressed);
+	ltk_color_create(window->dpy, window->screen, window->cm, "#FFFFFF", &theme.border_active);
+	ltk_color_create(window->dpy, window->screen, window->cm, "#738194", &theme.fill_active);
+	ltk_color_create(window->dpy, window->screen, window->cm, "#FFFFFF", &theme.border_disabled);
+	ltk_color_create(window->dpy, window->screen, window->cm, "#292929", &theme.fill_disabled);
 }
 
 void
 ltk_button_ini_handler(ltk_window *window, const char *prop, const char *value) {
+	const char *errstr;
 	if (strcmp(prop, "border_width") == 0) {
-		theme.border_width = atoi(value);
+		theme.border_width = ltk_strtonum(value, 0, MAX_BUTTON_BORDER_WIDTH, &errstr);
+		if (errstr)
+			ltk_warn("Invalid button border width '%s': %s.\n", value, errstr);
 	} else if (strcmp(prop, "pad") == 0) {
-		theme.pad = atoi(value);
+		theme.pad = ltk_strtonum(value, 0, MAX_BUTTON_PADDING, &errstr);
+		if (errstr)
+			ltk_warn("Invalid button padding '%s': %s.\n", value, errstr);
 	} else if (strcmp(prop, "border") == 0) {
-		ltk_color_create(window->dpy, window->screen, window->cm,
-		    value, &theme.border);
+		if (ltk_color_create(window->dpy, window->screen, window->cm, value, &theme.border))
+			ltk_warn("Error setting button border color to '%s'.\n", value);
 	} else if (strcmp(prop, "fill") == 0) {
-		ltk_color_create(window->dpy, window->screen, window->cm,
-		    value, &theme.fill);
+		if (ltk_color_create(window->dpy, window->screen, window->cm, value, &theme.fill))
+			ltk_warn("Error setting button fill color to '%s'.\n", value);
 	} else if (strcmp(prop, "border_pressed") == 0) {
-		ltk_color_create(window->dpy, window->screen, window->cm,
-		    value, &theme.border_pressed);
+		if (ltk_color_create(window->dpy, window->screen, window->cm, value, &theme.border_pressed))
+			ltk_warn("Error setting button pressed border color to '%s'.\n", value);
 	} else if (strcmp(prop, "fill_pressed") == 0) {
-		ltk_color_create(window->dpy, window->screen, window->cm,
-		    value, &theme.fill_pressed);
+		if (ltk_color_create(window->dpy, window->screen, window->cm, value, &theme.fill_pressed))
+			ltk_warn("Error setting button pressed fill color to '%s'.\n", value);
 	} else if (strcmp(prop, "border_active") == 0) {
-		ltk_color_create(window->dpy, window->screen, window->cm,
-		    value, &theme.border_active);
+		if (ltk_color_create(window->dpy, window->screen, window->cm, value, &theme.border_active))
+			ltk_warn("Error setting button active border color to '%s'.\n", value);
 	} else if (strcmp(prop, "fill_active") == 0) {
-		ltk_color_create(window->dpy, window->screen, window->cm,
-		    value, &theme.fill_active);
+		if (ltk_color_create(window->dpy, window->screen, window->cm, value, &theme.fill_active))
+			ltk_warn("Error setting button active fill color to '%s'.\n", value);
 	} else if (strcmp(prop, "border_disabled") == 0) {
-		ltk_color_create(window->dpy, window->screen, window->cm,
-		    value, &theme.border_disabled);
+		if (ltk_color_create(window->dpy, window->screen, window->cm, value, &theme.border_disabled))
+			ltk_warn("Error setting button disabled border color to '%s'.\n", value);
 	} else if (strcmp(prop, "fill_disabled") == 0) {
-		ltk_color_create(window->dpy, window->screen, window->cm,
-		    value, &theme.fill_disabled);
+		if (ltk_color_create(window->dpy, window->screen, window->cm, value, &theme.fill_disabled))
+			ltk_warn("Error setting button disabled fill color to '%s'.\n", value);
 	} else if (strcmp(prop, "text_color") == 0) {
-		ltk_color_create(window->dpy, window->screen, window->cm,
-		    value, &theme.text_color);
+		if (ltk_color_create(window->dpy, window->screen, window->cm, value, &theme.text_color))
+			ltk_warn("Error setting button text color to '%s'.\n", value);
 	} else {
-		ltk_warn("Unknown property \"%s\" for button style.\n", prop);
+		ltk_warn("Unknown property '%s' for button style.\n", prop);
 	}
 }
 
@@ -192,7 +191,6 @@ ltk_button_change_state(ltk_widget *self) {
 /* FIXME: only when pressed button was actually this one */
 static int
 ltk_button_mouse_release(ltk_widget *self, XEvent event) {
-	(void)event;
 	ltk_button *button = (ltk_button *)self;
 	if (event.xbutton.button == 1) {
 		ltk_queue_event(button->widget.window, LTK_EVENT_BUTTON, button->widget.id, "button_click");
@@ -220,6 +218,12 @@ ltk_button_create(ltk_window *window, const char *id, char *text) {
 static void
 ltk_button_destroy(ltk_widget *self, int shallow) {
 	(void)shallow;
+	char *errstr;
+	if (self->parent && self->parent->vtable->remove_child) {
+		self->parent->vtable->remove_child(
+		    self->window, self, self->parent, &errstr
+		);
+	}
 	ltk_button *button = (ltk_button *)self;
 	if (!button) {
 		ltk_warn("Tried to destroy NULL button.\n");
@@ -228,6 +232,7 @@ ltk_button_destroy(ltk_widget *self, int shallow) {
 	/* FIXME: this should be generic part of widget */
 	ltk_surface_cache_release_key(self->surface_key);
 	ltk_text_line_destroy(button->tl);
+	ltk_remove_widget(self->id);
 	ltk_remove_widget(button->widget.id);
 	ltk_free(button->widget.id);
 	ltk_free(button);
diff --git a/src/button.h b/src/button.h
@@ -1,5 +1,5 @@
 /*
- * Copyright (c) 2016, 2017, 2018, 2020 lumidify <nobody@lumidify.org>
+ * Copyright (c) 2016, 2017, 2018, 2020, 2022 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
diff --git a/src/color.c b/src/color.c
@@ -20,18 +20,23 @@
 
 #include "util.h"
 #include "color.h"
+#include "compat.h"
 
-void
+/* FIXME: avoid initializing part of the struct and then error returning */
+/* FIXME: better error codes */
+/* FIXME: I think xcolor is unneeded when xft is enabled */
+int
 ltk_color_create(Display *dpy, int screen, Colormap cm, const char *hex, ltk_color *col) {
-	if (!XParseColor(dpy, cm, hex, &col->xcolor)) {
-		/* FIXME: better error reporting!!! */
-		ltk_fatal("ltk_color_create");
-	}
-	XAllocColor(dpy, cm, &col->xcolor);
-	/* FIXME: replace with XftColorAllocValue; error checking */
-	#if USE_PANGO == 1
-	XftColorAllocName(dpy, DefaultVisual(dpy, screen), cm, hex, &col->xftcolor);
+	if (!XParseColor(dpy, cm, hex, &col->xcolor))
+		return 1;
+	if (!XAllocColor(dpy, cm, &col->xcolor))
+		return 1;
+	/* FIXME: replace with XftColorAllocValue */
+	#if USE_XFT == 1
+	if (!XftColorAllocName(dpy, DefaultVisual(dpy, screen), cm, hex, &col->xftcolor))
+		return 1;
 	#else
 	(void)screen;
 	#endif
+	return 0;
 }
diff --git a/src/color.h b/src/color.h
@@ -30,6 +30,7 @@ typedef struct {
 	#endif
 } ltk_color;
 
-void ltk_color_create(Display *dpy, int screen, Colormap cm, const char *hex, ltk_color *col);
+/* returns 1 on failure, 0 on success */
+int ltk_color_create(Display *dpy, int screen, Colormap cm, const char *hex, ltk_color *col);
 
 #endif /* _LTK_COLOR_H_ */
diff --git a/src/compat.h b/src/compat.h
@@ -1,4 +1,4 @@
-#ifdef _LTK_COMPAT_H_
+#ifndef _LTK_COMPAT_H_
 #define _LTK_COMPAT_H_
 
 #if USE_PANGO == 1
diff --git a/src/draw.c b/src/draw.c
@@ -254,22 +254,22 @@ ltk_draw_cmd_line(
 	}
 	draw = (ltk_draw *)ltk_get_widget(tokens[1], LTK_DRAW, errstr);
 	if (!draw) return 1;
-	x1 = strtonum(tokens[3], 0, 100000, &errstr_num);
+	x1 = ltk_strtonum(tokens[3], 0, 100000, &errstr_num);
 	if (errstr_num) {
 		*errstr = "Invalid x1.\n";
 		return 1;
 	}
-	y1 = strtonum(tokens[4], 0, 100000, &errstr_num);
+	y1 = ltk_strtonum(tokens[4], 0, 100000, &errstr_num);
 	if (errstr_num) {
 		*errstr = "Invalid y1.\n";
 		return 1;
 	}
-	x2 = strtonum(tokens[5], 0, 100000, &errstr_num);
+	x2 = ltk_strtonum(tokens[5], 0, 100000, &errstr_num);
 	if (errstr_num) {
 		*errstr = "Invalid x2.\n";
 		return 1;
 	}
-	y2 = strtonum(tokens[6], 0, 100000, &errstr_num);
+	y2 = ltk_strtonum(tokens[6], 0, 100000, &errstr_num);
 	if (errstr_num) {
 		*errstr = "Invalid y2.\n";
 		return 1;
@@ -294,27 +294,27 @@ ltk_draw_cmd_rect(
 	}
 	draw = (ltk_draw *)ltk_get_widget(tokens[1], LTK_DRAW, errstr);
 	if (!draw) return 1;
-	x = strtonum(tokens[3], 0, 100000, &errstr_num);
+	x = ltk_strtonum(tokens[3], 0, 100000, &errstr_num);
 	if (errstr_num) {
 		*errstr = "Invalid x.\n";
 		return 1;
 	}
-	y = strtonum(tokens[4], 0, 100000, &errstr_num);
+	y = ltk_strtonum(tokens[4], 0, 100000, &errstr_num);
 	if (errstr_num) {
 		*errstr = "Invalid y.\n";
 		return 1;
 	}
-	w = strtonum(tokens[5], 1, 100000, &errstr_num);
+	w = ltk_strtonum(tokens[5], 1, 100000, &errstr_num);
 	if (errstr_num) {
 		*errstr = "Invalid width.\n";
 		return 1;
 	}
-	h = strtonum(tokens[6], 1, 100000, &errstr_num);
+	h = ltk_strtonum(tokens[6], 1, 100000, &errstr_num);
 	if (errstr_num) {
 		*errstr = "Invalid height.\n";
 		return 1;
 	}
-	fill = strtonum(tokens[7], 0, 1, &errstr_num);
+	fill = ltk_strtonum(tokens[7], 0, 1, &errstr_num);
 	if (errstr_num) {
 		*errstr = "Invalid fill bool.\n";
 		return 1;
@@ -342,12 +342,12 @@ ltk_draw_cmd_create(
 		*errstr = "Widget ID already taken.\n";
 		return 1;
 	}
-	w = strtonum(tokens[3], 1, 100000, &errstr_num);
+	w = ltk_strtonum(tokens[3], 1, 100000, &errstr_num);
 	if (errstr_num) {
 		*errstr = "Invalid width.\n";
 		return 1;
 	}
-	h = strtonum(tokens[4], 1, 100000, &errstr_num);
+	h = ltk_strtonum(tokens[4], 1, 100000, &errstr_num);
 	if (errstr_num) {
 		*errstr = "Invalid height.\n";
 		return 1;
diff --git a/src/graphics.h b/src/graphics.h
@@ -28,6 +28,20 @@
 #include "ltk.h"
 #include "compat.h"
 
+typedef enum {
+	LTK_BORDER_NONE = 0,
+	LTK_BORDER_TOP = 1,
+	LTK_BORDER_RIGHT = 2,
+	LTK_BORDER_BOTTOM = 4,
+	LTK_BORDER_LEFT = 8,
+	LTK_BORDER_ALL = 0xF
+} ltk_border_sides;
+
+/* FIXME: X only supports 16-bit numbers */
+typedef struct {
+	int x, y;
+} ltk_point;
+
 /* typedef struct ltk_surface ltk_surface; */
 
 /* FIXME: graphics context */
@@ -42,6 +56,9 @@ void ltk_surface_get_size(ltk_surface *s, int *w, int *h);
 void ltk_surface_copy(ltk_surface *src, ltk_surface *dst, ltk_rect src_rect, int dst_x, int dst_y);
 void ltk_surface_draw_rect(ltk_surface *s, ltk_color *c, ltk_rect rect, int line_width);
 void ltk_surface_fill_rect(ltk_surface *s, ltk_color *c, ltk_rect rect);
+/* FIXME: document properly, especiall difference to draw_rect with offsets and line_width */
+void ltk_surface_draw_border(ltk_surface *s, ltk_color *c, ltk_rect rect, int line_width, ltk_border_sides border_sides);
+void ltk_surface_fill_polygon(ltk_surface *s, ltk_color *c, ltk_point *points, size_t npoints);
 
 /* TODO */
 /*
diff --git a/src/graphics_xlib.c b/src/graphics_xlib.c
@@ -107,22 +107,46 @@ ltk_surface_draw_rect(ltk_surface *s, ltk_color *c, ltk_rect rect, int line_widt
 }
 
 void
-ltk_surface_fill_rect(ltk_surface *s, ltk_color *c, ltk_rect rect) {
+ltk_surface_draw_border(ltk_surface *s, ltk_color *c, ltk_rect rect, int line_width, ltk_border_sides border_sides) {
+	/* drawn as rectangles to have proper control over line width - I'm not sure how exactly
+	   XDrawLine handles even line widths (i.e. on which side the extra pixel will be) */
 	XSetForeground(s->window->dpy, s->window->gc, c->xcolor.pixel);
-	XFillRectangle(s->window->dpy, s->d, s->window->gc, rect.x, rect.y, rect.w, rect.h);
+	if (border_sides & LTK_BORDER_TOP)
+		XFillRectangle(s->window->dpy, s->d, s->window->gc, rect.x, rect.y, rect.w, line_width);
+	if (border_sides & LTK_BORDER_BOTTOM)
+		XFillRectangle(s->window->dpy, s->d, s->window->gc, rect.x, rect.y + rect.h - line_width, rect.w, line_width);
+	if (border_sides & LTK_BORDER_LEFT)
+		XFillRectangle(s->window->dpy, s->d, s->window->gc, rect.x, rect.y, line_width, rect.h);
+	if (border_sides & LTK_BORDER_RIGHT)
+		XFillRectangle(s->window->dpy, s->d, s->window->gc, rect.x + rect.w - line_width, rect.y, line_width, rect.h);
 }
 
 void
-ltk_window_draw_rect(ltk_window *window, ltk_color *c, ltk_rect rect, int line_width) {
-	XSetForeground(window->dpy, window->gc, c->xcolor.pixel);
-	XSetLineAttributes(window->dpy, window->gc, line_width, LineSolid, CapButt, JoinMiter);
-	XDrawRectangle(window->dpy, window->drawable, window->gc, rect.x, rect.y, rect.w, rect.h);
+ltk_surface_fill_rect(ltk_surface *s, ltk_color *c, ltk_rect rect) {
+	XSetForeground(s->window->dpy, s->window->gc, c->xcolor.pixel);
+	XFillRectangle(s->window->dpy, s->d, s->window->gc, rect.x, rect.y, rect.w, rect.h);
 }
 
 void
-ltk_window_fill_rect(ltk_window *window, ltk_color *c, ltk_rect rect) {
-	XSetForeground(window->dpy, window->gc, c->xcolor.pixel);
-	XFillRectangle(window->dpy, window->drawable, window->gc, rect.x, rect.y, rect.w, rect.h);
+ltk_surface_fill_polygon(ltk_surface *s, ltk_color *c, ltk_point *points, size_t npoints) {
+	/* FIXME: maybe make this statis since this won't be threaded anyways? */
+	XPoint tmp_points[6]; /* to avoid extra allocations when not necessary */
+	/* FIXME: this is ugly and inefficient */
+	XPoint *final_points;
+	if (npoints <= 6) {
+		final_points = tmp_points;
+	} else {
+		final_points = ltk_reallocarray(NULL, npoints, sizeof(XPoint));
+	}
+	/* FIXME: how to deal with ints that don't fit in short? */
+	for (size_t i = 0; i < npoints; i++) {
+		final_points[i].x = (short)points[i].x;
+		final_points[i].y = (short)points[i].y;
+	}
+	XSetForeground(s->window->dpy, s->window->gc, c->xcolor.pixel);
+	XFillPolygon(s->window->dpy, s->d, s->window->gc, final_points, (int)npoints, Complex, CoordModeOrigin);
+	if (npoints > 6)
+		free(final_points);
 }
 
 void
diff --git a/src/grid.c b/src/grid.c
@@ -1,3 +1,4 @@
+/* FIXME: sometimes, resizing doesn't work properly when running test.sh */
 /*
  * Copyright (c) 2016, 2017, 2018, 2020, 2021, 2022 lumidify <nobody@lumidify.org>
  *
@@ -160,11 +161,18 @@ ltk_grid_create(ltk_window *window, const char *id, int rows, int columns) {
 static void
 ltk_grid_destroy(ltk_widget *self, int shallow) {
 	ltk_grid *grid = (ltk_grid *)self;
+	char *errstr; /* FIXME: unused */
+	if (self->parent && self->parent->vtable->remove_child) {
+		self->parent->vtable->remove_child(
+		    self->window, self, self->parent, &errstr
+		);
+	}
 	ltk_widget *ptr;
-	if (!shallow) {
-		for (int i = 0; i < grid->rows * grid->columns; i++) {
-			if (grid->widget_grid[i]) {
-				ptr = grid->widget_grid[i];
+	for (int i = 0; i < grid->rows * grid->columns; i++) {
+		if (grid->widget_grid[i]) {
+			ptr = grid->widget_grid[i];
+			ptr->parent = NULL;
+			if (!shallow) {
 				/* required to avoid freeing a widget multiple times
 				   if row_span or column_span is not 1 */
 				for (int r = ptr->row; r < ptr->row + ptr->row_span; r++) {
@@ -183,8 +191,8 @@ ltk_grid_destroy(ltk_widget *self, int shallow) {
 	ltk_free(grid->column_weights);
 	ltk_free(grid->row_pos);
 	ltk_free(grid->column_pos);
-	ltk_remove_widget(grid->widget.id);
-	ltk_free(grid->widget.id);
+	ltk_remove_widget(self->id);
+	ltk_free(self->id);
 	ltk_free(grid);
 }
 
@@ -300,6 +308,10 @@ ltk_grid_child_size_change(ltk_widget *self, ltk_widget *widget) {
 static int
 ltk_grid_add(ltk_window *window, ltk_widget *widget, ltk_grid *grid,
     int row, int column, int row_span, int column_span, unsigned short sticky, char **errstr) {
+	if (widget->parent) {
+		*errstr = "Widget already inside a container.\n";
+		return 1;
+	}
 	if (row + row_span > grid->rows || column + column_span > grid->columns) {
 		*errstr = "Invalid row or column.\n";
 		return 1;
@@ -439,22 +451,22 @@ ltk_grid_cmd_add(
 		*errstr = "Invalid widget ID.\n";
 		return 1;
 	}
-	row         = strtonum(tokens[4], 0, grid->rows - 1, &errstr_num);
+	row         = ltk_strtonum(tokens[4], 0, grid->rows - 1, &errstr_num);
 	if (errstr_num) {
 		*errstr = "Invalid row number.\n";
 		return 1;
 	}
-	column      = strtonum(tokens[5], 0, grid->columns - 1, &errstr_num);
+	column      = ltk_strtonum(tokens[5], 0, grid->columns - 1, &errstr_num);
 	if (errstr_num) {
 		*errstr = "Invalid row number.\n";
 		return 1;
 	}
-	row_span    = strtonum(tokens[6], 1, grid->rows, &errstr_num);
+	row_span    = ltk_strtonum(tokens[6], 1, grid->rows, &errstr_num);
 	if (errstr_num) {
 		*errstr = "Invalid row span.\n";
 		return 1;
 	}
-	column_span = strtonum(tokens[7], 1, grid->columns, &errstr_num);
+	column_span = ltk_strtonum(tokens[7], 1, grid->columns, &errstr_num);
 	if (errstr_num) {
 		*errstr = "Invalid column span.\n";
 		return 1;
@@ -517,12 +529,12 @@ ltk_grid_cmd_create(
 		*errstr = "Widget ID already taken.\n";
 		return 1;
 	}
-	rows    = strtonum(tokens[3], 1, 64, &errstr_num);
+	rows    = ltk_strtonum(tokens[3], 1, 64, &errstr_num);
 	if (errstr_num) {
 		*errstr = "Invalid number of rows.\n";
 		return 1;
 	}
-	columns = strtonum(tokens[4], 1, 64, &errstr_num);
+	columns = ltk_strtonum(tokens[4], 1, 64, &errstr_num);
 	if (errstr_num) {
 		*errstr = "Invalid number of columns.\n";
 		return 1;
@@ -550,12 +562,12 @@ ltk_grid_cmd_set_row_weight(
 	}
 	grid = (ltk_grid *)ltk_get_widget(tokens[1], LTK_GRID, errstr);
 	if (!grid) return 1;
-	row    = strtonum(tokens[3], 0, grid->rows, &errstr_num);
+	row    = ltk_strtonum(tokens[3], 0, grid->rows, &errstr_num);
 	if (errstr_num) {
 		*errstr = "Invalid row number.\n";
 		return 1;
 	}
-	weight = strtonum(tokens[4], 0, 64, &errstr_num);
+	weight = ltk_strtonum(tokens[4], 0, 64, &errstr_num);
 	if (errstr_num) {
 		*errstr = "Invalid row weight.\n";
 		return 1;
@@ -582,12 +594,12 @@ ltk_grid_cmd_set_column_weight(
 	}
 	grid = (ltk_grid *)ltk_get_widget(tokens[1], LTK_GRID, errstr);
 	if (!grid) return 1;
-	column = strtonum(tokens[3], 0, grid->columns, &errstr_num);
+	column = ltk_strtonum(tokens[3], 0, grid->columns, &errstr_num);
 	if (errstr_num) {
 		*errstr = "Invalid column number.\n";
 		return 1;
 	}
-	weight = strtonum(tokens[4], 0, 64, &errstr_num);
+	weight = ltk_strtonum(tokens[4], 0, 64, &errstr_num);
 	if (errstr_num) {
 		*errstr = "Invalid column weight.\n";
 		return 1;
diff --git a/src/label.c b/src/label.c
@@ -34,6 +34,8 @@
 #include "graphics.h"
 #include "surface_cache.h"
 
+#define MAX_LABEL_PADDING 500
+
 static void ltk_label_draw(ltk_widget *self, ltk_rect clip);
 static ltk_label *ltk_label_create(ltk_window *window,
     const char *id, char *text);
@@ -57,24 +59,26 @@ static struct {
 void
 ltk_label_setup_theme_defaults(ltk_window *window) {
 	theme.pad = 5;
-	ltk_color_create(window->dpy, window->screen, window->cm,
-	    "#FFFFFF", &theme.text_color);
-	ltk_color_create(window->dpy, window->screen, window->cm,
-	    "#000000", &theme.bg_color);
+	ltk_color_create(window->dpy, window->screen, window->cm, "#FFFFFF", &theme.text_color);
+	ltk_color_create(window->dpy, window->screen, window->cm, "#000000", &theme.bg_color);
 }
 
 void
 ltk_label_ini_handler(ltk_window *window, const char *prop, const char *value) {
+	const char *errstr;
+	/* FIXME: store generic max padding somewhere for all widgets? */
 	if (strcmp(prop, "pad") == 0) {
-		theme.pad = atoi(value);
+		theme.pad = ltk_strtonum(value, 0, MAX_LABEL_PADDING, &errstr);
+		if (errstr)
+			ltk_warn("Invalid label padding '%s': %s.\n", value, errstr);
 	} else if (strcmp(prop, "text_color") == 0) {
-		ltk_color_create(window->dpy, window->screen, window->cm,
-		    value, &theme.text_color);
+		if (ltk_color_create(window->dpy, window->screen, window->cm, value, &theme.text_color))
+			ltk_warn("Error setting label text color to '%s'.\n", value);
 	} else if (strcmp(prop, "bg_color") == 0) {
-		ltk_color_create(window->dpy, window->screen, window->cm,
-		    value, &theme.bg_color);
+		if (ltk_color_create(window->dpy, window->screen, window->cm, value, &theme.bg_color))
+			ltk_warn("Error setting label background color to '%s'.\n", value);
 	} else {
-		ltk_warn("Unknown property \"%s\" for label style.\n", prop);
+		ltk_warn("Unknown property '%s' for label style.\n", prop);
 	}
 }
 
@@ -121,6 +125,12 @@ ltk_label_create(ltk_window *window, const char *id, char *text) {
 static void
 ltk_label_destroy(ltk_widget *self, int shallow) {
 	(void)shallow;
+	char *errstr;
+	if (self->parent && self->parent->vtable->remove_child) {
+		self->parent->vtable->remove_child(
+		    self->window, self, self->parent, &errstr
+		);
+	}
 	ltk_label *label = (ltk_label *)self;
 	if (!label) {
 		ltk_warn("Tried to destroy NULL label.\n");
@@ -128,8 +138,8 @@ ltk_label_destroy(ltk_widget *self, int shallow) {
 	}
 	ltk_surface_cache_release_key(self->surface_key);
 	ltk_text_line_destroy(label->tl);
-	ltk_remove_widget(label->widget.id);
-	ltk_free(label->widget.id);
+	ltk_remove_widget(self->id);
+	ltk_free(self->id);
 	ltk_free(label);
 }
 
diff --git a/src/ltk.h b/src/ltk.h
@@ -28,7 +28,8 @@
 typedef enum {
 	LTK_EVENT_RESIZE = 1 << 0,
 	LTK_EVENT_BUTTON = 1 << 1,
-	LTK_EVENT_KEY = 1 << 2
+	LTK_EVENT_KEY = 1 << 2,
+	LTK_EVENT_MENU = 1 << 3
 } ltk_event_type;
 
 typedef struct {
@@ -83,6 +84,14 @@ typedef struct ltk_window {
 	ltk_rect dirty_rect;
 	struct ltk_event_queue *first_event;
 	struct ltk_event_queue *last_event;
+	/* FIXME: generic array */
+	ltk_widget **popups;
+	size_t popups_num;
+	size_t popups_alloc;
+	/* This is a hack so ltk_window_unregister_all_popups can
+	   call hide for all popup widgets even if the hide function
+	   already calls ltk_window_unregister_popup */
+	char popups_locked;
 } ltk_window;
 
 void ltk_window_invalidate_rect(ltk_window *window, ltk_rect rect);
@@ -92,4 +101,10 @@ void ltk_window_set_active_widget(ltk_window *window, ltk_widget *widget);
 void ltk_window_set_pressed_widget(ltk_window *window, ltk_widget *widget);
 void ltk_quit(ltk_window *window);
 
+void ltk_unregister_timer(int timer_id);
+int ltk_register_timer(long first, long repeat, void (*callback)(void *), void *data);
+void ltk_window_register_popup(ltk_window *window, ltk_widget *popup);
+void ltk_window_unregister_popup(ltk_window *window, ltk_widget *popup);
+void ltk_window_unregister_all_popups(ltk_window *window);
+
 #endif
diff --git a/src/ltkd.c b/src/ltkd.c
@@ -55,6 +55,11 @@
 #include "label.h"
 #include "scrollbar.h"
 #include "box.h"
+#include "menu.h"
+#include "macros.h"
+
+#define MAX_WINDOW_BORDER_WIDTH 100
+#define MAX_FONT_SIZE 200
 
 #define MAX_SOCK_CONNS 20
 #define READ_BLK_SIZE 128
@@ -85,6 +90,18 @@ static struct ltk_sock_info {
 	struct token_list tokens;  /* current tokens */
 } sockets[MAX_SOCK_CONNS];
 
+typedef struct {
+	void (*callback)(void *);
+	void *data;
+	struct timespec repeat;
+	struct timespec remaining;
+	int id;
+} ltk_timer;
+
+static ltk_timer *timers = NULL;
+static size_t timers_num = 0;
+static size_t timers_alloc = 0;
+
 static int ltk_mainloop(ltk_window *window);
 static char *get_sock_path(char *basedir, Window id);
 static FILE *open_log(char *dir);
@@ -179,9 +196,15 @@ ltk_mainloop(ltk_window *window) {
 	maxfd = listenfd;
 
 	printf("%lu", window->xwindow);
-	/*fflush(stdout);*/
+	fflush(stdout);
 	daemonize();
 
+	/* FIXME: make time management smarter - maybe always figure out how long
+	   it will take until the next timer is due and then sleep if no other events
+	   are happening */
+	struct timespec now, elapsed, last;
+	clock_gettime(CLOCK_MONOTONIC, &last);
+
 	while (running) {
 		rfds = rallfds;
 		wfds = wallfds;
@@ -241,6 +264,29 @@ ltk_mainloop(ltk_window *window) {
 			}
 		}
 
+		clock_gettime(CLOCK_MONOTONIC, &now);
+		ltk_timespecsub(&now, &last, &elapsed);
+		/* Note: it should be safe to give the same pointer as the first and
+		   last argument, as long as ltk_timespecsub/add isn't changed incompatibly */
+		size_t i = 0;
+		while (i < timers_num) {
+			ltk_timespecsub(&timers[i].remaining, &elapsed, &timers[i].remaining);
+			if (timers[i].remaining.tv_sec < 0 ||
+			    (timers[i].remaining.tv_sec == 0 && timers[i].remaining.tv_nsec == 0)) {
+				timers[i].callback(timers[i].data);
+				if (timers[i].repeat.tv_sec == 0 && timers[i].repeat.tv_nsec == 0) {
+					/* remove timers because it has no repeat */
+					memmove(timers + i, timers + i + 1, sizeof(ltk_timer) * (timers_num - i - 1));
+				} else {
+					ltk_timespecadd(&timers[i].remaining, &timers[i].repeat, &timers[i].remaining);
+					i++;
+				}
+			} else {
+				i++;
+			}
+		}
+		last = now;
+
 		if (window->dirty_rect.w != 0 && window->dirty_rect.h != 0) {
 			ltk_redraw_window(window);
 			window->dirty_rect.w = 0;
@@ -378,6 +424,7 @@ ltk_cleanup(void) {
 	ltk_widgets_cleanup();
 	if (main_window)
 		ltk_destroy_window(main_window);
+	main_window = NULL;
 }
 
 void
@@ -481,10 +528,15 @@ ltk_redraw_window(ltk_window *window) {
 		window->rect.x, window->rect.y,
 		window->rect.w, window->rect.h
 	);
-	if (!window->root_widget) return;
-	ptr = window->root_widget;
-	if (ptr)
+	if (window->root_widget) {
+		ptr = window->root_widget;
 		ptr->vtable->draw(ptr, window->rect);
+	}
+	/* last popup is the newest one, so draw that last */
+	for (size_t i = 0; i < window->popups_num; i++) {
+		ptr = window->popups[i];
+		ptr->vtable->draw(ptr, window->rect);
+	}
 	XdbeSwapInfo swap_info;
 	swap_info.swap_window = window->xwindow;
 	swap_info.swap_action = XdbeBackground;
@@ -497,6 +549,7 @@ static void
 ltk_window_other_event(ltk_window *window, XEvent event) {
 	ltk_widget *ptr = window->root_widget;
 	if (event.type == ConfigureNotify) {
+		ltk_window_unregister_all_popups(window);
 		int w, h;
 		w = event.xconfigure.width;
 		h = event.xconfigure.height;
@@ -526,6 +579,124 @@ ltk_window_other_event(ltk_window *window, XEvent event) {
 	}
 }
 
+/* FIXME: optimize timer handling - maybe also a sort of priority queue */
+/* FIXME: JUST USE A GENERIC DYNAMIC ARRAY ALREADY!!!!! */
+void
+ltk_unregister_timer(int timer_id) {
+	for (size_t i = 0; i < timers_num; i++) {
+		if (timers[i].id == timer_id) {
+			memmove(
+			    timers + i,
+			    timers + i + 1,
+			    sizeof(ltk_timer) * (timers_num - i - 1)
+			);
+			timers_num--;
+			size_t sz = ideal_array_size(timers_alloc, timers_num);
+			if (sz != timers_alloc) {
+				timers_alloc = sz;
+				timers = ltk_reallocarray(
+				    timers, sz, sizeof(ltk_timer)
+				);
+			}
+			return;
+		}
+	}
+}
+
+/* repeat <= 0 means no repeat, first <= 0 means run as soon as possible */
+int
+ltk_register_timer(long first, long repeat, void (*callback)(void *), void *data) {
+	if (first < 0)
+		first = 0;
+	if (repeat < 0)
+		repeat = 0;
+	if (timers_num == timers_alloc) {
+		timers_alloc = ideal_array_size(timers_alloc, timers_num + 1);
+		timers = ltk_reallocarray(
+		    timers, timers_alloc, sizeof(ltk_timer)
+		);
+	}
+	/* FIXME: better finding of id */
+	/* FIXME: maybe store sorted by id */
+	int id = 0;
+	for (size_t i = 0; i < timers_num; i++) {
+		if (timers[i].id >= id)
+			id = timers[i].id + 1;
+	}
+	ltk_timer *t = &timers[timers_num++];
+	t->callback = callback;
+	t->data = data;
+	t->repeat.tv_sec = repeat / 1000;
+	t->repeat.tv_nsec = (repeat % 1000) * 1000;
+	t->remaining.tv_sec = first / 1000;
+	t->remaining.tv_nsec = (first % 1000) * 1000;
+	t->id = id;
+	return id;
+}
+
+/* FIXME: check for duplicates? */
+void
+ltk_window_register_popup(ltk_window *window, ltk_widget *popup) {
+	if (window->popups_num == window->popups_alloc) {
+		window->popups_alloc = ideal_array_size(
+		    window->popups_alloc, window->popups_num + 1
+		);
+		window->popups = ltk_reallocarray(
+		    window->popups, window->popups_alloc, sizeof(ltk_widget *)
+		);
+	}
+	window->popups[window->popups_num++] = popup;
+}
+
+void
+ltk_window_unregister_popup(ltk_window *window, ltk_widget *popup) {
+	if (window->popups_locked)
+		return;
+	for (size_t i = 0; i < window->popups_num; i++) {
+		if (window->popups[i] == popup) {
+			memmove(
+			    window->popups + i,
+			    window->popups + i + 1,
+			    sizeof(ltk_widget *) * (window->popups_num - i - 1)
+			);
+			window->popups_num--;
+			size_t sz = ideal_array_size(
+			    window->popups_alloc, window->popups_num
+			);
+			if (sz != window->popups_alloc) {
+				window->popups_alloc = sz;
+				window->popups = ltk_reallocarray(
+				    window->popups, sz, sizeof(ltk_widget *)
+				);
+			}
+			return;
+		}
+	}
+}
+
+/* FIXME: where should actual hiding happen? */
+void
+ltk_window_unregister_all_popups(ltk_window *window) {
+	window->popups_locked = 1;
+	for (size_t i = 0; i < window->popups_num; i++) {
+		if (window->popups[i]->vtable->hide) {
+			window->popups[i]->vtable->hide(window->popups[i]);
+		}
+		window->popups[i]->hidden = 1;
+	}
+	window->popups_num = 0;
+	/* somewhat arbitrary, but should be enough for most cases */
+	if (window->popups_num > 4) {
+		window->popups = ltk_reallocarray(
+		    window->popups, 4, sizeof(ltk_widget *)
+		);
+		window->popups_alloc = 4;
+	}
+	window->popups_locked = 0;
+	/* I guess just invalidate everything instead of being smart */
+	ltk_window_invalidate_rect(window, window->rect);
+}
+
 static ltk_window *
 ltk_create_window(const char *title, int x, int y, unsigned int w, unsigned int h) {
 	char *theme_path;
@@ -533,6 +704,10 @@ ltk_create_window(const char *title, int x, int y, unsigned int w, unsigned int 
 
 	ltk_window *window = ltk_malloc(sizeof(ltk_window));
 
+	window->popups = NULL;
+	window->popups_num = window->popups_alloc = 0;
+	window->popups_locked = 0;
+
 	window->dpy = XOpenDisplay(NULL);
 	window->screen = DefaultScreen(window->dpy);
 	/* based on http://wili.cc/blog/xdbe.html */
@@ -562,6 +737,9 @@ ltk_create_window(const char *title, int x, int y, unsigned int w, unsigned int 
 			ltk_fatal("Couldn't match a Visual with double buffering.\n");
 		}
 		window->vis = xvisinfo_match->visual;
+		/* FIXME: is it legal to free this while keeping the visual? */
+		XFree(xvisinfo_match);
+		XdbeFreeVisualInfo(info);
 		found = 1;
 	} else {
 		window->vis = DefaultVisual(window->dpy, window->screen);
@@ -574,6 +752,7 @@ ltk_create_window(const char *title, int x, int y, unsigned int w, unsigned int 
 	if (!theme_path)
 		ltk_fatal_errno("Not enough memory for theme path.\n");
 	ltk_load_theme(window, theme_path);
+	ltk_free(theme_path);
 	window->wm_delete_msg = XInternAtom(window->dpy, "WM_DELETE_WINDOW", False);
 
 	memset(&attrs, 0, sizeof(attrs));
@@ -641,10 +820,13 @@ ltk_create_window(const char *title, int x, int y, unsigned int w, unsigned int 
 static void
 ltk_destroy_window(ltk_window *window) {
 	ltk_text_context_destroy(window->text_context);
+	if (window->popups)
+		ltk_free(window->popups);
+	XFreeGC(window->dpy, window->gc);
 	XDestroyWindow(window->dpy, window->xwindow);
 	XCloseDisplay(window->dpy);
-	/* FIXME: This doesn't work because it can sometimes be a readonly
-	   string from ltk_window_setup_theme_defaults! */
+	ltk_surface_destroy(window->surface);
+	ltk_surface_cache_destroy(window->surface_cache);
 	if (window->theme.font)
 		ltk_free(window->theme.font);
 	ltk_free(window);
@@ -652,18 +834,23 @@ ltk_destroy_window(ltk_window *window) {
 
 void
 ltk_window_ini_handler(ltk_window *window, const char *prop, const char *value) {
+	const char *errstr;
 	if (strcmp(prop, "border_width") == 0) {
-		window->theme.border_width = atoi(value);
+		window->theme.border_width = ltk_strtonum(value, 0, MAX_WINDOW_BORDER_WIDTH, &errstr);
+		if (errstr)
+			ltk_warn("Invalid window border width '%s': %s.\n", value, errstr);
 	} else if (strcmp(prop, "bg") == 0) {
-		ltk_color_create(window->dpy, window->screen,
-		    window->cm, value, &window->theme.bg);
+		if (ltk_color_create(window->dpy, window->screen, window->cm, value, &window->theme.bg))
+			ltk_warn("Error setting window background color to '%s'.\n", value);
 	} else if (strcmp(prop, "fg") == 0) {
-		ltk_color_create(window->dpy, window->screen,
-		    window->cm, value, &window->theme.fg);
+		if (ltk_color_create(window->dpy, window->screen, window->cm, value, &window->theme.fg))
+			ltk_warn("Error setting window foreground color to '%s'.\n", value);
 	} else if (strcmp(prop, "font") == 0) {
 		window->theme.font = ltk_strdup(value);
 	} else if (strcmp(prop, "font_size") == 0) {
-		window->theme.font_size = atoi(value);
+		window->theme.font_size = ltk_strtonum(value, 0, MAX_FONT_SIZE, &errstr);
+		if (errstr)
+			ltk_warn("Invalid window font size '%s': %s.\n", value, errstr);
 	}
 }
 
@@ -677,6 +864,10 @@ ltk_ini_handler(void *window, const char *widget, const char *prop, const char *
 		ltk_label_ini_handler(window, prop, value);
 	} else if (strcmp(widget, "scrollbar") == 0) {
 		ltk_scrollbar_ini_handler(window, prop, value);
+	} else if (strcmp(widget, "menu") == 0) {
+		ltk_menu_ini_handler(window, prop, value);
+	} else if (strcmp(widget, "submenu") == 0) {
+		ltk_submenu_ini_handler(window, prop, value);
 	} else {
 		return 0;
 	}
@@ -688,10 +879,8 @@ ltk_window_setup_theme_defaults(ltk_window *window) {
 	window->theme.border_width = 0;
 	window->theme.font_size = 15;
 	window->theme.font = ltk_strdup("Liberation Mono");
-	ltk_color_create(window->dpy, window->screen,
-	    window->cm, "#000000", &window->theme.bg);
-	ltk_color_create(window->dpy, window->screen,
-	    window->cm, "#FFFFFF", &window->theme.fg);
+	ltk_color_create(window->dpy, window->screen, window->cm, "#000000", &window->theme.bg);
+	ltk_color_create(window->dpy, window->screen, window->cm, "#FFFFFF", &window->theme.fg);
 }
 
 static void
@@ -701,6 +890,7 @@ ltk_load_theme(ltk_window *window, const char *path) {
 	ltk_button_setup_theme_defaults(window);
 	ltk_label_setup_theme_defaults(window);
 	ltk_scrollbar_setup_theme_defaults(window);
+	ltk_menu_setup_theme_defaults(window);
 	if (ini_parse(path, ltk_ini_handler, window) < 0) {
 		ltk_warn("Can't load theme.\n");
 	}
@@ -741,8 +931,18 @@ ltk_window_set_pressed_widget(ltk_window *window, ltk_widget *widget) {
 	}
 }
 
+static ltk_widget *
+get_hover_popup(ltk_window *window, int x, int y) {
+	for (size_t i = window->popups_num; i-- > 0;) {
+		if (ltk_collide_rect(window->popups[i]->rect, x, y))
+			return window->popups[i];
+	}
+	return NULL;
+}
+
 static void
 ltk_handle_event(ltk_window *window, XEvent event) {
+	ltk_widget *hover_popup;
 	ltk_widget *root_widget = window->root_widget;
 	switch (event.type) {
 	case KeyPress:
@@ -750,15 +950,26 @@ ltk_handle_event(ltk_window *window, XEvent event) {
 	case KeyRelease:
 		break;
 	case ButtonPress:
-		if (root_widget)
+		hover_popup = get_hover_popup(window, event.xbutton.x, event.xbutton.y);
+		if (hover_popup) {
+			ltk_widget_mouse_press_event(hover_popup, event);
+		} else if (root_widget) {
+			ltk_window_unregister_all_popups(window);
 			ltk_widget_mouse_press_event(root_widget, event);
+		}
 		break;
 	case ButtonRelease:
-		if (root_widget)
+		hover_popup = get_hover_popup(window, event.xbutton.x, event.xbutton.y);
+		if (hover_popup)
+			ltk_widget_mouse_release_event(hover_popup, event);
+		else if (root_widget)
 			ltk_widget_mouse_release_event(root_widget, event);
 		break;
 	case MotionNotify:
-		if (root_widget)
+		hover_popup = get_hover_popup(window, event.xmotion.x, event.xmotion.y);
+		if (hover_popup)
+			ltk_widget_motion_notify_event(hover_popup, event);
+		else if (root_widget)
 			ltk_widget_motion_notify_event(root_widget, event);
 		break;
 	default:
@@ -1015,6 +1226,10 @@ process_commands(ltk_window *window, struct ltk_sock_info *sock) {
 			err = ltk_button_cmd(window, tokens, num_tokens, &errstr);
 		} else if (strcmp(tokens[0], "label") == 0) {
 			err = ltk_label_cmd(window, tokens, num_tokens, &errstr);
+		} else if (strcmp(tokens[0], "menu") == 0) {
+			err = ltk_menu_cmd(window, tokens, num_tokens, &errstr);
+		} else if (strcmp(tokens[0], "submenu") == 0) {
+			err = ltk_menu_cmd(window, tokens, num_tokens, &errstr);
 		} else if (strcmp(tokens[0], "set-root-widget") == 0) {
 			err = ltk_set_root_widget_cmd(window, tokens, num_tokens, &errstr);
 /*
@@ -1024,7 +1239,7 @@ process_commands(ltk_window *window, struct ltk_sock_info *sock) {
 		} else if (strcmp(tokens[0], "quit") == 0) {
 			ltk_quit(window);
 		} else if (strcmp(tokens[0], "destroy") == 0) {
-			err = ltk_widget_destroy(window, tokens, num_tokens, &errstr);
+			err = ltk_widget_destroy_cmd(window, tokens, num_tokens, &errstr);
 		} else {
 			errstr = "Invalid command.\n";
 			err = 1;
diff --git a/src/macros.h b/src/macros.h
@@ -0,0 +1,25 @@
+#ifndef _MACROS_H_
+#define _MACROS_H_
+
+/* stolen from OpenBSD */
+#define ltk_timespecadd(tsp, usp, vsp)                                  \
+	do {                                                            \
+		(vsp)->tv_sec = (tsp)->tv_sec + (usp)->tv_sec;          \
+		(vsp)->tv_nsec = (tsp)->tv_nsec + (usp)->tv_nsec;       \
+		if ((vsp)->tv_nsec >= 1000000000L) {                    \
+			(vsp)->tv_sec++;                                \
+			(vsp)->tv_nsec -= 1000000000L;                  \
+		}                                                       \
+	} while (0)
+
+#define ltk_timespecsub(tsp, usp, vsp)                                  \
+	do {                                                            \
+		(vsp)->tv_sec = (tsp)->tv_sec - (usp)->tv_sec;          \
+		(vsp)->tv_nsec = (tsp)->tv_nsec - (usp)->tv_nsec;       \
+		if ((vsp)->tv_nsec < 0) {                               \
+			(vsp)->tv_sec--;                                \
+			(vsp)->tv_nsec += 1000000000L;                  \
+		}                                                       \
+	} while (0)
+
+#endif
diff --git a/src/memory.c b/src/memory.c
@@ -129,6 +129,9 @@ ltk_reallocarray(void *optr, size_t nmemb, size_t size)
 size_t
 ideal_array_size(size_t old, size_t needed) {
 	size_t ret = old;
+	/* FIXME: the shrinking here only makes sense if not
+	   many elements are removed at once - what would be
+	   more sensible here? */
 	if (old < needed)
 		ret = old * 2 > needed ? old * 2 : needed;
 	else if (needed * 4 < old)
diff --git a/src/menu.c b/src/menu.c
@@ -0,0 +1,1772 @@
+/*
+ * Copyright (c) 2022 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 <stdlib.h>
+#include <stdint.h>
+#include <string.h>
+#include <stdarg.h>
+#include <math.h>
+
+#include <X11/Xlib.h>
+#include <X11/Xutil.h>
+
+#include "memory.h"
+#include "color.h"
+#include "rect.h"
+#include "widget.h"
+#include "ltk.h"
+#include "util.h"
+#include "text.h"
+#include "menu.h"
+#include "graphics.h"
+#include "surface_cache.h"
+
+#define MAX_MENU_BORDER_WIDTH 100
+#define MAX_MENU_PAD 500
+#define MAX_MENU_ARROW_SIZE 100
+
+#define MAX(a, b) ((a) > (b) ? (a) : (b))
+
+static struct theme {
+	int border_width;
+	int pad;
+	int text_pad;
+	int arrow_size;
+	int arrow_pad;
+	int compress_borders;
+	int menu_border_width;
+	/* FIXME: should border_sides actually factor into
+	   size calculation? - probably useless and would
+	   just make it more complicated */
+	/* FIXME: allow different values for different states? */
+	ltk_border_sides border_sides;
+
+	ltk_color background;
+	ltk_color scroll_background;
+	ltk_color scroll_arrow_color;
+	ltk_color menu_border;
+
+	ltk_color text;
+	ltk_color border;
+	ltk_color fill;
+
+	ltk_color text_pressed;
+	ltk_color border_pressed;
+	ltk_color fill_pressed;
+
+	ltk_color text_active;
+	ltk_color border_active;
+	ltk_color fill_active;
+
+	ltk_color text_disabled;
+	ltk_color border_disabled;
+	ltk_color fill_disabled;
+} menu_theme, submenu_theme;
+
+static void ini_handler(ltk_window *window, struct theme *t, const char *prop, const char *value);
+static void ltk_menu_resize(ltk_widget *self);
+static void ltk_menu_change_state(ltk_widget *self);
+static void ltk_menu_draw(ltk_widget *self, ltk_rect clip);
+static void ltk_menu_redraw_surface(ltk_menu *menu, ltk_surface *s);
+static void ltk_menu_get_max_scroll_offset(ltk_menu *menu, int *x_ret, int *y_ret);
+static void ltk_menu_scroll(ltk_menu *menu, char t, char b, char l, char r, int step);
+static void ltk_menu_scroll_callback(void *data);
+static void stop_scrolling(ltk_menu *menu);
+static size_t get_entry_at_point(ltk_menu *menu, int x, int y, ltk_rect *entry_rect_ret);
+static int set_scroll_timer(ltk_menu *menu, int x, int y);
+static int ltk_menu_mouse_release(ltk_widget *self, XEvent event);
+static int ltk_menu_mouse_press(ltk_widget *self, XEvent event);
+static void ltk_menu_hide(ltk_widget *self);
+static void popup_active_menu(ltk_menu *menu, ltk_rect r);
+static void unpopup_active_entry(ltk_menu *menu);
+static void handle_hover(ltk_menu *menu, int x, int y);
+static int ltk_menu_motion_notify(ltk_widget *self, XEvent event);
+static int ltk_menu_mouse_enter(ltk_widget *self, XEvent event);
+static int ltk_menu_mouse_leave(ltk_widget *self, XEvent event);
+static ltk_menu *ltk_menu_create(ltk_window *window, const char *id, int is_submenu);
+static ltk_menuentry *insert_entry(ltk_menu *menu, size_t idx);
+static void recalc_menu_size(ltk_menu *menu);
+static void shrink_entries(ltk_menu *menu);
+static size_t get_entry_with_id(ltk_menu *menu, const char *id);
+static void ltk_menu_destroy(ltk_widget *self, int shallow);
+
+static ltk_menuentry *ltk_menu_insert_entry(ltk_menu *menu, const char *id, const char *text, ltk_menu *submenu, size_t idx, char **errstr);
+static ltk_menuentry *ltk_menu_add_entry(ltk_menu *menu, const char *id, const char *text, ltk_menu *submenu, char **errstr);
+static ltk_menuentry *ltk_menu_insert_submenu(ltk_menu *menu, const char *id, const char *text, ltk_menu *submenu, size_t idx, char **errstr);
+static ltk_menuentry *ltk_menu_add_submenu(ltk_menu *menu, const char *id, const char *text, ltk_menu *submenu, char **errstr);
+static int ltk_menu_remove_entry_index(ltk_menu *menu, size_t idx, int shallow, char **errstr);
+static int ltk_menu_remove_entry_id(ltk_menu *menu, const char *id, int shallow, char **errstr);
+static int ltk_menu_remove_all_entries(ltk_menu *menu, int shallow, char **errstr);
+static int ltk_menu_detach_submenu_from_entry_id(ltk_menu *menu, const char *id, char **errstr);
+static int ltk_menu_detach_submenu_from_entry_index(ltk_menu *menu, size_t idx, char **errstr);
+static int ltk_menu_disable_entry_index(ltk_menu *menu, size_t idx, char **errstr);
+static int ltk_menu_disable_entry_id(ltk_menu *menu, const char *id, char **errstr);
+static int ltk_menu_disable_all_entries(ltk_menu *menu, char **errstr);
+static int ltk_menu_enable_entry_index(ltk_menu *menu, size_t idx, char **errstr);
+static int ltk_menu_enable_entry_id(ltk_menu *menu, const char *id, char **errstr);
+static int ltk_menu_enable_all_entries(ltk_menu *menu, char **errstr);
+
+static struct ltk_widget_vtable vtable = {
+    .mouse_press = <k_menu_mouse_press,
+    .motion_notify = <k_menu_motion_notify,
+    .mouse_release = <k_menu_mouse_release,
+    .mouse_enter = <k_menu_mouse_enter,
+    .mouse_leave = <k_menu_mouse_leave,
+    .resize = <k_menu_resize,
+    .change_state = <k_menu_change_state,
+    .hide = <k_menu_hide,
+    .draw = <k_menu_draw,
+    .destroy = <k_menu_destroy,
+    .type = LTK_MENU,
+    .needs_redraw = 1,
+    .needs_surface = 1
+};
+
+/* FIXME: maybe just store colors as pointers and check after
+   ini handling if any are null */
+
+void
+ltk_menu_setup_theme_defaults(ltk_window *window) {
+	menu_theme.border_width = 2;
+	menu_theme.pad = 0;
+	menu_theme.text_pad = 5;
+	menu_theme.arrow_size = 10;
+	menu_theme.arrow_pad = 5;
+	menu_theme.compress_borders = 1;
+	menu_theme.border_sides = LTK_BORDER_ALL;
+	menu_theme.menu_border_width = 0;
+	ltk_color_create(window->dpy, window->screen, window->cm, "#555555", &menu_theme.background);
+	ltk_color_create(window->dpy, window->screen, window->cm, "#333333", &menu_theme.scroll_background);
+	ltk_color_create(window->dpy, window->screen, window->cm, "#000000", &menu_theme.scroll_arrow_color);
+	ltk_color_create(window->dpy, window->screen, window->cm, "#FFFFFF", &menu_theme.text);
+	ltk_color_create(window->dpy, window->screen, window->cm, "#339999", &menu_theme.border);
+	ltk_color_create(window->dpy, window->screen, window->cm, "#113355", &menu_theme.fill);
+	ltk_color_create(window->dpy, window->screen, window->cm, "#FFFFFF", &menu_theme.text_pressed);
+	ltk_color_create(window->dpy, window->screen, window->cm, "#FFFFFF", &menu_theme.border_pressed);
+	ltk_color_create(window->dpy, window->screen, window->cm, "#113355", &menu_theme.fill_pressed);
+	ltk_color_create(window->dpy, window->screen, window->cm, "#FFFFFF", &menu_theme.text_active);
+	ltk_color_create(window->dpy, window->screen, window->cm, "#FFFFFF", &menu_theme.border_active);
+	ltk_color_create(window->dpy, window->screen, window->cm, "#738194", &menu_theme.fill_active);
+	ltk_color_create(window->dpy, window->screen, window->cm, "#FFFFFF", &menu_theme.text_disabled);
+	ltk_color_create(window->dpy, window->screen, window->cm, "#FFFFFF", &menu_theme.border_disabled);
+	ltk_color_create(window->dpy, window->screen, window->cm, "#292929", &menu_theme.fill_disabled);
+
+	/* FIXME: actually unnecessary since border width is 0 */
+	ltk_color_create(window->dpy, window->screen, window->cm, "#000000", &menu_theme.menu_border);
+
+	submenu_theme.border_width = 0;
+	submenu_theme.pad = 5;
+	submenu_theme.text_pad = 5;
+	submenu_theme.arrow_size = 10;
+	submenu_theme.arrow_pad = 5;
+	submenu_theme.compress_borders = 0;
+	submenu_theme.border_sides = LTK_BORDER_NONE;
+	submenu_theme.menu_border_width = 1;
+	ltk_color_create(window->dpy, window->screen, window->cm, "#555555", &submenu_theme.background);
+	ltk_color_create(window->dpy, window->screen, window->cm, "#333333", &submenu_theme.scroll_background);
+	ltk_color_create(window->dpy, window->screen, window->cm, "#000000", &submenu_theme.scroll_arrow_color);
+	ltk_color_create(window->dpy, window->screen, window->cm, "#FFFFFF", &submenu_theme.menu_border);
+	ltk_color_create(window->dpy, window->screen, window->cm, "#FFFFFF", &submenu_theme.text);
+	ltk_color_create(window->dpy, window->screen, window->cm, "#113355", &submenu_theme.fill);
+	ltk_color_create(window->dpy, window->screen, window->cm, "#000000", &submenu_theme.text_pressed);
+	ltk_color_create(window->dpy, window->screen, window->cm, "#113355", &submenu_theme.fill_pressed);
+	ltk_color_create(window->dpy, window->screen, window->cm, "#000000", &submenu_theme.text_active);
+	ltk_color_create(window->dpy, window->screen, window->cm, "#113355", &submenu_theme.fill_active);
+	ltk_color_create(window->dpy, window->screen, window->cm, "#FFFFFF", &submenu_theme.text_disabled);
+	ltk_color_create(window->dpy, window->screen, window->cm, "#292929", &submenu_theme.fill_disabled);
+
+	/* FIXME: make this unnecessary if border width is 0 */
+	ltk_color_create(window->dpy, window->screen, window->cm, "#FFFFFF", &submenu_theme.border);
+	ltk_color_create(window->dpy, window->screen, window->cm, "#FFFFFF", &submenu_theme.border_pressed);
+	ltk_color_create(window->dpy, window->screen, window->cm, "#FFFFFF", &submenu_theme.border_active);
+	ltk_color_create(window->dpy, window->screen, window->cm, "#FFFFFF", &submenu_theme.border_disabled);
+}
+
+/* FIXME: use border-width, etc. */
+/* FIXME: make theme parsing more convenient */
+/* FIXME: DEALLOCATE COLORS INSTEAD OF OVERWRITING DEFAULTS! */
+static void
+ini_handler(ltk_window *window, struct theme *t, const char *prop, const char *value) {
+	const char *errstr;
+	if (strcmp(prop, "border_width") == 0) {
+		t->border_width = ltk_strtonum(value, 0, MAX_MENU_BORDER_WIDTH, &errstr);
+		if (errstr)
+			ltk_warn("Invalid menu border width '%s': %s.\n", value, errstr);
+	} else if (strcmp(prop, "menu_border_width") == 0) {
+		t->menu_border_width = ltk_strtonum(value, 0, MAX_MENU_BORDER_WIDTH, &errstr);
+		/* FIXME: clarify different types of border width in error message */
+		if (errstr)
+			ltk_warn("Invalid menu border width '%s': %s.\n", value, errstr);
+	} else if (strcmp(prop, "pad") == 0) {
+		t->pad = ltk_strtonum(value, 0, MAX_MENU_PAD, &errstr);
+		if (errstr)
+			ltk_warn("Invalid menu pad '%s': %s.\n", value, errstr);
+	} else if (strcmp(prop, "text_pad") == 0) {
+		t->text_pad = ltk_strtonum(value, 0, MAX_MENU_PAD, &errstr);
+		if (errstr)
+			ltk_warn("Invalid menu text pad '%s': %s.\n", value, errstr);
+	} else if (strcmp(prop, "arrow_size") == 0) {
+		/* FIXME: should be error when used for menu instead of submenu */
+		t->arrow_size = ltk_strtonum(value, 0, MAX_MENU_ARROW_SIZE, &errstr);
+		if (errstr)
+			ltk_warn("Invalid menu arrow size '%s': %s.\n", value, errstr);
+	} else if (strcmp(prop, "arrow_pad") == 0) {
+		/* FIXME: should be error when used for menu instead of submenu */
+		t->arrow_pad = ltk_strtonum(value, 0, MAX_MENU_PAD, &errstr);
+		if (errstr)
+			ltk_warn("Invalid menu arrow pad '%s': %s.\n", value, errstr);
+	} else if (strcmp(prop, "compress_borders") == 0) {
+		if (strcmp(value, "true") == 0)
+			t->compress_borders = 1;
+		else if (strcmp(value, "false") == 0)
+			t->compress_borders = 0;
+		else
+			ltk_warn("Invalid menu compress_borders '%s'.\n", value);
+	} else if (strcmp(prop, "border_sides") == 0) {
+		t->border_sides = LTK_BORDER_NONE;
+		for (const char *c = value; *c != '\0'; c++) {
+			switch (*c) {
+			case 't':
+				t->border_sides |= LTK_BORDER_TOP;
+				break;
+			case 'b':
+				t->border_sides |= LTK_BORDER_BOTTOM;
+				break;
+			case 'l':
+				t->border_sides |= LTK_BORDER_LEFT;
+				break;
+			case 'r':
+				t->border_sides |= LTK_BORDER_RIGHT;
+				break;
+			default:
+				ltk_warn("Invalid menu border_sides '%s'.\n", value);
+				return;
+			}
+		}
+	} else if (strcmp(prop, "background") == 0) {
+		if (ltk_color_create(window->dpy, window->screen, window->cm, value, &t->background))
+			ltk_warn("Error setting menu background color to '%s'.\n", value);
+	} else if (strcmp(prop, "menu_border") == 0) {
+		/* FIXME: clarify different type of menu border color */
+		if (ltk_color_create(window->dpy, window->screen, window->cm, value, &t->menu_border))
+			ltk_warn("Error setting menu border color to '%s'.\n", value);
+	} else if (strcmp(prop, "scroll_background") == 0) {
+		if (ltk_color_create(window->dpy, window->screen, window->cm, value, &t->scroll_background))
+			ltk_warn("Error setting menu scroll background color to '%s'.\n", value);
+	} else if (strcmp(prop, "scroll_arrow_color") == 0) {
+		if (ltk_color_create(window->dpy, window->screen, window->cm, value, &t->scroll_arrow_color))
+			ltk_warn("Error setting menu scroll arrow color to '%s'.\n", value);
+	} else if (strcmp(prop, "text") == 0) {
+		if (ltk_color_create(window->dpy, window->screen, window->cm, value, &t->text))
+			ltk_warn("Error setting menu text color to '%s'.\n", value);
+	} else if (strcmp(prop, "border") == 0) {
+		if (ltk_color_create(window->dpy, window->screen, window->cm, value, &t->border))
+			ltk_warn("Error setting menu border color to '%s'.\n", value);
+	} else if (strcmp(prop, "fill") == 0) {
+		if (ltk_color_create(window->dpy, window->screen, window->cm, value, &t->fill))
+			ltk_warn("Error setting menu fill color to '%s'.\n", value);
+	} else if (strcmp(prop, "text_pressed") == 0) {
+		if (ltk_color_create(window->dpy, window->screen, window->cm, value, &t->text_pressed))
+			ltk_warn("Error setting menu pressed text color to '%s'.\n", value);
+	} else if (strcmp(prop, "border_pressed") == 0) {
+		if (ltk_color_create(window->dpy, window->screen, window->cm, value, &t->border_pressed))
+			ltk_warn("Error setting menu pressed border color to '%s'.\n", value);
+	} else if (strcmp(prop, "fill_pressed") == 0) {
+		if (ltk_color_create(window->dpy, window->screen, window->cm, value, &t->fill_pressed))
+			ltk_warn("Error setting menu pressed fill color to '%s'.\n", value);
+	} else if (strcmp(prop, "text_active") == 0) {
+		if (ltk_color_create(window->dpy, window->screen, window->cm, value, &t->text_active))
+			ltk_warn("Error setting menu active text color to '%s'.\n", value);
+	} else if (strcmp(prop, "border_active") == 0) {
+		if (ltk_color_create(window->dpy, window->screen, window->cm, value, &t->border_active))
+			ltk_warn("Error setting menu active border color to '%s'.\n", value);
+	} else if (strcmp(prop, "fill_active") == 0) {
+		if (ltk_color_create(window->dpy, window->screen, window->cm, value, &t->fill_active))
+			ltk_warn("Error setting menu active fill color to '%s'.\n", value);
+	} else if (strcmp(prop, "text_disabled") == 0) {
+		if (ltk_color_create(window->dpy, window->screen, window->cm, value, &t->text_disabled))
+			ltk_warn("Error setting menu disabled text color to '%s'.\n", value);
+	} else if (strcmp(prop, "border_disabled") == 0) {
+		if (ltk_color_create(window->dpy, window->screen, window->cm, value, &t->border_disabled))
+			ltk_warn("Error setting menu disabled border color to '%s'.\n", value);
+	} else if (strcmp(prop, "fill_disabled") == 0) {
+		if (ltk_color_create(window->dpy, window->screen, window->cm, value, &t->fill_disabled))
+			ltk_warn("Error setting menu disabled fill color to '%s'.\n", value);
+	} else {
+		ltk_warn("Unknown property '%s' for button style.\n", prop);
+	}
+}
+
+void
+ltk_menu_ini_handler(ltk_window *window, const char *prop, const char *value) {
+	ini_handler(window, &menu_theme, prop, value);
+}
+
+void
+ltk_submenu_ini_handler(ltk_window *window, const char *prop, const char *value) {
+	ini_handler(window, &submenu_theme, prop, value);
+}
+
+static void
+ltk_menu_resize(ltk_widget *self) {
+	ltk_menu *menu = (ltk_menu *)self;
+	double x_old = menu->x_scroll_offset;
+	double y_old = menu->y_scroll_offset;
+	int max_x, max_y;
+	ltk_menu_get_max_scroll_offset(menu, &max_x, &max_y);
+	if (menu->x_scroll_offset > max_x)
+		menu->x_scroll_offset = max_x;
+	if (menu->y_scroll_offset > max_y)
+		menu->y_scroll_offset = max_y;
+	if (fabs(x_old - menu->x_scroll_offset) < 0.01 ||
+	    fabs(y_old - menu->y_scroll_offset) < 0.01) {
+		menu->widget.dirty = 1;
+		ltk_window_invalidate_rect(menu->widget.window, menu->widget.rect);
+	}
+}
+
+static void
+ltk_menu_change_state(ltk_widget *self) {
+	ltk_menu *menu = (ltk_menu *)self;
+	if (self->state != LTK_PRESSED && menu->pressed_entry < menu->num_entries) {
+		menu->pressed_entry = SIZE_MAX;
+		self->dirty = 1;
+		ltk_window_invalidate_rect(self->window, self->rect);
+	}
+}
+
+static void
+ltk_menu_draw(ltk_widget *self, ltk_rect clip) {
+	if (self->hidden)
+		return;
+	ltk_menu *menu = (ltk_menu *)self;
+	ltk_rect rect = self->rect;
+	ltk_rect clip_final = ltk_rect_intersect(clip, rect);
+	ltk_surface *s;
+	if (!ltk_surface_cache_get_surface(self->surface_key, &s) || self->dirty)
+		ltk_menu_redraw_surface(menu, s);
+	ltk_surface_copy(s, self->window->surface, ltk_rect_relative(rect, clip_final), clip_final.x, clip_final.y);
+}
+
+/* FIXME: glitches when drawing text with stb backend while scrolling */
+static void
+ltk_menu_redraw_surface(ltk_menu *menu, ltk_surface *s) {
+	ltk_rect rect = menu->widget.rect;
+	int ideal_w = menu->widget.ideal_w, ideal_h = menu->widget.ideal_h;
+	struct theme *t = menu->is_submenu ? &submenu_theme : &menu_theme;
+
+	int arrow_size = t->arrow_pad * 2 + t->arrow_size;
+	int start_x = rect.w < ideal_w ? arrow_size : 0;
+	int start_y = rect.h < ideal_h ? arrow_size : 0;
+	start_x += t->menu_border_width;
+	start_y += t->menu_border_width;
+	int real_w = rect.w - start_x * 2;
+	int real_h = rect.h - start_y * 2;
+
+	int offset_x = (int)menu->x_scroll_offset;
+	int offset_y = (int)menu->y_scroll_offset;
+
+	ltk_surface_fill_rect(s, &t->background, (ltk_rect){0, 0, rect.w, rect.h});
+	int text_w, text_h;
+	ltk_color *text, *border, *fill;
+	int cur_abs_x = 0, cur_abs_y = 0;
+	if (menu->is_submenu)
+		cur_abs_y = t->pad;
+	else
+		cur_abs_x = t->pad;
+	int overlap = t->compress_borders ? t->border_width - t->pad : 0;
+	int bw_advance = t->compress_borders ? t->border_width : t->border_width * 2;
+	int mbw = t->menu_border_width;
+	for (size_t i = 0; i < menu->num_entries; i++) {
+		ltk_menuentry *e = &menu->entries[i];
+		ltk_text_line_get_size(e->text, &text_w, &text_h);
+		if (menu->is_submenu) {
+			if (cur_abs_y + t->border_width * 2 + t->text_pad * 2 + text_h <= offset_y) {
+				/* FIXME: ugly because repeated further down */
+				cur_abs_y += bw_advance + t->text_pad * 2 + text_h + t->pad;
+				continue;
+			} else if (cur_abs_y >= offset_y + real_h) {
+				break;
+			}
+		} else {
+			if (cur_abs_x + t->border_width * 2 + t->text_pad * 2 + text_w <= offset_x) {
+				cur_abs_x += bw_advance + t->text_pad * 2 + text_w + t->pad;
+				continue;
+			} else if (cur_abs_x >= offset_x + real_w) {
+				break;
+			}
+		}
+		/* FIXME: allow different border_sides for different states */
+		if (e->disabled) {
+			text = &t->text_disabled;
+			border = &t->border_disabled;
+			fill = &t->fill_disabled;
+		} else if (menu->pressed_entry == i) {
+			text = &t->text_pressed;
+			border = &t->border_pressed;
+			fill = &t->fill_pressed;
+		} else if (menu->active_entry == i) {
+			text = &t->text_active;
+			border = &t->border_active;
+			fill = &t->fill_active;
+		} else {
+			text = &t->text;
+			border = &t->border;
+			fill = &t->fill;
+		}
+		/* FIXME: how well-defined is it to give X drawing commands
+		   with parts outside of the actual pixmap? */
+		/* FIXME: optimize drawing (avoid drawing pixels multiple times) */
+		int draw_x = cur_abs_x - offset_x + start_x;
+		int draw_y = cur_abs_y - offset_y + start_y;
+		int last_special = i > 0 && (menu->active_entry == i - 1 || menu->pressed_entry == i - 1);
+		if (menu->is_submenu) {
+			int extra_size = e->submenu ? t->arrow_pad * 2 + t->arrow_size : 0;
+			int height = MAX(text_h + t->text_pad * 2, extra_size) + t->border_width * 2;
+			ltk_rect r;
+			if (last_special && overlap > 0) {
+				r = (ltk_rect){
+				    draw_x + overlap,
+				    draw_y + t->pad, /* t->pad is the same as t->border_width - overlap */
+				    ideal_w - t->pad * 2 - mbw * 2,
+				    height - overlap
+				};
+			} else {
+				r = (ltk_rect){draw_x + t->pad, draw_y, ideal_w - t->pad * 2, height};
+			}
+			ltk_surface_fill_rect(s, fill, r);
+			ltk_text_line_draw(
+			    e->text, s, text,
+			    draw_x + t->pad + t->border_width + t->text_pad,
+			    draw_y + height / 2 - text_h / 2
+			);
+			if (e->submenu) {
+				ltk_point arrow_points[3] = {
+				    {draw_x + ideal_w - t->pad - t->arrow_pad, draw_y + height / 2},
+				    {draw_x + ideal_w - t->pad - t->arrow_pad - t->arrow_size, draw_y + height / 2 - t->arrow_size / 2},
+				    {draw_x + ideal_w - t->pad - t->arrow_pad - t->arrow_size, draw_y + height / 2 + t->arrow_size / 2}
+				};
+				ltk_surface_fill_polygon(s, text, arrow_points, 3);
+			}
+			if (last_special && overlap > 0) {
+				ltk_surface_draw_border(s, border, r, t->border_width, t->border_sides & ~LTK_BORDER_TOP);
+				if (t->border_sides & LTK_BORDER_TOP)
+					ltk_surface_draw_border(s, border, r, t->pad, LTK_BORDER_TOP);
+			} else {
+				ltk_surface_draw_border(s, border, r, t->border_width, t->border_sides);
+			}
+			cur_abs_y += bw_advance + t->text_pad * 2 + text_h + t->pad;
+		} else {
+			ltk_rect r;
+			if (last_special && overlap > 0) {
+				r = (ltk_rect){
+				    draw_x + overlap,
+				    draw_y + t->pad,
+				    t->text_pad * 2 + t->border_width * 2 - overlap + text_w,
+				    ideal_h - t->pad * 2 - mbw * 2
+				};
+			} else {
+				r = (ltk_rect){draw_x, draw_y + t->pad, t->text_pad * 2 + t->border_width * 2 + text_w, ideal_h - t->pad * 2};
+			}
+			ltk_surface_fill_rect(s, fill, r);
+			/* FIXME: should the text be bottom-aligned in case different
+			   entries have different text height? */
+			ltk_text_line_draw(
+			    e->text, s, text,
+			    draw_x + t->border_width + t->text_pad,
+			    draw_y + t->pad + t->border_width + t->text_pad
+			);
+			if (last_special && overlap > 0) {
+				ltk_surface_draw_border(s, border, r, t->border_width, t->border_sides & ~LTK_BORDER_LEFT);
+				if (t->border_sides & LTK_BORDER_LEFT)
+					ltk_surface_draw_border(s, border, r, t->pad, LTK_BORDER_LEFT);
+			} else {
+				ltk_surface_draw_border(s, border, r, t->border_width, t->border_sides);
+			}
+			cur_abs_x += bw_advance + t->text_pad * 2 + text_w + t->pad;
+		}
+	}
+	/* FIXME: active, pressed states */
+	int sz = t->arrow_size + t->arrow_pad * 2;
+	int ww = menu->widget.rect.w;
+	int wh = menu->widget.rect.h;
+	if (rect.w < ideal_w) {
+		ltk_surface_fill_rect(s, &t->scroll_background, (ltk_rect){mbw, mbw, sz, wh - mbw * 2});
+		ltk_surface_fill_rect(s, &t->scroll_background, (ltk_rect){ww - sz - mbw, mbw, sz, wh - mbw * 2});
+		ltk_point arrow_points[3] = {
+		    {t->arrow_pad + mbw, wh / 2},
+		    {t->arrow_pad + mbw + t->arrow_size, wh / 2 - t->arrow_size / 2},
+		    {t->arrow_pad + mbw + t->arrow_size, wh / 2 + t->arrow_size / 2}
+		};
+		ltk_surface_fill_polygon(s, &t->scroll_arrow_color, arrow_points, 3);
+		arrow_points[0] = (ltk_point){ww - t->arrow_pad - mbw, wh / 2};
+		arrow_points[1] = (ltk_point){ww - t->arrow_pad - mbw - t->arrow_size, wh / 2 - t->arrow_size / 2};
+		arrow_points[2] = (ltk_point){ww - t->arrow_pad - mbw - t->arrow_size, wh / 2 + t->arrow_size / 2};
+		ltk_surface_fill_polygon(s, &t->scroll_arrow_color, arrow_points, 3);
+	}
+	if (rect.h < ideal_h) {
+		ltk_surface_fill_rect(s, &t->scroll_background, (ltk_rect){mbw, mbw, ww - mbw * 2, sz});
+		ltk_surface_fill_rect(s, &t->scroll_background, (ltk_rect){mbw, wh - sz - mbw, ww - mbw * 2, sz});
+		ltk_point arrow_points[3] = {
+		    {ww / 2, t->arrow_pad + mbw},
+		    {ww / 2 - t->arrow_size / 2, t->arrow_pad + mbw + t->arrow_size},
+		    {ww / 2 + t->arrow_size / 2, t->arrow_pad + mbw + t->arrow_size}
+		};
+		ltk_surface_fill_polygon(s, &t->scroll_arrow_color, arrow_points, 3);
+		arrow_points[0] = (ltk_point){ww / 2, wh - t->arrow_pad - mbw};
+		arrow_points[1] = (ltk_point){ww / 2 - t->arrow_size / 2, wh - t->arrow_pad - mbw - t->arrow_size};
+		arrow_points[2] = (ltk_point){ww / 2 + t->arrow_size / 2, wh - t->arrow_pad - mbw - t->arrow_size};
+		ltk_surface_fill_polygon(s, &t->scroll_arrow_color, arrow_points, 3);
+	}
+	ltk_surface_draw_border(s, &t->menu_border, (ltk_rect){0, 0, ww, wh}, mbw, LTK_BORDER_ALL);
+
+	menu->widget.dirty = 0;
+}
+
+static void
+ltk_menu_get_max_scroll_offset(ltk_menu *menu, int *x_ret, int *y_ret) {
+	struct theme *theme = menu->is_submenu ? &submenu_theme : &menu_theme;
+	int extra_size = theme->arrow_size * 2 + theme->arrow_pad * 4;
+	*x_ret = 0;
+	*y_ret = 0;
+	if (menu->widget.rect.w < (int)menu->widget.ideal_w) {
+		*x_ret = menu->widget.ideal_w - (menu->widget.rect.w - extra_size);
+	}
+	if (menu->widget.rect.h < (int)menu->widget.ideal_h) {
+		*y_ret = menu->widget.ideal_h - (menu->widget.rect.h - extra_size);
+	}
+}
+
+static void
+ltk_menu_scroll(ltk_menu *menu, char t, char b, char l, char r, int step) {
+	int max_scroll_x, max_scroll_y;
+	ltk_menu_get_max_scroll_offset(menu, &max_scroll_x, &max_scroll_y);
+	double y_old = menu->y_scroll_offset;
+	double x_old = menu->x_scroll_offset;
+	if (t)
+		menu->y_scroll_offset -= step;
+	else if (b)
+		menu->y_scroll_offset += step;
+	else if (l)
+		menu->x_scroll_offset -= step;
+	else if (r)
+		menu->x_scroll_offset += step;
+	if (menu->x_scroll_offset < 0)
+		menu->x_scroll_offset = 0;
+	if (menu->y_scroll_offset < 0)
+		menu->y_scroll_offset = 0;
+	if (menu->x_scroll_offset > max_scroll_x)
+		menu->x_scroll_offset = max_scroll_x;
+	if (menu->y_scroll_offset > max_scroll_y)
+		menu->y_scroll_offset = max_scroll_y;
+	/* FIXME: sensible epsilon? */
+	if (fabs(x_old - menu->x_scroll_offset) < 0.01 ||
+	    fabs(y_old - menu->y_scroll_offset) < 0.01) {
+		menu->widget.dirty = 1;
+		ltk_window_invalidate_rect(menu->widget.window, menu->widget.rect);
+	}
+}
+
+/* FIXME: show scroll arrow disabled when nothing further */
+static void
+ltk_menu_scroll_callback(void *data) {
+	ltk_menu *menu = (ltk_menu *)data;
+	ltk_menu_scroll(
+	    menu,
+	    menu->scroll_top_hover, menu->scroll_bottom_hover,
+	    menu->scroll_left_hover, menu->scroll_right_hover, 2
+	);
+}
+
+/* FIXME: HANDLE mouse scroll wheel! */
+
+static void
+stop_scrolling(ltk_menu *menu) {
+	menu->scroll_top_hover = 0;
+	menu->scroll_bottom_hover = 0;
+	menu->scroll_left_hover = 0;
+	menu->scroll_right_hover = 0;
+	if (menu->scroll_timer_id >= 0)
+		ltk_unregister_timer(menu->scroll_timer_id);
+}
+
+/* FIXME: should ideal_w, ideal_h just be int? */
+static size_t
+get_entry_at_point(ltk_menu *menu, int x, int y, ltk_rect *entry_rect_ret) {
+	struct theme *t = menu->is_submenu ? &submenu_theme : &menu_theme;
+	int arrow_size = t->arrow_size + t->arrow_pad * 2;
+	int mbw = t->menu_border_width;
+	int start_x = menu->widget.rect.x + mbw, end_x = menu->widget.rect.x + menu->widget.rect.w - mbw;
+	int start_y = menu->widget.rect.y + mbw, end_y = menu->widget.rect.y + menu->widget.rect.h - mbw;
+	if (menu->widget.rect.w < (int)menu->widget.ideal_w) {
+		start_x += arrow_size;
+		end_x -= arrow_size;
+	}
+	if (menu->widget.rect.h < (int)menu->widget.ideal_h) {
+		start_y += arrow_size;
+		end_y -= arrow_size;
+	}
+	if (!ltk_collide_rect((ltk_rect){start_x, start_y, end_x - start_x, end_y - start_y}, x, y))
+		return SIZE_MAX;
+
+	int bw_sub = t->compress_borders ? t->border_width : 0;
+	int cur_x = start_x - (int)menu->x_scroll_offset + t->pad;
+	int cur_y = start_y - (int)menu->y_scroll_offset + t->pad;
+	/* FIXME: could be optimized a bit */
+	for (size_t i = 0; i < menu->num_entries; i++) {
+		ltk_menuentry *e = &menu->entries[i];
+		int text_w, text_h;
+		ltk_text_line_get_size(e->text, &text_w, &text_h);
+		if (menu->is_submenu) {
+			int extra_size = e->submenu ? t->arrow_pad * 2 + t->arrow_size : 0;
+			int w = (int)menu->widget.ideal_w - t->pad * 2;
+			int h = MAX(text_h + t->text_pad * 2, extra_size) + t->border_width * 2;
+			if (x >= cur_x && x <= cur_x + w && y >= cur_y && y <= cur_y + h) {
+				if (entry_rect_ret) {
+					entry_rect_ret->x = cur_x;
+					entry_rect_ret->y = cur_y;
+					entry_rect_ret->w = w;
+					entry_rect_ret->h = h;
+				}
+				return i;
+			}
+			cur_y += h - bw_sub + t->pad;
+		} else {
+			int w = text_w + t->text_pad * 2 + t->border_width * 2;
+			int h = (int)menu->widget.ideal_h - t->pad * 2;
+			if (x >= cur_x && x <= cur_x + w && y >= cur_y && y <= cur_y + h) {
+				if (entry_rect_ret) {
+					entry_rect_ret->x = cur_x;
+					entry_rect_ret->y = cur_y;
+					entry_rect_ret->w = w;
+					entry_rect_ret->h = h;
+				}
+				return i;
+			}
+			cur_x += w - bw_sub + t->pad;
+		}
+	}
+	return SIZE_MAX;
+}
+
+/* FIXME: make sure timers are always destroyed when widget is destroyed */
+static int
+set_scroll_timer(ltk_menu *menu, int x, int y) {
+	if (!ltk_collide_rect(menu->widget.rect, x, y))
+		return 0;
+	int t = 0, b = 0, l = 0,r = 0;
+	struct theme *theme = menu->is_submenu ? &submenu_theme : &menu_theme;
+	int arrow_size = theme->arrow_size + theme->arrow_pad * 2;
+	if (menu->widget.rect.w < (int)menu->widget.ideal_w) {
+		if (x < menu->widget.rect.x + arrow_size)
+			l = 1;
+		else if (x > menu->widget.rect.x + menu->widget.rect.w - arrow_size)
+			r = 1;
+	}
+	if (menu->widget.rect.h < (int)menu->widget.ideal_h) {
+		if (y < menu->widget.rect.y + arrow_size)
+			t = 1;
+		else if (y > menu->widget.rect.y + menu->widget.rect.h - arrow_size)
+			b = 1;
+	}
+	if (t == menu->scroll_top_hover &&
+	    b == menu->scroll_bottom_hover &&
+	    l == menu->scroll_left_hover &&
+	    r == menu->scroll_right_hover)
+		return 0;
+	stop_scrolling(menu);
+	menu->scroll_top_hover = t;
+	menu->scroll_bottom_hover = b;
+	menu->scroll_left_hover = l;
+	menu->scroll_right_hover = r;
+	ltk_menu_scroll_callback(menu);
+	menu->scroll_timer_id = ltk_register_timer(0, 300, <k_menu_scroll_callback, menu);
+	return 1;
+}
+
+static int
+ltk_menu_mouse_release(ltk_widget *self, XEvent event) {
+	ltk_menu *menu = (ltk_menu *)self;
+	size_t idx = get_entry_at_point(menu, event.xbutton.x, event.xbutton.y, NULL);
+	if (idx < menu->num_entries && idx == menu->pressed_entry) {
+		ltk_window_unregister_all_popups(self->window);
+		/* FIXME: give menu id and entry id */
+		ltk_queue_event(self->window, LTK_EVENT_MENU, menu->entries[idx].id, "menu_entry_click");
+	}
+	if (menu->pressed_entry < menu->num_entries && idx < menu->num_entries)
+		menu->active_entry = menu->pressed_entry;
+	else if (idx < menu->num_entries)
+		menu->active_entry = idx;
+	menu->pressed_entry = SIZE_MAX;
+	self->dirty = 1;
+	return 1;
+}
+
+static int
+ltk_menu_mouse_press(ltk_widget *self, XEvent event) {
+	ltk_menu *menu = (ltk_menu *)self;
+	size_t idx;
+	/* FIXME: configure scroll step */
+	switch (event.xbutton.button) {
+	case 1:
+		idx = get_entry_at_point(menu, event.xbutton.x, event.xbutton.y, NULL);
+		if (idx < menu->num_entries) {
+			menu->pressed_entry = idx;
+			self->dirty = 1;
+		}
+		break;
+	case 4:
+		ltk_menu_scroll(menu, 1, 0, 0, 0, 10);
+		handle_hover(menu, event.xbutton.x, event.xbutton.y);
+		break;
+	case 5:
+		ltk_menu_scroll(menu, 0, 1, 0, 0, 10);
+		handle_hover(menu, event.xbutton.x, event.xbutton.y);
+		break;
+	case 6:
+		ltk_menu_scroll(menu, 0, 0, 1, 0, 10);
+		handle_hover(menu, event.xbutton.x, event.xbutton.y);
+		break;
+	case 7:
+		ltk_menu_scroll(menu, 0, 0, 0, 1, 10);
+		handle_hover(menu, event.xbutton.x, event.xbutton.y);
+		break;
+	default:
+		break;
+	}
+	return 1;
+}
+
+static void
+ltk_menu_hide(ltk_widget *self) {
+	ltk_menu *menu = (ltk_menu *)self;
+	menu->active_entry = menu->pressed_entry = SIZE_MAX;
+	if (menu->scroll_timer_id >= 0)
+		ltk_unregister_timer(menu->scroll_timer_id);
+	menu->scroll_bottom_hover = menu->scroll_top_hover = 0;
+	menu->scroll_left_hover = menu->scroll_right_hover = 0;
+	ltk_window_unregister_popup(self->window, self);
+	ltk_window_invalidate_rect(self->window, self->rect);
+}
+
+/* FIXME: don't require passing rect */
+static void
+popup_active_menu(ltk_menu *menu, ltk_rect r) {
+	size_t idx = menu->active_entry;
+	if (idx >= menu->num_entries)
+		return;
+	int win_w = menu->widget.window->rect.w;
+	int win_h = menu->widget.window->rect.h;
+	if (menu->entries[idx].submenu) {
+		ltk_menu *submenu = menu->entries[idx].submenu;
+		int ideal_w = submenu->widget.ideal_w + 2;
+		int ideal_h = submenu->widget.ideal_h;
+		int x_final = 0, y_final = 0, w_final = ideal_w, h_final = ideal_h;
+		if (menu->is_submenu) {
+			int space_left = menu->widget.rect.x;
+			int space_right = win_w - (menu->widget.rect.x + menu->widget.rect.w);
+			int x_right = menu->widget.rect.x + menu->widget.rect.w;
+			int x_left = menu->widget.rect.x - ideal_w;
+			if (menu->was_opened_left) {
+				if (x_left >= 0) {
+					x_final = x_left;
+					submenu->was_opened_left = 1;
+				} else if (space_right >= ideal_w) {
+					x_final = x_right;
+					submenu->was_opened_left = 0;
+				} else {
+					x_final = 0;
+					if (win_w < ideal_w)
+						w_final = win_w;
+					submenu->was_opened_left = 1;
+				}
+			} else {
+				if (space_right >= ideal_w) {
+					x_final = x_right;
+					submenu->was_opened_left = 0;
+				} else if (space_left >= ideal_w) {
+					x_final = x_left;
+					submenu->was_opened_left = 1;
+				} else {
+					x_final = win_w - ideal_w;
+					if (x_final < 0) {
+						x_final = 0;
+						w_final = win_w;
+					}
+					submenu->was_opened_left = 0;
+				}
+			}
+			/* subtract padding and border width so the actual entries are at the right position */
+			y_final = r.y - submenu_theme.pad - submenu_theme.menu_border_width;
+			if (y_final + ideal_h > win_h)
+				y_final = win_h - ideal_h;
+			if (y_final < 0) {
+				y_final = 0;
+				h_final = win_h;
+			}
+		} else {
+			int space_top = menu->widget.rect.y;
+			int space_bottom = win_h - (menu->widget.rect.y + menu->widget.rect.h);
+			int y_top = menu->widget.rect.y - ideal_h;
+			int y_bottom = menu->widget.rect.y + menu->widget.rect.h;
+			if (space_top > space_bottom) {
+				y_final = y_top;
+				if (y_final < 0) {
+					y_final = 0;
+					h_final = menu->widget.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 = r.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 */
+		submenu->x_scroll_offset = submenu->y_scroll_offset = 0;
+		submenu->active_entry = submenu->pressed_entry = SIZE_MAX;
+		submenu->scroll_top_hover = submenu->scroll_bottom_hover = 0;
+		submenu->scroll_left_hover = submenu->scroll_right_hover = 0;
+		submenu->widget.rect.x = x_final;
+		submenu->widget.rect.y = y_final;
+		submenu->widget.rect.w = w_final;
+		submenu->widget.rect.h = h_final;
+		ltk_surface_cache_request_surface_size(submenu->widget.surface_key, w_final, h_final);
+		submenu->widget.dirty = 1;
+		submenu->widget.hidden = 0;
+		ltk_window_register_popup(menu->widget.window, (ltk_widget *)submenu);
+		ltk_window_invalidate_rect(submenu->widget.window, submenu->widget.rect);
+	}
+}
+
+static void
+unpopup_active_entry(ltk_menu *menu) {
+	if (menu->active_entry >= menu->num_entries)
+		return;
+	ltk_menu *cur_menu = menu->entries[menu->active_entry].submenu;
+	menu->active_entry = SIZE_MAX;
+	while (cur_menu) {
+		ltk_menu *tmp = NULL;
+		if (cur_menu->active_entry < cur_menu->num_entries)
+			tmp = cur_menu->entries[cur_menu->active_entry].submenu;
+		ltk_menu_hide((ltk_widget *)cur_menu);
+		cur_menu = tmp;
+	}
+}
+
+static void
+handle_hover(ltk_menu *menu, int x, int y) {
+	if (set_scroll_timer(menu, x, y) || menu->pressed_entry < menu->num_entries)
+		return;
+	ltk_rect r;
+	size_t idx = get_entry_at_point(menu, x, y, &r);
+	if (idx >= menu->num_entries)
+		return;
+	ltk_menu *cur_submenu = menu->active_entry < menu->num_entries ? menu->entries[menu->active_entry].submenu : NULL;
+	if (idx != menu->active_entry) {
+		unpopup_active_entry(menu);
+		menu->active_entry = idx;
+		menu->widget.dirty = 1;
+		ltk_window_invalidate_rect(menu->widget.window, menu->widget.rect);
+		popup_active_menu(menu, r);
+	} else if (cur_submenu && cur_submenu->widget.hidden) {
+		popup_active_menu(menu, r);
+	}
+}
+
+static int
+ltk_menu_motion_notify(ltk_widget *self, XEvent event) {
+	handle_hover((ltk_menu *)self, event.xmotion.x, event.xmotion.y);
+	return 1;
+}
+
+static int
+ltk_menu_mouse_enter(ltk_widget *self, XEvent event) {
+	handle_hover((ltk_menu *)self, event.xbutton.x, event.xbutton.y);
+	return 1;
+}
+
+static int
+ltk_menu_mouse_leave(ltk_widget *self, XEvent event) {
+	(void)event;
+	stop_scrolling((ltk_menu *)self);
+	return 1;
+}
+
+static ltk_menu *
+ltk_menu_create(ltk_window *window, const char *id, int is_submenu) {
+	ltk_menu *menu = ltk_malloc(sizeof(ltk_menu));
+	menu->widget.ideal_w = menu_theme.pad;
+	menu->widget.ideal_h = menu_theme.pad;
+	ltk_fill_widget_defaults(&menu->widget, id, window, &vtable, menu->widget.ideal_w, menu->widget.ideal_h);
+	menu->widget.dirty = 1;
+
+	menu->entries = NULL;
+	menu->num_entries = menu->num_alloc = 0;
+	menu->pressed_entry = menu->active_entry = SIZE_MAX;
+	menu->x_scroll_offset = menu->y_scroll_offset = 0;
+	menu->is_submenu = is_submenu;
+	menu->was_opened_left = 0;
+	menu->scroll_timer_id = -1;
+	menu->scroll_top_hover = menu->scroll_bottom_hover = 0;
+	menu->scroll_left_hover = menu->scroll_right_hover = 0;
+	/* FIXME: hide widget by default so recalc doesn't cause
+	   unnecessary redrawing */
+	recalc_menu_size(menu);
+
+	return menu;
+}
+
+static ltk_menuentry *
+insert_entry(ltk_menu *menu, size_t idx) {
+	if (idx > menu->num_entries)
+		return NULL;
+	if (menu->num_entries == menu->num_alloc) {
+		menu->num_alloc = ideal_array_size(menu->num_alloc, menu->num_entries + 1);
+		menu->entries = ltk_reallocarray(menu->entries, menu->num_alloc, sizeof(ltk_menuentry));
+	}
+	memmove(
+	    menu->entries + idx + 1,
+	    menu->entries + idx,
+	    sizeof(ltk_menuentry) * (menu->num_entries - idx)
+	);
+	if (menu->active_entry >= idx && menu->active_entry < menu->num_entries)
+		menu->active_entry++;
+	if (menu->pressed_entry >= idx && menu->pressed_entry < menu->num_entries)
+		menu->pressed_entry++;
+	menu->num_entries++;
+	return &menu->entries[idx];
+}
+
+/* FIXME: handle child_size_change - what if something added while menu shown?
+   -> I guess the scroll arrows will just be added when that's the case - it's
+      kind of pointless to spend time implementing the logic for recalculating
+      all submenu positions and sizes when it's such a corner case */
+static void
+recalc_menu_size(ltk_menu *menu) {
+	struct theme *t = menu->is_submenu ? &submenu_theme : &menu_theme;
+	menu->widget.ideal_w = menu->widget.ideal_h = t->pad + t->menu_border_width * 2;
+	ltk_menuentry *e;
+	int text_w, text_h, bw;
+	for (size_t i = 0; i < menu->num_entries; i++) {
+		e = &menu->entries[i];
+		ltk_text_line_get_size(e->text, &text_w, &text_h);
+		bw = t->border_width * 2;
+		if (t->compress_borders && i != 0)
+			bw = t->border_width;
+		if (menu->is_submenu) {
+			int extra_size = e->submenu ? t->arrow_pad * 2 + t->arrow_size : 0;
+			menu->widget.ideal_w =
+			    MAX(text_w + extra_size + (t->pad + t->text_pad + t->border_width + t->menu_border_width) * 2, (int)menu->widget.ideal_w);
+			menu->widget.ideal_h += MAX(text_h + t->text_pad * 2, extra_size) + bw + t->pad;
+		} else {
+			menu->widget.ideal_h =
+			    MAX(text_h + (t->pad + t->text_pad + t->border_width + t->menu_border_width) * 2, (int)menu->widget.ideal_h);
+			menu->widget.ideal_w += text_w + t->text_pad * 2 + bw + t->pad;
+		}
+	}
+	if (!menu->widget.hidden && menu->widget.parent && menu->widget.parent->vtable->child_size_change) {
+		menu->widget.parent->vtable->child_size_change(menu->widget.parent, (ltk_widget *)menu);
+	}
+	menu->widget.dirty = 1;
+	if (!menu->widget.hidden)
+		ltk_window_invalidate_rect(menu->widget.window, menu->widget.rect);
+}
+
+static ltk_menuentry *
+ltk_menu_insert_entry(ltk_menu *menu, const char *id, const char *text, ltk_menu *submenu, size_t idx, char **errstr) {
+	if (submenu && submenu->widget.parent) {
+		*errstr = "Submenu already part of other menu.\n";
+		return NULL;
+	}
+	ltk_menuentry *e = insert_entry(menu, idx);
+	if (!e) {
+		*errstr = "Unable to insert menu entry at given index.\n";
+		return NULL;
+	}
+	e->id = ltk_strdup(id);
+	ltk_window *w = menu->widget.window;
+	/* FIXME: pass const text */
+	e->text = ltk_text_line_create(w->text_context, w->theme.font_size, (char *)text, 0, -1);
+	e->submenu = submenu;
+	if (submenu)
+		submenu->widget.parent = (ltk_widget *)menu;
+	e->disabled = 0;
+	recalc_menu_size(menu);
+	menu->widget.dirty = 1;
+	return e;
+}
+
+static ltk_menuentry *
+ltk_menu_add_entry(ltk_menu *menu, const char *id, const char *text, ltk_menu *submenu, char **errstr) {
+	return ltk_menu_insert_entry(menu, id, text, submenu, menu->num_entries, errstr);
+}
+
+/* FIXME: maybe allow any menu and just change is_submenu (also need to recalculate size then) */
+static ltk_menuentry *
+ltk_menu_insert_submenu(ltk_menu *menu, const char *id, const char *text, ltk_menu *submenu, size_t idx, char **errstr) {
+	if (!submenu->is_submenu) {
+		*errstr = "Not a submenu.\n";
+		return NULL;
+	}
+	return ltk_menu_insert_entry(menu, id, text, submenu, idx, errstr);
+}
+
+static ltk_menuentry *
+ltk_menu_add_submenu(ltk_menu *menu, const char *id, const char *text, ltk_menu *submenu, char **errstr) {
+	return ltk_menu_insert_submenu(menu, id, text, submenu, menu->num_entries, errstr);
+}
+
+static void
+shrink_entries(ltk_menu *menu) {
+	size_t new_alloc = ideal_array_size(menu->num_alloc, menu->num_entries);
+	if (new_alloc != menu->num_alloc) {
+		menu->entries = ltk_reallocarray(menu->entries, new_alloc, sizeof(ltk_menuentry));
+		menu->num_alloc = new_alloc;
+	}
+}
+
+static int
+ltk_menu_remove_entry_index(ltk_menu *menu, size_t idx, int shallow, char **errstr) {
+	if (idx >= menu->num_entries) {
+		*errstr = "Invalid menu entry index.\n";
+		return 1;
+	}
+	ltk_menuentry *e = &menu->entries[idx];
+	ltk_free(e->id);
+	ltk_text_line_destroy(e->text);
+	if (e->submenu) {
+		e->submenu->widget.parent = NULL;
+		if (!shallow)
+			ltk_menu_destroy((ltk_widget *)e->submenu, shallow);
+	}
+	memmove(
+	    menu->entries + idx,
+	    menu->entries + idx + 1,
+	    sizeof(ltk_menuentry) * (menu->num_entries - idx - 1)
+	);
+	menu->num_entries--;
+	shrink_entries(menu);
+	recalc_menu_size(menu);
+	return 0;
+}
+
+static int
+ltk_menu_remove_entry_id(ltk_menu *menu, const char *id, int shallow, char **errstr) {
+	size_t idx = get_entry_with_id(menu, id);
+	if (idx >= menu->num_entries) {
+		*errstr = "Invalid menu entry id.\n";
+		return 1;
+	}
+	ltk_menu_remove_entry_index(menu, idx, shallow, errstr);
+	return 0;
+}
+
+static int
+ltk_menu_remove_all_entries(ltk_menu *menu, int shallow, char **errstr) {
+	(void)errstr; /* FIXME: why is errstr given at all? */
+	for (size_t i = 0; i < menu->num_entries; i++) {
+		ltk_menuentry *e = &menu->entries[i];
+		ltk_free(e->id);
+		ltk_text_line_destroy(e->text);
+		if (e->submenu) {
+			e->submenu->widget.parent = NULL;
+			if (!shallow)
+				ltk_menu_destroy((ltk_widget *)e->submenu, shallow);
+		}
+	}
+	menu->num_entries = menu->num_alloc = 0;
+	ltk_free(menu->entries);
+	menu->entries = NULL;
+	recalc_menu_size(menu);
+	return 0;
+}
+
+/* FIXME: how to get rid of duplicate IDs? */
+
+static size_t
+get_entry_with_id(ltk_menu *menu, const char *id) {
+	for (size_t i = 0; i < menu->num_entries; i++) {
+		if (!strcmp(id, menu->entries[i].id))
+			return i;
+	}
+	return SIZE_MAX;
+}
+
+/* FIXME: unregister from window popups? */
+static int
+ltk_menu_detach_submenu_from_entry_id(ltk_menu *menu, const char *id, char **errstr) {
+	size_t idx = get_entry_with_id(menu, id);
+	if (idx >= menu->num_entries) {
+		*errstr = "Invalid menu entry id.\n";
+		return 1;
+	}
+	/* FIXME: error if submenu already NULL? */
+	menu->entries[idx].submenu = NULL;
+	recalc_menu_size(menu);
+	return 0;
+}
+
+static int
+ltk_menu_detach_submenu_from_entry_index(ltk_menu *menu, size_t idx, char **errstr) {
+	if (idx >= menu->num_entries) {
+		*errstr = "Invalid menu entry index.\n";
+		return 1;
+	}
+	menu->entries[idx].submenu = NULL;
+	recalc_menu_size(menu);
+	return 0;
+}
+
+static int
+ltk_menu_disable_entry_index(ltk_menu *menu, size_t idx, char **errstr) {
+	if (idx >= menu->num_entries) {
+		*errstr = "Invalid menu entry index.\n";
+		return 1;
+	}
+	menu->entries[idx].disabled = 1;
+	menu->widget.dirty = 1;
+	if (!menu->widget.hidden)
+		ltk_window_invalidate_rect(menu->widget.window, menu->widget.rect);
+	return 0;
+}
+
+static int
+ltk_menu_disable_entry_id(ltk_menu *menu, const char *id, char **errstr) {
+	size_t idx = get_entry_with_id(menu, id);
+	if (idx >= menu->num_entries) {
+		*errstr = "Invalid menu entry id.\n";
+		return 1;
+	}
+	menu->entries[idx].disabled = 1;
+	menu->widget.dirty = 1;
+	if (!menu->widget.hidden)
+		ltk_window_invalidate_rect(menu->widget.window, menu->widget.rect);
+	return 0;
+}
+
+static int
+ltk_menu_disable_all_entries(ltk_menu *menu, char **errstr) {
+	(void)errstr;
+	for (size_t i = 0; i < menu->num_entries; i++) {
+		menu->entries[i].disabled = 1;
+	}
+	menu->widget.dirty = 1;
+	if (!menu->widget.hidden)
+		ltk_window_invalidate_rect(menu->widget.window, menu->widget.rect);
+	return 0;
+}
+
+static int
+ltk_menu_enable_entry_index(ltk_menu *menu, size_t idx, char **errstr) {
+	if (idx >= menu->num_entries) {
+		*errstr = "Invalid menu entry index.\n";
+		return 1;
+	}
+	menu->entries[idx].disabled = 0;
+	menu->widget.dirty = 1;
+	if (!menu->widget.hidden)
+		ltk_window_invalidate_rect(menu->widget.window, menu->widget.rect);
+	return 0;
+}
+
+static int
+ltk_menu_enable_entry_id(ltk_menu *menu, const char *id, char **errstr) {
+	size_t idx = get_entry_with_id(menu, id);
+	if (idx >= menu->num_entries) {
+		*errstr = "Invalid menu entry id.\n";
+		return 1;
+	}
+	menu->entries[idx].disabled = 0;
+	menu->widget.dirty = 1;
+	if (!menu->widget.hidden)
+		ltk_window_invalidate_rect(menu->widget.window, menu->widget.rect);
+	return 0;
+}
+
+static int
+ltk_menu_enable_all_entries(ltk_menu *menu, char **errstr) {
+	(void)errstr;
+	for (size_t i = 0; i < menu->num_entries; i++) {
+		menu->entries[i].disabled = 0;
+	}
+	menu->widget.dirty = 1;
+	if (!menu->widget.hidden)
+		ltk_window_invalidate_rect(menu->widget.window, menu->widget.rect);
+	return 0;
+}
+
+static void
+ltk_menu_destroy(ltk_widget *self, int shallow) {
+	ltk_menu *menu = (ltk_menu *)self;
+	char *errstr;
+	if (self->parent && self->parent->vtable->remove_child) {
+		self->parent->vtable->remove_child(
+		    self->window, self, self->parent, &errstr
+		);
+	}
+	if (!menu) {
+		ltk_warn("Tried to destroy NULL menu.\n");
+		return;
+	}
+	/* FIXME: this should be generic part of widget */
+	ltk_surface_cache_release_key(self->surface_key);
+	if (menu->scroll_timer_id >= 0)
+		ltk_unregister_timer(menu->scroll_timer_id);
+	ltk_menu_remove_all_entries(menu, shallow, NULL);
+	ltk_window_unregister_popup(self->window, self);
+	/* FIXME: what to do on error here? */
+	/* FIXME: maybe unregister popup in ltk_remove_widget? */
+	ltk_remove_widget(self->id);
+	ltk_free(self->id);
+	ltk_free(menu);
+}
+
+/* FIXME: simplify command handling to avoid all this boilerplate */
+/* TODO: get-index-for-id */
+
+/* [sub]menu <menu id> create */
+static int
+ltk_menu_cmd_create(
+    ltk_window *window,
+    char **tokens,
+    size_t num_tokens,
+    char **errstr) {
+	ltk_menu *menu;
+	if (num_tokens != 3) {
+		*errstr = "Invalid number of arguments.\n";
+		return 1;
+	}
+	if (!ltk_widget_id_free(tokens[1])) {
+		*errstr = "Widget ID already taken.\n";
+		return 1;
+	}
+	if (!strcmp(tokens[0], "menu")) {
+		menu = ltk_menu_create(window, tokens[1], 0);
+	} else {
+		menu = ltk_menu_create(window, tokens[1], 1);
+	}
+	ltk_set_widget((ltk_widget *)menu, tokens[1]);
+
+	return 0;
+}
+
+/* menu <menu id> insert-entry <entry id> <entry text> <index> */
+static int
+ltk_menu_cmd_insert_entry(
+    ltk_window *window,
+    char **tokens,
+    size_t num_tokens,
+    char **errstr) {
+	(void)window;
+	ltk_menu *menu;
+	const char *errstr_num;
+	if (num_tokens != 6) {
+		*errstr = "Invalid number of arguments.\n";
+		return 1;
+	}
+	/* FIXME: actually use this errstr */
+	menu = (ltk_menu *)ltk_get_widget(tokens[1], LTK_MENU, errstr);
+	if (!menu) {
+		*errstr = "Invalid widget ID.\n";
+		return 1;
+	}
+	size_t idx = (size_t)ltk_strtonum(tokens[5], 0, (long long)menu->num_entries, &errstr_num);
+	if (errstr_num) {
+		*errstr = "Invalid index.\n";
+		return 1;
+	}
+	if (!ltk_menu_insert_entry(menu, tokens[3], tokens[4], NULL, idx, errstr))
+		return 1;
+
+	return 0;
+}
+
+/* menu <menu id> add-entry <entry id> <entry text> */
+static int
+ltk_menu_cmd_add_entry(
+    ltk_window *window,
+    char **tokens,
+    size_t num_tokens,
+    char **errstr) {
+	(void)window;
+	ltk_menu *menu;
+	if (num_tokens != 5) {
+		*errstr = "Invalid number of arguments.\n";
+		return 1;
+	}
+	menu = (ltk_menu *)ltk_get_widget(tokens[1], LTK_MENU, errstr);
+	if (!menu) {
+		*errstr = "Invalid widget ID.\n";
+		return 1;
+	}
+	if (!ltk_menu_add_entry(menu, tokens[3], tokens[4], NULL, errstr))
+		return 1;
+
+	return 0;
+}
+
+/* menu <menu id> insert-submenu <entry id> <entry text> <submenu id> <index> */
+static int
+ltk_menu_cmd_insert_submenu(
+    ltk_window *window,
+    char **tokens,
+    size_t num_tokens,
+    char **errstr) {
+	(void)window;
+	ltk_menu *menu, *submenu;
+	const char *errstr_num;
+	if (num_tokens != 7) {
+		*errstr = "Invalid number of arguments.\n";
+		return 1;
+	}
+	menu = (ltk_menu *)ltk_get_widget(tokens[1], LTK_MENU, errstr);
+	submenu = (ltk_menu *)ltk_get_widget(tokens[5], LTK_MENU, errstr);
+	if (!menu || !submenu) {
+		*errstr = "Invalid widget ID.\n";
+		return 1;
+	}
+	size_t idx = (size_t)ltk_strtonum(tokens[6], 0, (long long)menu->num_entries, &errstr_num);
+	if (errstr_num) {
+		*errstr = "Invalid index.\n";
+		return 1;
+	}
+	if (!ltk_menu_insert_submenu(menu, tokens[3], tokens[4], submenu, idx, errstr))
+		return 1;
+
+	return 0;
+}
+
+/* menu <menu id> add-submenu <entry id> <entry text> <submenu id> */
+static int
+ltk_menu_cmd_add_submenu(
+    ltk_window *window,
+    char **tokens,
+    size_t num_tokens,
+    char **errstr) {
+	(void)window;
+	ltk_menu *menu, *submenu;
+	if (num_tokens != 6) {
+		*errstr = "Invalid number of arguments.\n";
+		return 1;
+	}
+	menu = (ltk_menu *)ltk_get_widget(tokens[1], LTK_MENU, errstr);
+	submenu = (ltk_menu *)ltk_get_widget(tokens[5], LTK_MENU, errstr);
+	if (!menu || !submenu) {
+		*errstr = "Invalid widget ID.\n";
+		return 1;
+	}
+	if (!ltk_menu_add_submenu(menu, tokens[3], tokens[4], submenu, errstr))
+		return 1;
+
+	return 0;
+}
+
+/* menu <menu id> remove-entry-index <entry index> [shallow|deep] */
+static int
+ltk_menu_cmd_remove_entry_index(
+    ltk_window *window,
+    char **tokens,
+    size_t num_tokens,
+    char **errstr) {
+	(void)window;
+	ltk_menu *menu;
+	const char *errstr_num;
+	if (num_tokens != 4 && num_tokens != 5) {
+		*errstr = "Invalid number of arguments.\n";
+		return 1;
+	}
+	menu = (ltk_menu *)ltk_get_widget(tokens[1], LTK_MENU, errstr);
+	if (!menu) {
+		*errstr = "Invalid widget ID.\n";
+		return 1;
+	}
+	size_t idx = (size_t)ltk_strtonum(tokens[3], 0, (long long)menu->num_entries, &errstr_num);
+	if (errstr_num) {
+		*errstr = "Invalid index.\n";
+		return 1;
+	}
+	int shallow = 1;
+	if (num_tokens == 5) {
+		if (!strcmp(tokens[4], "shallow")) {
+			/* NOP */
+		} else if (!strcmp(tokens[4], "deep")) {
+			shallow = 0;
+		} else {
+			*errstr = "Invalid shallow specifier.\n";
+			return 1;
+		}
+	}
+	if (!ltk_menu_remove_entry_index(menu, idx, shallow, errstr))
+		return 1;
+
+	return 0;
+}
+
+/* menu <menu id> remove-entry-id <entry id> [shallow|deep] */
+static int
+ltk_menu_cmd_remove_entry_id(
+    ltk_window *window,
+    char **tokens,
+    size_t num_tokens,
+    char **errstr) {
+	(void)window;
+	ltk_menu *menu;
+	if (num_tokens != 4 && num_tokens != 5) {
+		*errstr = "Invalid number of arguments.\n";
+		return 1;
+	}
+	menu = (ltk_menu *)ltk_get_widget(tokens[1], LTK_MENU, errstr);
+	if (!menu) {
+		*errstr = "Invalid widget ID.\n";
+		return 1;
+	}
+	int shallow = 1;
+	if (num_tokens == 5) {
+		if (!strcmp(tokens[4], "shallow")) {
+			/* NOP */
+		} else if (!strcmp(tokens[4], "deep")) {
+			shallow = 0;
+		} else {
+			*errstr = "Invalid shallow specifier.\n";
+			return 1;
+		}
+	}
+	if (!ltk_menu_remove_entry_id(menu, tokens[3], shallow, errstr))
+		return 1;
+
+	return 0;
+}
+
+/* menu <menu id> remove-all-entries [shallow|deep] */
+static int
+ltk_menu_cmd_remove_all_entries(
+    ltk_window *window,
+    char **tokens,
+    size_t num_tokens,
+    char **errstr) {
+	(void)window;
+	ltk_menu *menu;
+	if (num_tokens != 3 && num_tokens != 4) {
+		*errstr = "Invalid number of arguments.\n";
+		return 1;
+	}
+	menu = (ltk_menu *)ltk_get_widget(tokens[1], LTK_MENU, errstr);
+	if (!menu) {
+		*errstr = "Invalid widget ID.\n";
+		return 1;
+	}
+	int shallow = 1;
+	if (num_tokens == 4) {
+		if (!strcmp(tokens[3], "shallow")) {
+			/* NOP */
+		} else if (!strcmp(tokens[3], "deep")) {
+			shallow = 0;
+		} else {
+			*errstr = "Invalid shallow specifier.\n";
+			return 1;
+		}
+	}
+	if (!ltk_menu_remove_all_entries(menu, shallow, errstr))
+		return 1;
+
+	return 0;
+}
+
+/* menu <menu id> detach-submenu-from-entry-id <entry id> */
+static int
+ltk_menu_cmd_detach_submenu_from_entry_id(
+    ltk_window *window,
+    char **tokens,
+    size_t num_tokens,
+    char **errstr) {
+	(void)window;
+	ltk_menu *menu;
+	if (num_tokens != 4) {
+		*errstr = "Invalid number of arguments.\n";
+		return 1;
+	}
+	menu = (ltk_menu *)ltk_get_widget(tokens[1], LTK_MENU, errstr);
+	if (!menu) {
+		*errstr = "Invalid widget ID.\n";
+		return 1;
+	}
+
+	if (!ltk_menu_detach_submenu_from_entry_id(menu, tokens[3], errstr))
+		return 1;
+
+	return 0;
+}
+
+/* menu <menu id> detach-submenu-from-entry-index <entry index> */
+static int
+ltk_menu_cmd_detach_submenu_from_entry_index(
+    ltk_window *window,
+    char **tokens,
+    size_t num_tokens,
+    char **errstr) {
+	(void)window;
+	ltk_menu *menu;
+	const char *errstr_num;
+	if (num_tokens != 4) {
+		*errstr = "Invalid number of arguments.\n";
+		return 1;
+	}
+	menu = (ltk_menu *)ltk_get_widget(tokens[1], LTK_MENU, errstr);
+	if (!menu) {
+		*errstr = "Invalid widget ID.\n";
+		return 1;
+	}
+	size_t idx = (size_t)ltk_strtonum(tokens[3], 0, (long long)menu->num_entries, &errstr_num);
+	if (errstr_num) {
+		*errstr = "Invalid index.\n";
+		return 1;
+	}
+
+	if (!ltk_menu_detach_submenu_from_entry_index(menu, idx, errstr))
+		return 1;
+
+	return 0;
+}
+
+/* menu <menu id> enable-entry-index <entry index> */
+static int
+ltk_menu_cmd_enable_entry_index(
+    ltk_window *window,
+    char **tokens,
+    size_t num_tokens,
+    char **errstr) {
+	(void)window;
+	ltk_menu *menu;
+	const char *errstr_num;
+	if (num_tokens != 4) {
+		*errstr = "Invalid number of arguments.\n";
+		return 1;
+	}
+	menu = (ltk_menu *)ltk_get_widget(tokens[1], LTK_MENU, errstr);
+	if (!menu) {
+		*errstr = "Invalid widget ID.\n";
+		return 1;
+	}
+	size_t idx = (size_t)ltk_strtonum(tokens[3], 0, (long long)menu->num_entries, &errstr_num);
+	if (errstr_num) {
+		*errstr = "Invalid index.\n";
+		return 1;
+	}
+
+	if (!ltk_menu_enable_entry_index(menu, idx, errstr))
+		return 1;
+
+	return 0;
+}
+
+/* menu <menu id> enable-entry-id <entry id> */
+static int
+ltk_menu_cmd_enable_entry_id(
+    ltk_window *window,
+    char **tokens,
+    size_t num_tokens,
+    char **errstr) {
+	(void)window;
+	ltk_menu *menu;
+	if (num_tokens != 4) {
+		*errstr = "Invalid number of arguments.\n";
+		return 1;
+	}
+	menu = (ltk_menu *)ltk_get_widget(tokens[1], LTK_MENU, errstr);
+	if (!menu) {
+		*errstr = "Invalid widget ID.\n";
+		return 1;
+	}
+
+	if (!ltk_menu_enable_entry_id(menu, tokens[3], errstr))
+		return 1;
+
+	return 0;
+}
+
+/* menu <menu id> enable-all-entries */
+static int
+ltk_menu_cmd_enable_all_entries(
+    ltk_window *window,
+    char **tokens,
+    size_t num_tokens,
+    char **errstr) {
+	(void)window;
+	ltk_menu *menu;
+	if (num_tokens != 3) {
+		*errstr = "Invalid number of arguments.\n";
+		return 1;
+	}
+	menu = (ltk_menu *)ltk_get_widget(tokens[1], LTK_MENU, errstr);
+	if (!menu) {
+		*errstr = "Invalid widget ID.\n";
+		return 1;
+	}
+
+	if (!ltk_menu_enable_all_entries(menu, errstr))
+		return 1;
+
+	return 0;
+}
+
+/* menu <menu id> disable-entry-index <entry index> */
+static int
+ltk_menu_cmd_disable_entry_index(
+    ltk_window *window,
+    char **tokens,
+    size_t num_tokens,
+    char **errstr) {
+	(void)window;
+	ltk_menu *menu;
+	const char *errstr_num;
+	if (num_tokens != 4) {
+		*errstr = "Invalid number of arguments.\n";
+		return 1;
+	}
+	menu = (ltk_menu *)ltk_get_widget(tokens[1], LTK_MENU, errstr);
+	if (!menu) {
+		*errstr = "Invalid widget ID.\n";
+		return 1;
+	}
+	size_t idx = (size_t)ltk_strtonum(tokens[3], 0, (long long)menu->num_entries, &errstr_num);
+	if (errstr_num) {
+		*errstr = "Invalid index.\n";
+		return 1;
+	}
+
+	if (!ltk_menu_disable_entry_index(menu, idx, errstr))
+		return 1;
+
+	return 0;
+}
+
+/* menu <menu id> disable-entry-id <entry id> */
+static int
+ltk_menu_cmd_disable_entry_id(
+    ltk_window *window,
+    char **tokens,
+    size_t num_tokens,
+    char **errstr) {
+	(void)window;
+	ltk_menu *menu;
+	if (num_tokens != 4) {
+		*errstr = "Invalid number of arguments.\n";
+		return 1;
+	}
+	menu = (ltk_menu *)ltk_get_widget(tokens[1], LTK_MENU, errstr);
+	if (!menu) {
+		*errstr = "Invalid widget ID.\n";
+		return 1;
+	}
+
+	if (!ltk_menu_disable_entry_id(menu, tokens[3], errstr))
+		return 1;
+
+	return 0;
+}
+
+/* menu <menu id> disable-all-entries */
+static int
+ltk_menu_cmd_disable_all_entries(
+    ltk_window *window,
+    char **tokens,
+    size_t num_tokens,
+    char **errstr) {
+	(void)window;
+	ltk_menu *menu;
+	if (num_tokens != 3) {
+		*errstr = "Invalid number of arguments.\n";
+		return 1;
+	}
+	menu = (ltk_menu *)ltk_get_widget(tokens[1], LTK_MENU, errstr);
+	if (!menu) {
+		*errstr = "Invalid widget ID.\n";
+		return 1;
+	}
+
+	if (!ltk_menu_disable_all_entries(menu, errstr))
+		return 1;
+
+	return 0;
+}
+
+/* FIXME: binary search for command handler */
+/* FIXME: distinguish between menu/submenu in commands other than create? */
+/* menu <menu id> <command> ... */
+int
+ltk_menu_cmd(
+    ltk_window *window,
+    char **tokens,
+    size_t num_tokens,
+    char **errstr) {
+	if (num_tokens < 3) {
+		*errstr = "Invalid number of arguments.\n";
+		return 1;
+	}
+	if (strcmp(tokens[2], "create") == 0) {
+		return ltk_menu_cmd_create(window, tokens, num_tokens, errstr);
+	} else if (strcmp(tokens[2], "insert-entry") == 0) {
+		return ltk_menu_cmd_insert_entry(window, tokens, num_tokens, errstr);
+	} else if (strcmp(tokens[2], "add-entry") == 0) {
+		return ltk_menu_cmd_add_entry(window, tokens, num_tokens, errstr);
+	} else if (strcmp(tokens[2], "insert-submenu") == 0) {
+		return ltk_menu_cmd_insert_submenu(window, tokens, num_tokens, errstr);
+	} else if (strcmp(tokens[2], "add-submenu") == 0) {
+		return ltk_menu_cmd_add_submenu(window, tokens, num_tokens, errstr);
+	} else if (strcmp(tokens[2], "remove-entry-index") == 0) {
+		return ltk_menu_cmd_remove_entry_index(window, tokens, num_tokens, errstr);
+	} else if (strcmp(tokens[2], "remove-entry-id") == 0) {
+		return ltk_menu_cmd_remove_entry_id(window, tokens, num_tokens, errstr);
+	} else if (strcmp(tokens[2], "remove-all-entries") == 0) {
+		return ltk_menu_cmd_remove_all_entries(window, tokens, num_tokens, errstr);
+	} else if (strcmp(tokens[2], "detach-submenu-from-entry-id") == 0) {
+		return ltk_menu_cmd_detach_submenu_from_entry_id(window, tokens, num_tokens, errstr);
+	} else if (strcmp(tokens[2], "detach-submenu-from-entry-index") == 0) {
+		return ltk_menu_cmd_detach_submenu_from_entry_index(window, tokens, num_tokens, errstr);
+	} else if (strcmp(tokens[2], "disable-entry-index") == 0) {
+		return ltk_menu_cmd_disable_entry_index(window, tokens, num_tokens, errstr);
+	} else if (strcmp(tokens[2], "disable-entry-id") == 0) {
+		return ltk_menu_cmd_disable_entry_id(window, tokens, num_tokens, errstr);
+	} else if (strcmp(tokens[2], "disable-all-entries") == 0) {
+		return ltk_menu_cmd_disable_all_entries(window, tokens, num_tokens, errstr);
+	} else if (strcmp(tokens[2], "enable-entry-index") == 0) {
+		return ltk_menu_cmd_enable_entry_index(window, tokens, num_tokens, errstr);
+	} else if (strcmp(tokens[2], "enable-entry-id") == 0) {
+		return ltk_menu_cmd_enable_entry_id(window, tokens, num_tokens, errstr);
+	} else if (strcmp(tokens[2], "enable-all-entries") == 0) {
+		return ltk_menu_cmd_enable_all_entries(window, tokens, num_tokens, errstr);
+	} else {
+		*errstr = "Invalid command.\n";
+		return 1;
+	}
+
+	return 0;
+}
diff --git a/src/menu.h b/src/menu.h
@@ -0,0 +1,64 @@
+/*
+ * Copyright (c) 2022 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_MENU_H_
+#define _LTK_MENU_H_
+
+#include "ltk.h"
+#include "text.h"
+#include "widget.h"
+
+/* TODO: implement scrolling */
+
+typedef struct ltk_menuentry ltk_menuentry;
+
+typedef struct {
+	ltk_widget widget;
+	ltk_menuentry *entries;
+	size_t num_entries;
+	size_t num_alloc;
+	size_t pressed_entry;
+	size_t active_entry;
+	double x_scroll_offset;
+	double y_scroll_offset;
+	int scroll_timer_id;
+	char is_submenu;
+	char was_opened_left;
+	char scroll_top_hover;
+	char scroll_bottom_hover;
+	char scroll_left_hover;
+	char scroll_right_hover;
+} ltk_menu;
+
+struct ltk_menuentry {
+	char *id;
+	ltk_text_line *text;
+	ltk_menu *submenu;
+	int disabled;
+};
+
+void ltk_menu_setup_theme_defaults(ltk_window *window);
+void ltk_menu_ini_handler(ltk_window *window, const char *prop, const char *value);
+void ltk_submenu_ini_handler(ltk_window *window, const char *prop, const char *value);
+
+int ltk_menu_cmd(
+	ltk_window *window,
+	char **tokens,
+	size_t num_tokens,
+	char **errstr
+);
+
+#endif /* _LTK_MENU_H_ */
diff --git a/src/scrollbar.c b/src/scrollbar.c
@@ -1,3 +1,4 @@
+/* FIXME: make scrollbar a "real" widget that is also in widget hash */
 /*
  * Copyright (c) 2021, 2022 lumidify <nobody@lumidify.org>
  *
@@ -31,6 +32,8 @@
 #include "util.h"
 #include "scrollbar.h"
 
+#define MAX_SCROLLBAR_WIDTH 100 /* completely arbitrary */
+
 static void ltk_scrollbar_draw(ltk_widget *self, ltk_rect clip);
 static int ltk_scrollbar_mouse_press(ltk_widget *self, XEvent event);
 static int ltk_scrollbar_motion_notify(ltk_widget *self, XEvent event);
@@ -59,44 +62,43 @@ static struct {
 void
 ltk_scrollbar_setup_theme_defaults(ltk_window *window) {
 	theme.size = 15;
-	ltk_color_create(window->dpy, window->screen, window->cm,
-	    "#000000", &theme.bg_normal);
-	ltk_color_create(window->dpy, window->screen, window->cm,
-	    "#555555", &theme.bg_disabled);
-	ltk_color_create(window->dpy, window->screen, window->cm,
-	    "#113355", &theme.fg_normal);
-	ltk_color_create(window->dpy, window->screen, window->cm,
-	    "#738194", &theme.fg_active);
-	ltk_color_create(window->dpy, window->screen, window->cm,
-	    "#113355", &theme.fg_pressed);
-	ltk_color_create(window->dpy, window->screen, window->cm,
-	    "#292929", &theme.fg_disabled);
+	/* FIXME: error checking - but if these fail, there is probably a bigger
+	   problem, so it might be best to just die completely */
+	ltk_color_create(window->dpy, window->screen, window->cm, "#000000", &theme.bg_normal);
+	ltk_color_create(window->dpy, window->screen, window->cm, "#555555", &theme.bg_disabled);
+	ltk_color_create(window->dpy, window->screen, window->cm, "#113355", &theme.fg_normal);
+	ltk_color_create(window->dpy, window->screen, window->cm, "#738194", &theme.fg_active);
+	ltk_color_create(window->dpy, window->screen, window->cm, "#113355", &theme.fg_pressed);
+	ltk_color_create(window->dpy, window->screen, window->cm, "#292929", &theme.fg_disabled);
 }
 
 void
 ltk_scrollbar_ini_handler(ltk_window *window, const char *prop, const char *value) {
+	const char *errstr;
 	if (strcmp(prop, "size") == 0) {
-		theme.size = atoi(value); /* FIXME: proper strtonum */
+		theme.size = ltk_strtonum(value, 1, MAX_SCROLLBAR_WIDTH, &errstr);
+		if (errstr)
+			ltk_warn("Invalid scrollbar size '%s': %s.\n", value, errstr);
 	} else if (strcmp(prop, "bg") == 0) {
-		ltk_color_create(window->dpy, window->screen, window->cm,
-		    value, &theme.bg_normal);
+		if (ltk_color_create(window->dpy, window->screen, window->cm, value, &theme.bg_normal))
+			ltk_warn("Error setting scrollbar background color to '%s'.\n", value);
 	} else if (strcmp(prop, "bg_disabled") == 0) {
-		ltk_color_create(window->dpy, window->screen, window->cm,
-		    value, &theme.bg_disabled);
+		if (ltk_color_create(window->dpy, window->screen, window->cm, value, &theme.bg_disabled))
+			ltk_warn("Error setting scrollbar disabled background color to '%s'.\n", value);
 	} else if (strcmp(prop, "fg") == 0) {
-		ltk_color_create(window->dpy, window->screen, window->cm,
-		    value, &theme.fg_normal);
+		if (ltk_color_create(window->dpy, window->screen, window->cm, value, &theme.fg_normal))
+			ltk_warn("Error setting scrollbar foreground color to '%s'.\n", value);
 	} else if (strcmp(prop, "fg_active") == 0) {
-		ltk_color_create(window->dpy, window->screen, window->cm,
-		    value, &theme.fg_active);
+		if (ltk_color_create(window->dpy, window->screen, window->cm, value, &theme.fg_active))
+			ltk_warn("Error setting scrollbar active foreground color to '%s'.\n", value);
 	} else if (strcmp(prop, "fg_pressed") == 0) {
-		ltk_color_create(window->dpy, window->screen, window->cm,
-		    value, &theme.fg_pressed);
+		if (ltk_color_create(window->dpy, window->screen, window->cm, value, &theme.fg_pressed))
+			ltk_warn("Error setting scrollbar pressed foreground color to '%s'.\n", value);
 	} else if (strcmp(prop, "fg_disabled") == 0) {
-		ltk_color_create(window->dpy, window->screen, window->cm,
-		    value, &theme.fg_disabled);
+		if (ltk_color_create(window->dpy, window->screen, window->cm, value, &theme.fg_disabled))
+			ltk_warn("Error setting scrollbar disabled foreground color to '%s'.\n", value);
 	} else {
-		ltk_warn("Unknown property \"%s\" for scrollbar style.\n", prop);
+		ltk_warn("Unknown property '%s' for scrollbar style.\n", prop);
 	}
 }
 
@@ -262,7 +264,12 @@ ltk_scrollbar_create(ltk_window *window, ltk_orientation orient, void (*callback
 static void
 ltk_scrollbar_destroy(ltk_widget *self, int shallow) {
 	(void)shallow;
+	char *errstr;
+	if (self->parent && self->parent->vtable->remove_child) {
+		self->parent->vtable->remove_child(
+		    self->window, self, self->parent, &errstr
+		);
+	}
 	ltk_surface_cache_release_key(self->surface_key);
-	ltk_scrollbar *scrollbar = (ltk_scrollbar *)self;
-	ltk_free(scrollbar);
+	ltk_free(self);
 }
diff --git a/src/strtonum.c b/src/strtonum.c
@@ -1,4 +1,6 @@
-/*	$OpenBSD: strtonum.c,v 1.8 2015/09/13 08:31:48 guenther Exp $	*/
+/* Note: Taken from OpenBSD:
+ * $OpenBSD: strtonum.c,v 1.8 2015/09/13 08:31:48 guenther Exp $
+ */
 
 /*
  * Copyright (c) 2004 Ted Unangst and Todd Miller
@@ -17,8 +19,6 @@
  * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
  */
 
-/* #ifndef __OpenBSD__ */
-
 #include <errno.h>
 #include <limits.h>
 #include <stdlib.h>
@@ -28,9 +28,9 @@
 #define	TOOLARGE	3
 
 long long
-strtonum(const char *numstr, long long minval, long long maxval,
-    const char **errstrp)
-{
+ltk_strtonum(
+    const char *numstr, long long minval,
+    long long maxval, const char **errstrp) {
 	long long ll = 0;
 	int error = 0;
 	char *ep;
@@ -65,7 +65,3 @@ strtonum(const char *numstr, long long minval, long long maxval,
 
 	return (ll);
 }
-/* FIXME: What does this do? - lumidify */
-/* DEF_WEAK(strtonum); */
-
-/* #endif */
diff --git a/src/text.h b/src/text.h
@@ -36,6 +36,11 @@ void ltk_text_line_get_size(ltk_text_line *tl, int *w, int *h);
 void ltk_text_line_destroy(ltk_text_line *tl);
 
 /* Draw the entire line to a surface. */
+/* FIXME: Some widgets rely on this to not fail when negative coordinates are given or
+   the text goes outside of the surface boundaries - in the stb backend, this is taken
+   into account and the pango-xft backend doesn't *seem* to have any problems with it,
+   but I don't know if that's guaranteed. Proper clipping would be better, but Pango
+   can't do that. */
 void ltk_text_line_draw(ltk_text_line *tl, ltk_surface *s, ltk_color *color, int x, int y);
 
 /* Get the smallest rectangle of the line that can be drawn while covering 'clip'.
diff --git a/src/text_pango.c b/src/text_pango.c
@@ -48,20 +48,23 @@ struct ltk_text_context {
 	char *default_font;
 };
 
-void
-ltk_text_context_create(ltk_window *window, const char *default_font) {
+ltk_text_context *
+ltk_text_context_create(ltk_window *window, char *default_font) {
 	ltk_text_context *ctx = ltk_malloc(sizeof(ltk_text_context));
 	ctx->window = window;
 	ctx->fontmap = pango_xft_get_font_map(window->dpy, window->screen);
 	ctx->context = pango_font_map_create_context(ctx->fontmap);
 	ctx->default_font = ltk_strdup(default_font);
+	return ctx;
 }
 
 void
 ltk_text_context_destroy(ltk_text_context *ctx) {
 	ltk_free(ctx->default_font);
+	/* FIXME: if both are unref'd, there is a segfault - what is
+	   the normal thing to do here? */
 	g_object_unref(ctx->fontmap);
-	g_object_unref(ctx->context);
+	/*g_object_unref(ctx->context);*/
 	ltk_free(ctx);
 }
 
diff --git a/src/text_stb.c b/src/text_stb.c
@@ -1,3 +1,4 @@
+/* FIXME: max cache size for glyphs */
 /*
  * Copyright (c) 2017, 2018, 2020, 2022 lumidify <nobody@lumidify.org>
  *
@@ -99,7 +100,6 @@ struct ltk_text_context {
 	ltk_font **fonts;
 	int num_fonts;
 	int fonts_bufsize;
-	FcPattern *fcpattern;
 	ltk_font *default_font;
 	uint16_t font_id_cur;
 };
@@ -208,7 +208,6 @@ ltk_text_context_create(ltk_window *window, char *default_font) {
 	ctx->fonts = ltk_malloc(sizeof(ltk_font *));
 	ctx->num_fonts = 0;
 	ctx->fonts_bufsize = 1;
-	ctx->fcpattern = NULL;
 	ltk_load_default_font(ctx, default_font);
 	ctx->font_id_cur = 1;
 	return ctx;
@@ -216,10 +215,10 @@ ltk_text_context_create(ltk_window *window, char *default_font) {
 
 void
 ltk_text_context_destroy(ltk_text_context *ctx) {
-	/* FIXME: destroy fcpattern */
 	for (int i = 0; i < ctx->num_fonts; i++) {
 		ltk_destroy_font(ctx->fonts[i]);
 	}
+	ltk_free(ctx->fonts);
 	if (!ctx->glyph_cache) return;
 	for (khint_t k = kh_begin(ctx->glyph_cache); k != kh_end(ctx->glyph_cache); k++) {
 		if (kh_exist(ctx->glyph_cache, k)) {
@@ -227,6 +226,7 @@ ltk_text_context_destroy(ltk_text_context *ctx) {
 		}
 	}
 	kh_destroy(glyphcache, ctx->glyph_cache);
+	ltk_free(ctx);
 }
 
 static ltk_glyph_info *
@@ -302,18 +302,18 @@ ltk_destroy_glyph_cache(khash_t(glyphinfo) *cache) {
 
 static void
 ltk_load_default_font(ltk_text_context *ctx, char *name) {
-	FcPattern *match;
+	FcPattern *match, *pat;
 	FcResult result;
 	char *file;
 	int index;
 
 	/* FIXME: Get rid of this stupid cast somehow */
-	ctx->fcpattern = FcNameParse((const FcChar8 *)name);
-	/*ctx->fcpattern = FcPatternCreate();*/
-	FcPatternAddString(ctx->fcpattern, FC_FONTFORMAT, (const FcChar8 *)"truetype");
-	FcConfigSubstitute(NULL, ctx->fcpattern, FcMatchPattern);
-	FcDefaultSubstitute(ctx->fcpattern);
-	match = FcFontMatch(NULL, ctx->fcpattern, &result);
+	pat = FcNameParse((const FcChar8 *)name);
+	FcPatternAddString(pat, FC_FONTFORMAT, (const FcChar8 *)"truetype");
+	FcConfigSubstitute(NULL, pat, FcMatchPattern);
+	FcDefaultSubstitute(pat);
+	/* FIXME look at result */
+	match = FcFontMatch(NULL, pat, &result);
 
 	FcPatternGetString(match, FC_FILE, 0, (FcChar8 **) &file);
 	FcPatternGetInteger(match, FC_INDEX, 0, &index);
@@ -321,6 +321,7 @@ ltk_load_default_font(ltk_text_context *ctx, char *name) {
 	ctx->default_font = ltk_get_font(ctx, file, index);
 
 	FcPatternDestroy(match);
+	FcPatternDestroy(pat);
 }
 
 static ltk_font *
@@ -331,6 +332,7 @@ ltk_create_font(char *path, uint16_t id, int index) {
 	if (!contents)
 		ltk_fatal_errno("Unable to read font file %s\n", path);
 	int offset = stbtt_GetFontOffsetForIndex((unsigned char *)contents, index);
+	font->info.data = NULL;
 	if (!stbtt_InitFont(&font->info, (unsigned char *)contents, offset))
 		ltk_fatal("Failed to load font %s\n", path);
 	font->id = id;
@@ -343,6 +345,7 @@ ltk_create_font(char *path, uint16_t id, int index) {
 
 static void
 ltk_destroy_font(ltk_font *font) {
+	ltk_free(font->path);
 	ltk_free(font->info.data);
 	ltk_free(font);
 }
@@ -405,6 +408,7 @@ ltk_text_to_glyphs(ltk_text_context *ctx, ltk_glyph *glyphs, int num_glyphs, cha
 			/* Question: Why does this not work with FcPatternDuplicate? */
 			FcPattern *pat = FcPatternCreate();
 			FcPattern *match;
+			/* FIXME: use result */
 			FcResult result;
 			FcPatternAddBool(pat, FC_SCALABLE, 1);
 			FcConfigSubstitute(NULL, pat, FcMatchPattern);
@@ -506,6 +510,8 @@ ltk_text_line_draw_glyph(ltk_glyph *glyph, int x, int y, XImage *img, XColor fg)
 	int b;
 	for (int i = 0; i < glyph->info->h; i++) {
 		for (int j = 0; j < glyph->info->w; j++) {
+			/* FIXME: this check could be moved to the for loop condition and initialization */
+			/* -> not sure it that would *possibly* be a tiny bit faster */
 			if (y + i >= img->height || x + j >= img->width ||
 			    y + i < 0 || x + i < 0)
 				continue;
@@ -569,8 +575,23 @@ void
 ltk_text_line_draw(ltk_text_line *tl, ltk_surface *s, ltk_color *color, int x, int y) {
 	if (tl->dirty)
 		ltk_text_line_break_lines(tl);
+	int xoff = 0, yoff = 0;
+	if (x < 0) {
+		xoff = x;
+		x = 0;
+	}
+	if (y < 0) {
+		yoff = y;
+		y = 0;
+	}
+	int s_w, s_h;
+	ltk_surface_get_size(s, &s_w, &s_h);
+	int w = x + xoff + tl->w > s_w ? s_w - x : xoff + tl->w;
+	int h = y + yoff + tl->h > s_h ? s_h - y : yoff + tl->h;
+	if (w <= 0 || h <= 0)
+		return;
 	Drawable d = ltk_surface_get_drawable(s);
-	XImage *img = XGetImage(tl->ctx->window->dpy, d, x, y, tl->w, tl->h, 0xFFFFFF, ZPixmap);
+	XImage *img = XGetImage(tl->ctx->window->dpy, d, x, y, w, h, 0xFFFFFF, ZPixmap);
 
 	int last_break = 0;
 	for (int i = 0; i < tl->lines; i++) {
@@ -580,13 +601,13 @@ ltk_text_line_draw(ltk_text_line *tl, ltk_surface *s, ltk_color *color, int x, i
 		else
 			next_break = tl->glyph_len;
 		for (int j = last_break; j < next_break; j++) {
-			int x = tl->glyphs[j].x - tl->glyphs[last_break].x;
-			int y = tl->glyphs[j].y - tl->y_min + tl->line_h * i;
-			ltk_text_line_draw_glyph(&tl->glyphs[j], x, y, img, color->xcolor);
+			int g_x = tl->glyphs[j].x - tl->glyphs[last_break].x + xoff;
+			int g_y = tl->glyphs[j].y - tl->y_min + tl->line_h * i + yoff;
+			ltk_text_line_draw_glyph(&tl->glyphs[j], g_x, g_y, img, color->xcolor);
 		}
 		last_break = next_break;
 	}
-	XPutImage(tl->ctx->window->dpy, d, tl->ctx->window->gc, img, 0, 0, x, y, tl->w, tl->h);
+	XPutImage(tl->ctx->window->dpy, d, tl->ctx->window->gc, img, 0, 0, x, y, w, h);
 	XDestroyImage(img);
 }
 
diff --git a/src/util.h b/src/util.h
@@ -16,11 +16,10 @@
 
 /* Requires: <stdarg.h> */
 
-/* #ifndef __OpenBSD__ */
-long long
-strtonum(const char *numstr, long long minval, long long maxval,
-    const char **errstrp);
-/* #endif */
+long long ltk_strtonum(
+    const char *numstr, long long minval,
+    long long maxval, const char **errstrp
+);
 
 char *ltk_read_file(const char *path, unsigned long *len);
 int ltk_grow_string(char **str, int *alloc_size, int needed);
diff --git a/src/widget.c b/src/widget.c
@@ -1,5 +1,10 @@
 /* FIXME: store coordinates relative to parent widget */
 /* FIXME: Destroy function for widget to destroy pixmap! */
+/* FIXME/NOTE: maybe it would be better to do some sort of
+   inheritance where the generic widget destroy function is
+   called before the specific function for each widget type
+   so each widget doesn't have to manually remove itself from
+   its parent */
 /*
  * Copyright (c) 2021, 2022 lumidify <nobody@lumidify.org>
  *
@@ -35,18 +40,25 @@ static void ltk_destroy_widget_hash(void);
 
 KHASH_MAP_INIT_STR(widget, ltk_widget *)
 static khash_t(widget) *widget_hash = NULL;
+/* Hack to make ltk_destroy_widget_hash work */
+/* FIXME: any better way to do this? */
+static int hash_locked = 0;
 
 static void
 ltk_destroy_widget_hash(void) {
+	hash_locked = 1;
 	khint_t k;
 	ltk_widget *ptr;
 	for (k = kh_begin(widget_hash); k != kh_end(widget_hash); k++) {
 		if (kh_exist(widget_hash, k)) {
 			ptr = kh_value(widget_hash, k);
+			ltk_free((char *)kh_key(widget_hash, k));
 			ptr->vtable->destroy(ptr, 1);
 		}
 	}
 	kh_destroy(widget, widget_hash);
+	widget_hash = NULL;
+	hash_locked = 0;
 }
 
 void
@@ -90,6 +102,7 @@ ltk_fill_widget_defaults(ltk_widget *widget, const char *id, ltk_window *window,
 	widget->column_span = 0;
 	widget->sticky = 0;
 	widget->dirty = 1;
+	widget->hidden = 0;
 }
 
 /* FIXME: Maybe pass the new width as arg here?
@@ -137,15 +150,28 @@ ltk_widget_mouse_release_event(ltk_widget *widget, XEvent event) {
 
 void
 ltk_widget_motion_notify_event(ltk_widget *widget, XEvent event) {
-	if (!widget || widget->state == LTK_DISABLED)
-		return;
 	/* FIXME: THIS WHOLE STATE HANDLING IS STILL PARTIALLY BROKEN */
+	/* FIXME: need to bring back hover state to make enter/leave work properly */
+	/* ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ */
+	/* (especially once keyboard navigation is added) */
+	/* Also, enter/leave should probably be called for all in hierarchy */
 	int set_active = 1;
 	if (widget->window->pressed_widget && widget->window->pressed_widget->vtable->motion_notify) {
 		widget->window->pressed_widget->vtable->motion_notify(widget->window->pressed_widget, event);
 		set_active = 0;
-	} else if (widget->vtable->motion_notify) {
-		set_active = widget->vtable->motion_notify(widget, event);
+	} else if (widget && widget->state != LTK_DISABLED) {
+		/* FIXME: because only the bottom widget of the hierarchy is stored,
+		   this *really* does not work properly! */
+		if (widget != widget->window->active_widget) {
+			if (widget->window->active_widget && widget->window->active_widget->vtable->mouse_leave) {
+				widget->window->active_widget->vtable->mouse_leave(widget->window->active_widget, event);
+			}
+			if (widget->vtable->mouse_enter) {
+				widget->vtable->mouse_enter(widget, event);
+			}
+		}
+		if (widget->vtable->motion_notify)
+			set_active = widget->vtable->motion_notify(widget, event);
 	}
 	if (set_active)
 		ltk_window_set_active_widget(widget->window, widget);
@@ -182,8 +208,7 @@ void
 ltk_set_widget(ltk_widget *widget, const char *id) {
 	int ret;
 	khint_t k;
-	/* apparently, khash requires the string to stay accessible */
-	/* FIXME: How is this freed? */
+	/* FIXME: make sure no widget is overwritten here */
 	char *tmp = ltk_strdup(id);
 	k = kh_put(widget, widget_hash, tmp, &ret);
 	kh_value(widget_hash, k) = widget;
@@ -191,20 +216,40 @@ ltk_set_widget(ltk_widget *widget, const char *id) {
 
 void
 ltk_remove_widget(const char *id) {
+	if (hash_locked)
+		return;
 	khint_t k;
 	k = kh_get(widget, widget_hash, id);
 	if (k != kh_end(widget_hash)) {
+		ltk_free((char *)kh_key(widget_hash, k));
 		kh_del(widget, widget_hash, k);
 	}
 }
 
 int
-ltk_widget_destroy(
+ltk_widget_destroy(ltk_widget *widget, int shallow, char **errstr) {
+	/* widget->parent->remove_child should never be NULL because of the fact that
+	   the widget is set as parent, but let's just check anyways... */
+	int err = 0;
+	/* FIXME: why is window passed here? */
+	if (widget->parent && widget->parent->vtable->remove_child) {
+		err = widget->parent->vtable->remove_child(
+		    widget->window, widget, widget->parent, errstr
+		);
+	}
+	widget->vtable->destroy(widget, shallow);
+
+	return err;
+}
+
+int
+ltk_widget_destroy_cmd(
     ltk_window *window,
     char **tokens,
     size_t num_tokens,
     char **errstr) {
-	int err = 0, shallow = 1;
+	(void)window;
+	int shallow = 1;
 	if (num_tokens != 2 && num_tokens != 3) {
 		*errstr = "Invalid number of arguments.\n";
 		return 1;
@@ -224,15 +269,5 @@ ltk_widget_destroy(
 		*errstr = "Invalid widget ID.\n";
 		return 1;
 	}
-	ltk_remove_widget(tokens[1]);
-	/* widget->parent->remove_child should never be NULL because of the fact that
-	   the widget is set as parent, but let's just check anyways... */
-	if (widget->parent && widget->parent->vtable->remove_child) {
-		err = widget->parent->vtable->remove_child(
-		    window, widget, widget->parent, errstr
-		);
-	}
-	widget->vtable->destroy(widget, shallow);
-
-	return err;
+	return ltk_widget_destroy(widget, shallow, errstr);
 }
diff --git a/src/widget.h b/src/widget.h
@@ -53,6 +53,7 @@ typedef enum {
 	LTK_LABEL,
 	LTK_WIDGET,
 	LTK_BOX,
+	LTK_MENU,
 	LTK_NUM_WIDGETS
 } ltk_widget_type;
 
@@ -81,6 +82,7 @@ struct ltk_widget {
 	unsigned short row_span;
 	unsigned short column_span;
 	char dirty;
+	char hidden;
 };
 
 struct ltk_widget_vtable {
@@ -90,10 +92,11 @@ struct ltk_widget_vtable {
 	int (*mouse_release) (struct ltk_widget *, XEvent);
 	int (*mouse_wheel) (struct ltk_widget *, XEvent);
 	int (*motion_notify) (struct ltk_widget *, XEvent);
-	void (*mouse_leave) (struct ltk_widget *, XEvent);
-	void (*mouse_enter) (struct ltk_widget *, XEvent);
+	int (*mouse_leave) (struct ltk_widget *, XEvent);
+	int (*mouse_enter) (struct ltk_widget *, XEvent);
 
 	void (*resize) (struct ltk_widget *);
+	void (*hide) (struct ltk_widget *);
 	void (*draw) (struct ltk_widget *, ltk_rect);
 	void (*change_state) (struct ltk_widget *);
 	void (*destroy) (struct ltk_widget *, int);
@@ -106,7 +109,8 @@ struct ltk_widget_vtable {
 	char needs_surface;
 };
 
-int ltk_widget_destroy(struct ltk_window *window, char **tokens, size_t num_tokens, char **errstr);
+int ltk_widget_destroy(ltk_widget *widget, int shallow, char **errstr);
+int ltk_widget_destroy_cmd(struct ltk_window *window, char **tokens, size_t num_tokens, char **errstr);
 void ltk_fill_widget_defaults(ltk_widget *widget, const char *id, struct ltk_window *window,
     struct ltk_widget_vtable *vtable, int w, int h);
 void ltk_widget_change_state(ltk_widget *widget);
diff --git a/test.sh b/test.sh
@@ -2,7 +2,7 @@
 
 # This is very hacky.
 #
-# All events are still printed to the terminal curerntly because
+# All events are still printed to the terminal currently because
 # the second './ltkc' still prints everything - event masks aren't
 # supported yet.
 
diff --git a/test2.gui b/test2.gui
@@ -0,0 +1,25 @@
+grid grd1 create 2 1
+grid grd1 set-row-weight 1 1
+grid grd1 set-column-weight 0 1
+set-root-widget grd1
+menu menu1 create
+menu menu1 add-entry entry1 "Menu Entry"
+menu menu1 add-entry entrya1 "Menu Entry 2"
+submenu submenu1 create
+menu submenu1 add-entry entry2 "Submenu Entry 1"
+menu submenu1 add-entry entry6 "Submenu Entry 2"
+menu submenu1 add-entry entry7 "Submenu Entry 3"
+menu submenu1 add-entry entry8 "Submenu Entry 4"
+menu submenu1 add-entry entry9 "Submenu Entry 5"
+menu submenu1 add-entry entry10 "Submenu Entry 6"
+menu submenu1 add-entry entry11 "Submenu Entry 7"
+menu submenu1 add-entry entry12 "Submenu Entry 8"
+menu submenu1 add-entry entry13 "Submenu Entry 9"
+menu menu1 add-submenu entry3 "Submenu" submenu1
+submenu submenu2 create
+menu submenu2 add-entry entry4 "Submenu Entry"
+menu submenu1 add-submenu entry5 "Submenu" submenu2
+submenu submenu3 create
+menu submenu3 add-entry entrya3 "Submenu Entry"
+menu submenu2 add-submenu entrya2 "Submenu" submenu3
+grid grd1 add menu1 0 0 1 1 ew
diff --git a/test2.sh b/test2.sh
@@ -0,0 +1,10 @@
+#!/bin/sh
+
+export LTKDIR="`pwd`/.ltk"
+ltk_id=`./src/ltkd -t "Cool Window"`
+if [ $? -ne 0 ]; then
+	echo "Unable to start ltkd." >&2
+	exit 1
+fi
+
+cat test2.gui | ./src/ltkc $ltk_id