Index: uspace/lib/ui/src/paint.c
===================================================================
--- uspace/lib/ui/src/paint.c	(revision b1f0a141d37cd3a919a708e87dbff0bd6527788e)
+++ uspace/lib/ui/src/paint.c	(revision 1eaead40ef1e81dc0e203498597d60b2e71fcd27)
@@ -1,4 +1,4 @@
 /*
- * Copyright (c) 2022 Jiri Svoboda
+ * Copyright (c) 2023 Jiri Svoboda
  * All rights reserved.
  *
@@ -632,14 +632,17 @@
 }
 
-/** Paint a text box.
+/** Paint a custom text box.
+ *
+ * Paint a text box using user-provided box chars.
  *
  * @param resource UI resource
  * @param rect Rectangle inside which to paint the box
  * @param style Box style
+ * @param boxc Box characters
  * @param color Color
  * @return EOK on success or an error code
  */
-errno_t ui_paint_text_box(ui_resource_t *resource, gfx_rect_t *rect,
-    ui_box_style_t style, gfx_color_t *color)
+errno_t ui_paint_text_box_custom(ui_resource_t *resource, gfx_rect_t *rect,
+    ui_box_chars_t *boxc, gfx_color_t *color)
 {
 	errno_t rc;
@@ -653,5 +656,4 @@
 	gfx_coord_t y;
 	char *str = NULL;
-	ui_box_chars_t *boxc = NULL;
 
 	gfx_rect_points_sort(rect, &srect);
@@ -661,4 +663,93 @@
 	if (dim.x < 2 || dim.y < 2)
 		return EOK;
+
+	gfx_text_fmt_init(&fmt);
+	fmt.font = resource->font;
+	fmt.color = color;
+
+	bufsz = str_size(boxc->c[0][0]) +
+	    str_size(boxc->c[0][1]) * (dim.x - 2) +
+	    str_size(boxc->c[0][2]) + 1;
+
+	str = malloc(bufsz);
+	if (str == NULL)
+		return ENOMEM;
+
+	/* Top edge and corners */
+
+	str_cpy(str, bufsz, boxc->c[0][0]);
+	off = str_size(boxc->c[0][0]);
+
+	for (i = 1; i < dim.x - 1; i++) {
+		str_cpy(str + off, bufsz - off, boxc->c[0][1]);
+		off += str_size(boxc->c[0][1]);
+	}
+
+	str_cpy(str + off, bufsz - off, boxc->c[0][2]);
+	off += str_size(boxc->c[0][2]);
+	str[off] = '\0';
+
+	pos = rect->p0;
+	rc = gfx_puttext(&pos, &fmt, str);
+	if (rc != EOK)
+		goto error;
+
+	/* Vertical edges */
+	for (y = rect->p0.y + 1; y < rect->p1.y - 1; y++) {
+		pos.y = y;
+
+		pos.x = rect->p0.x;
+		rc = gfx_puttext(&pos, &fmt, boxc->c[1][0]);
+		if (rc != EOK)
+			goto error;
+
+		pos.x = rect->p1.x - 1;
+		rc = gfx_puttext(&pos, &fmt, boxc->c[1][2]);
+		if (rc != EOK)
+			goto error;
+	}
+
+	/* Bottom edge and corners */
+
+	str_cpy(str, bufsz, boxc->c[2][0]);
+	off = str_size(boxc->c[2][0]);
+
+	for (i = 1; i < dim.x - 1; i++) {
+		str_cpy(str + off, bufsz - off, boxc->c[2][1]);
+		off += str_size(boxc->c[2][1]);
+	}
+
+	str_cpy(str + off, bufsz - off, boxc->c[2][2]);
+	off += str_size(boxc->c[2][2]);
+	str[off] = '\0';
+
+	pos.x = rect->p0.x;
+	pos.y = rect->p1.y - 1;
+	rc = gfx_puttext(&pos, &fmt, str);
+	if (rc != EOK)
+		goto error;
+
+	free(str);
+	return EOK;
+error:
+	if (str != NULL)
+		free(str);
+	return rc;
+}
+
+/** Paint a text box.
+ *
+ * Paint a text box with the specified style.
+ *
+ * @param resource UI resource
+ * @param rect Rectangle inside which to paint the box
+ * @param style Box style
+ * @param color Color
+ * @return EOK on success or an error code
+ */
+errno_t ui_paint_text_box(ui_resource_t *resource, gfx_rect_t *rect,
+    ui_box_style_t style, gfx_color_t *color)
+{
+	ui_box_chars_t *boxc = NULL;
 
 	switch (style) {
@@ -674,76 +765,5 @@
 		return EINVAL;
 
-	gfx_text_fmt_init(&fmt);
-	fmt.font = resource->font;
-	fmt.color = color;
-
-	bufsz = str_size(boxc->c[0][0]) +
-	    str_size(boxc->c[0][1]) * (dim.x - 2) +
-	    str_size(boxc->c[0][2]) + 1;
-
-	str = malloc(bufsz);
-	if (str == NULL)
-		return ENOMEM;
-
-	/* Top edge and corners */
-
-	str_cpy(str, bufsz, boxc->c[0][0]);
-	off = str_size(boxc->c[0][0]);
-
-	for (i = 1; i < dim.x - 1; i++) {
-		str_cpy(str + off, bufsz - off, boxc->c[0][1]);
-		off += str_size(boxc->c[0][1]);
-	}
-
-	str_cpy(str + off, bufsz - off, boxc->c[0][2]);
-	off += str_size(boxc->c[0][2]);
-	str[off] = '\0';
-
-	pos = rect->p0;
-	rc = gfx_puttext(&pos, &fmt, str);
-	if (rc != EOK)
-		goto error;
-
-	/* Vertical edges */
-	for (y = rect->p0.y + 1; y < rect->p1.y - 1; y++) {
-		pos.y = y;
-
-		pos.x = rect->p0.x;
-		rc = gfx_puttext(&pos, &fmt, boxc->c[1][0]);
-		if (rc != EOK)
-			goto error;
-
-		pos.x = rect->p1.x - 1;
-		rc = gfx_puttext(&pos, &fmt, boxc->c[1][2]);
-		if (rc != EOK)
-			goto error;
-	}
-
-	/* Bottom edge and corners */
-
-	str_cpy(str, bufsz, boxc->c[2][0]);
-	off = str_size(boxc->c[2][0]);
-
-	for (i = 1; i < dim.x - 1; i++) {
-		str_cpy(str + off, bufsz - off, boxc->c[2][1]);
-		off += str_size(boxc->c[2][1]);
-	}
-
-	str_cpy(str + off, bufsz - off, boxc->c[2][2]);
-	off += str_size(boxc->c[2][2]);
-	str[off] = '\0';
-
-	pos.x = rect->p0.x;
-	pos.y = rect->p1.y - 1;
-	rc = gfx_puttext(&pos, &fmt, str);
-	if (rc != EOK)
-		goto error;
-
-	free(str);
-	return EOK;
-error:
-	if (str != NULL)
-		free(str);
-	return rc;
+	return ui_paint_text_box_custom(resource, rect, boxc, color);
 }
 
Index: uspace/lib/ui/src/tab.c
===================================================================
--- uspace/lib/ui/src/tab.c	(revision 1eaead40ef1e81dc0e203498597d60b2e71fcd27)
+++ uspace/lib/ui/src/tab.c	(revision 1eaead40ef1e81dc0e203498597d60b2e71fcd27)
@@ -0,0 +1,671 @@
+/*
+ * Copyright (c) 2023 Jiri Svoboda
+ * All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions
+ * are met:
+ *
+ * - Redistributions of source code must retain the above copyright
+ *   notice, this list of conditions and the following disclaimer.
+ * - Redistributions in binary form must reproduce the above copyright
+ *   notice, this list of conditions and the following disclaimer in the
+ *   documentation and/or other materials provided with the distribution.
+ * - The name of the author may not be used to endorse or promote products
+ *   derived from this software without specific prior written permission.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS OR
+ * IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES
+ * OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED.
+ * IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT,
+ * INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT
+ * NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF
+ * THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+/** @addtogroup libui
+ * @{
+ */
+/**
+ * @file Tab
+ */
+
+#include <adt/list.h>
+#include <errno.h>
+#include <gfx/color.h>
+#include <gfx/context.h>
+#include <gfx/render.h>
+#include <gfx/text.h>
+#include <io/pos_event.h>
+#include <stdlib.h>
+#include <str.h>
+#include <uchar.h>
+#include <ui/control.h>
+#include <ui/paint.h>
+#include <ui/popup.h>
+#include <ui/tab.h>
+#include <ui/tabset.h>
+#include <ui/resource.h>
+#include <ui/window.h>
+#include "../private/control.h"
+#include "../private/tab.h"
+#include "../private/tabset.h"
+#include "../private/resource.h"
+
+enum {
+	/** Horizontal margin before first tab handle */
+	tab_start_hmargin = 6,
+	/** Horizontal margin before first tab handle in text mode */
+	tab_start_hmargin_text = 1,
+	/** Tab handle horizontal internal padding */
+	tab_handle_hpad = 6,
+	/** Tab handle top internal padding */
+	tab_handle_top_pad = 5,
+	/** Tab handle bottom internal padding */
+	tab_handle_bottom_pad = 5,
+	/** Tab handle horizontal internal padding in text mode */
+	tab_handle_hpad_text = 1,
+	/** Tab handle top internal padding in text mode */
+	tab_handle_top_pad_text = 0,
+	/** Tab handle bototm internal padding in text mode */
+	tab_handle_bottom_pad_text = 1,
+	/** Tab handle chamfer */
+	tab_handle_chamfer = 3,
+	/** Number of pixels to pull active handle up by */
+	tab_handle_pullup = 2,
+	/** Tab frame horizontal thickness */
+	tab_frame_w = 2,
+	/** Tab frame vertical thickness */
+	tab_frame_h = 2,
+	/** Tab frame horizontal thickness in text mode */
+	tab_frame_w_text = 1,
+	/** Tab frame horizontal thickness in text mode */
+	tab_frame_h_text = 1
+};
+
+/** Selected tab handle box characters */
+static ui_box_chars_t sel_tab_box_chars = {
+	{
+		{ "\u250c", "\u2500", "\u2510" },
+		{ "\u2502", " ",      "\u2502" },
+		{ "\u2518", " ",      "\u2514" }
+	}
+};
+
+/** Not selected tab handle box characters */
+static ui_box_chars_t unsel_tab_box_chars = {
+	{
+		{ "\u250c", "\u2500", "\u2510" },
+		{ "\u2502", " ",      "\u2502" },
+		{ "\u2534", "\u2500", "\u2534" }
+	}
+};
+
+/** Create new tab.
+ *
+ * @param tabset Tab set
+ * @param caption Caption
+ * @param rtab Place to store pointer to new tab
+ * @return EOK on success, ENOMEM if out of memory
+ */
+errno_t ui_tab_create(ui_tab_set_t *tabset, const char *caption,
+    ui_tab_t **rtab)
+{
+	ui_tab_t *tab;
+	ui_tab_t *prev;
+
+	tab = calloc(1, sizeof(ui_tab_t));
+	if (tab == NULL)
+		return ENOMEM;
+
+	tab->caption = str_dup(caption);
+	if (tab->caption == NULL) {
+		free(tab);
+		return ENOMEM;
+	}
+
+	prev = ui_tab_last(tabset);
+	if (prev != NULL)
+		tab->xoff = prev->xoff + ui_tab_handle_width(prev);
+	else
+		tab->xoff = tabset->res->textmode ?
+		    tab_start_hmargin_text : tab_start_hmargin;
+
+	tab->tabset = tabset;
+	list_append(&tab->ltabs, &tabset->tabs);
+
+	/* This is the first tab. Select it. */
+	if (tabset->selected == NULL)
+		tabset->selected = tab;
+
+	*rtab = tab;
+	return EOK;
+}
+
+/** Destroy tab.
+ *
+ * @param tab Tab or @c NULL
+ */
+void ui_tab_destroy(ui_tab_t *tab)
+{
+	if (tab == NULL)
+		return;
+
+	/* Destroy content */
+	ui_control_destroy(tab->content);
+
+	list_remove(&tab->ltabs);
+	free(tab->caption);
+	free(tab);
+}
+
+/** Get first tab in tab bar.
+ *
+ * @param tabset Tab set
+ * @return First tab or @c NULL if there is none
+ */
+ui_tab_t *ui_tab_first(ui_tab_set_t *tabset)
+{
+	link_t *link;
+
+	link = list_first(&tabset->tabs);
+	if (link == NULL)
+		return NULL;
+
+	return list_get_instance(link, ui_tab_t, ltabs);
+}
+
+/** Get next tab in tab bar.
+ *
+ * @param cur Current tab
+ * @return Next tab or @c NULL if @a cur is the last one
+ */
+ui_tab_t *ui_tab_next(ui_tab_t *cur)
+{
+	link_t *link;
+
+	link = list_next(&cur->ltabs, &cur->tabset->tabs);
+	if (link == NULL)
+		return NULL;
+
+	return list_get_instance(link, ui_tab_t, ltabs);
+}
+
+/** Get last tab in tab bar.
+ *
+ * @param tabset Tab set
+ * @return Last tab or @c NULL if there is none
+ */
+ui_tab_t *ui_tab_last(ui_tab_set_t *tabset)
+{
+	link_t *link;
+
+	link = list_last(&tabset->tabs);
+	if (link == NULL)
+		return NULL;
+
+	return list_get_instance(link, ui_tab_t, ltabs);
+}
+
+/** Get previous tab in tab bar.
+ *
+ * @param cur Current tab
+ * @return Previous tab or @c NULL if @a cur is the fist one
+ */
+ui_tab_t *ui_tab_prev(ui_tab_t *cur)
+{
+	link_t *link;
+
+	link = list_prev(&cur->ltabs, &cur->tabset->tabs);
+	if (link == NULL)
+		return NULL;
+
+	return list_get_instance(link, ui_tab_t, ltabs);
+}
+
+/** Determine if tab is selected.
+ *
+ * @param tab Tab
+ * @return @c true iff tab is selected
+ */
+bool ui_tab_is_selected(ui_tab_t *tab)
+{
+	return tab->tabset->selected == tab;
+}
+
+/** Add control to tab.
+ *
+ * Only one control can be added to a window. If more than one control
+ * is added, the results are undefined.
+ *
+ * @param tab Tab
+ * @param control Control
+ */
+void ui_tab_add(ui_tab_t *tab, ui_control_t *control)
+{
+	assert(tab->content == NULL);
+
+	tab->content = control;
+	control->elemp = (void *) tab;
+}
+
+/** Remove control from tab.
+ *
+ * @param tab Tab
+ * @param control Control
+ */
+void ui_tab_remove(ui_tab_t *tab, ui_control_t *control)
+{
+	assert(tab->content == control);
+	assert((ui_tab_t *) control->elemp == tab);
+
+	tab->content = NULL;
+	control->elemp = NULL;
+}
+
+/** Get tab handle width.
+ *
+ * @param tab Tab
+ * @return Handle width in pixels
+ */
+gfx_coord_t ui_tab_handle_width(ui_tab_t *tab)
+{
+	ui_resource_t *res;
+	gfx_coord_t frame_w;
+	gfx_coord_t handle_hpad;
+	gfx_coord_t text_w;
+
+	res = tab->tabset->res;
+	if (!res->textmode) {
+		frame_w = tab_frame_w;
+		handle_hpad = tab_handle_hpad;
+	} else {
+		frame_w = tab_frame_w_text;
+		handle_hpad = tab_handle_hpad_text;
+	}
+
+	text_w = ui_text_width(tab->tabset->res->font, tab->caption);
+	return 2 * frame_w + 2 * handle_hpad + text_w;
+}
+
+/** Get tab handle height.
+ *
+ * @param tab Tab
+ * @return Handle height in pixels
+ */
+gfx_coord_t ui_tab_handle_height(ui_tab_t *tab)
+{
+	gfx_coord_t frame_h;
+	gfx_coord_t handle_top_pad;
+	gfx_coord_t handle_bottom_pad;
+	gfx_font_metrics_t metrics;
+	ui_resource_t *res;
+
+	res = tab->tabset->res;
+	gfx_font_get_metrics(tab->tabset->res->font, &metrics);
+
+	if (!res->textmode) {
+		frame_h = tab_frame_h;
+		handle_top_pad = tab_handle_top_pad;
+		handle_bottom_pad = tab_handle_bottom_pad;
+	} else {
+		frame_h = tab_frame_h_text;
+		handle_top_pad = tab_handle_top_pad_text;
+		handle_bottom_pad = tab_handle_bottom_pad_text;
+	}
+
+	return frame_h + handle_top_pad + metrics.ascent +
+	    metrics.descent + 1 + handle_bottom_pad;
+}
+
+/** Get tab geometry.
+ *
+ * @param tab Tab
+ * @param geom Structure to fill in with computed geometry
+ */
+void ui_tab_get_geom(ui_tab_t *tab, ui_tab_geom_t *geom)
+{
+	gfx_coord_t handle_w;
+	gfx_coord_t handle_h;
+	gfx_coord_t pullup;
+	gfx_coord_t frame_w;
+	gfx_coord_t frame_h;
+	gfx_coord_t handle_hpad;
+	gfx_coord_t handle_top_pad;
+	ui_resource_t *res;
+
+	res = tab->tabset->res;
+
+	handle_w = ui_tab_handle_width(tab);
+	handle_h = ui_tab_handle_height(tab);
+	pullup = res->textmode ? 0 : tab_handle_pullup;
+
+	if (!res->textmode) {
+		frame_w = tab_frame_w;
+		frame_h = tab_frame_h;
+		handle_hpad = tab_handle_hpad;
+		handle_top_pad = tab_handle_top_pad;
+	} else {
+		frame_w = tab_frame_w_text;
+		frame_h = tab_frame_h_text;
+		handle_hpad = tab_handle_hpad_text;
+		handle_top_pad = tab_handle_top_pad_text;
+	}
+
+	/* Entire handle area */
+	geom->handle_area.p0.x = tab->tabset->rect.p0.x + tab->xoff;
+	geom->handle_area.p0.y = tab->tabset->rect.p0.y;
+	geom->handle_area.p1.x = geom->handle_area.p0.x + handle_w;
+	geom->handle_area.p1.y = geom->handle_area.p0.y + handle_h + pullup;
+
+	geom->handle = geom->handle_area;
+
+	/* If handle is selected */
+	if (!ui_tab_is_selected(tab)) {
+		/* Push top of handle down a bit */
+		geom->handle.p0.y += pullup;
+		/* Do not paint background over tab body frame */
+		geom->handle_area.p1.y -= pullup;
+	}
+
+	/* Caption text position */
+	geom->text_pos.x = geom->handle.p0.x + frame_w + handle_hpad;
+	geom->text_pos.y = geom->handle.p0.y + frame_h + handle_top_pad;
+
+	/* Tab body */
+	geom->body.p0.x = tab->tabset->rect.p0.x;
+	geom->body.p0.y = tab->tabset->rect.p0.y + handle_h - frame_h +
+	    pullup;
+	geom->body.p1 = tab->tabset->rect.p1;
+}
+
+/** Get UI resource from tab.
+ *
+ * @param tab Tab
+ * @return UI resource
+ */
+ui_resource_t *ui_tab_get_res(ui_tab_t *tab)
+{
+	return tab->tabset->res;
+}
+
+/** Paint tab handle frame.
+ *
+ * @param gc Graphic context
+ * @param rect Rectangle
+ * @param chamfer Chamfer
+ * @param hi_color Highlight color
+ * @param sh_color Shadow color
+ * @param selected Tab is selected
+ * @param irect Place to store interior rectangle
+ * @return EOK on success or an error code
+ */
+errno_t ui_tab_paint_handle_frame(gfx_context_t *gc, gfx_rect_t *rect,
+    gfx_coord_t chamfer, gfx_color_t *hi_color, gfx_color_t *sh_color,
+    bool selected, gfx_rect_t *irect)
+{
+	gfx_rect_t r;
+	gfx_coord_t i;
+	errno_t rc;
+
+	rc = gfx_set_color(gc, hi_color);
+	if (rc != EOK)
+		goto error;
+
+	/* Left side */
+	r.p0.x = rect->p0.x;
+	r.p0.y = rect->p0.y + chamfer;
+	r.p1.x = rect->p0.x + 1;
+	r.p1.y = rect->p1.y - 2;
+	rc = gfx_fill_rect(gc, &r);
+	if (rc != EOK)
+		goto error;
+
+	/* Top-left chamfer */
+	for (i = 1; i < chamfer; i++) {
+		r.p0.x = rect->p0.x + i;
+		r.p0.y = rect->p0.y + chamfer - i;
+		r.p1.x = r.p0.x + 1;
+		r.p1.y = r.p0.y + 1;
+		rc = gfx_fill_rect(gc, &r);
+		if (rc != EOK)
+			goto error;
+	}
+
+	/* Top side */
+	r.p0.x = rect->p0.x + chamfer;
+	r.p0.y = rect->p0.y;
+	r.p1.x = rect->p1.x - chamfer;
+	r.p1.y = rect->p0.y + 1;
+	rc = gfx_fill_rect(gc, &r);
+	if (rc != EOK)
+		goto error;
+
+	rc = gfx_set_color(gc, sh_color);
+	if (rc != EOK)
+		goto error;
+
+	/* Top-right chamfer */
+	for (i = 1; i < chamfer; i++) {
+		r.p0.x = rect->p1.x - 1 - i;
+		r.p0.y = rect->p0.y + chamfer - i;
+		r.p1.x = r.p0.x + 1;
+		r.p1.y = r.p0.y + 1;
+		rc = gfx_fill_rect(gc, &r);
+		if (rc != EOK)
+			goto error;
+	}
+
+	/* Right side */
+	r.p0.x = rect->p1.x - 1;
+	r.p0.y = rect->p0.y + chamfer;
+	r.p1.x = rect->p1.x;
+	r.p1.y = rect->p1.y - 2;
+	rc = gfx_fill_rect(gc, &r);
+	if (rc != EOK)
+		goto error;
+
+	irect->p0.x = rect->p0.x + 1;
+	irect->p0.y = rect->p0.y + 1;
+	irect->p1.x = rect->p1.x - 1;
+	irect->p1.y = rect->p1.y;
+	return EOK;
+error:
+	return rc;
+}
+
+/** Paint tab body frame.
+ *
+ * @param tab Tab
+ * @return EOK on success or an error code
+ */
+errno_t ui_tab_paint_body_frame(ui_tab_t *tab)
+{
+	gfx_rect_t bg_rect;
+	ui_tab_geom_t geom;
+	ui_resource_t *res;
+	errno_t rc;
+
+	res = ui_tab_get_res(tab);
+	ui_tab_get_geom(tab, &geom);
+
+	if (!res->textmode) {
+		rc = ui_paint_outset_frame(res, &geom.body, &bg_rect);
+		if (rc != EOK)
+			goto error;
+	} else {
+		rc = ui_paint_text_box(res, &geom.body, ui_box_single,
+		    res->wnd_face_color);
+		if (rc != EOK)
+			goto error;
+
+		bg_rect.p0.x = geom.body.p0.x + 1;
+		bg_rect.p0.y = geom.body.p0.y + 1;
+		bg_rect.p1.x = geom.body.p1.x - 1;
+		bg_rect.p1.y = geom.body.p1.y - 1;
+	}
+
+	rc = gfx_set_color(res->gc, res->wnd_face_color);
+	if (rc != EOK)
+		goto error;
+
+	rc = gfx_fill_rect(res->gc, &bg_rect);
+	if (rc != EOK)
+		goto error;
+
+	return EOK;
+error:
+	return rc;
+}
+
+/** Paint tab frame.
+ *
+ * @param tab Tab
+ * @return EOK on success or an error code
+ */
+errno_t ui_tab_paint_frame(ui_tab_t *tab)
+{
+	gfx_rect_t r0;
+	ui_tab_geom_t geom;
+	ui_resource_t *res;
+	errno_t rc;
+
+	res = ui_tab_get_res(tab);
+	ui_tab_get_geom(tab, &geom);
+
+	/* Paint handle background */
+
+	rc = gfx_set_color(res->gc, res->wnd_face_color);
+	if (rc != EOK)
+		goto error;
+
+	rc = gfx_fill_rect(res->gc, &geom.handle_area);
+	if (rc != EOK)
+		goto error;
+
+	/* Paint handle frame */
+	if (!res->textmode) {
+		rc = ui_tab_paint_handle_frame(res->gc, &geom.handle,
+		    tab_handle_chamfer, res->wnd_frame_hi_color, res->wnd_frame_sh_color,
+		    ui_tab_is_selected(tab), &r0);
+		if (rc != EOK)
+			goto error;
+
+		rc = ui_tab_paint_handle_frame(res->gc, &r0, tab_handle_chamfer - 1,
+		    res->wnd_highlight_color, res->wnd_shadow_color,
+		    ui_tab_is_selected(tab), &r0);
+		if (rc != EOK)
+			goto error;
+	} else {
+		rc = ui_paint_text_box_custom(res, &geom.handle,
+		    ui_tab_is_selected(tab) ? &sel_tab_box_chars :
+		    &unsel_tab_box_chars, res->wnd_face_color);
+		if (rc != EOK)
+			goto error;
+	}
+
+	return EOK;
+error:
+	return rc;
+}
+
+/** Paint tab.
+ *
+ * @param tab Tab
+ * @return EOK on success or an error code
+ */
+errno_t ui_tab_paint(ui_tab_t *tab)
+{
+	gfx_text_fmt_t fmt;
+	ui_tab_geom_t geom;
+	ui_resource_t *res;
+	errno_t rc;
+
+	res = ui_tab_get_res(tab);
+	ui_tab_get_geom(tab, &geom);
+
+	rc = ui_tab_paint_frame(tab);
+	if (rc != EOK)
+		goto error;
+
+	/* Paint caption */
+
+	gfx_text_fmt_init(&fmt);
+	fmt.font = res->font;
+	fmt.halign = gfx_halign_left;
+	fmt.valign = gfx_valign_top;
+	fmt.color = res->wnd_text_color;
+
+	rc = gfx_puttext(&geom.text_pos, &fmt, tab->caption);
+	if (rc != EOK)
+		goto error;
+
+	if (tab->content != NULL && ui_tab_is_selected(tab)) {
+		/* Paint content */
+		rc = ui_control_paint(tab->content);
+		if (rc != EOK)
+			goto error;
+	}
+
+	rc = gfx_update(res->gc);
+	if (rc != EOK)
+		goto error;
+
+	return EOK;
+error:
+	return rc;
+}
+
+/** Handle position event in tab.
+ *
+ * @param tab Tab
+ * @param event Position event
+ * @return ui_claimed iff the event was claimed
+ */
+ui_evclaim_t ui_tab_pos_event(ui_tab_t *tab, pos_event_t *event)
+{
+	ui_tab_geom_t geom;
+	gfx_coord2_t epos;
+
+	ui_tab_get_geom(tab, &geom);
+	epos.x = event->hpos;
+	epos.y = event->vpos;
+
+	/* Event inside tab handle? */
+	if (gfx_pix_inside_rect(&epos, &geom.handle)) {
+		/* Select tab? */
+		if (event->type == POS_PRESS && event->btn_num == 1 &&
+		    !ui_tab_is_selected(tab))
+			ui_tab_set_select(tab->tabset, tab);
+
+		/* Claim event */
+		return ui_claimed;
+	}
+
+	/* Deliver event to content control, if any */
+	if (ui_tab_is_selected(tab) && tab->content != NULL)
+		return ui_control_pos_event(tab->content, event);
+
+	return ui_unclaimed;
+}
+
+/** Handle keyboard event in tab.
+ *
+ * @param tab Tab
+ * @param event Keyboard event
+ * @return ui_claimed iff the event was claimed
+ */
+ui_evclaim_t ui_tab_kbd_event(ui_tab_t *tab, kbd_event_t *event)
+{
+	/* Deliver event to content control, if any */
+	if (ui_tab_is_selected(tab) && tab->content != NULL)
+		return ui_control_kbd_event(tab->content, event);
+
+	return ui_unclaimed;
+}
+
+/** @}
+ */
Index: uspace/lib/ui/src/tabset.c
===================================================================
--- uspace/lib/ui/src/tabset.c	(revision 1eaead40ef1e81dc0e203498597d60b2e71fcd27)
+++ uspace/lib/ui/src/tabset.c	(revision 1eaead40ef1e81dc0e203498597d60b2e71fcd27)
@@ -0,0 +1,278 @@
+/*
+ * Copyright (c) 2023 Jiri Svoboda
+ * All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions
+ * are met:
+ *
+ * - Redistributions of source code must retain the above copyright
+ *   notice, this list of conditions and the following disclaimer.
+ * - Redistributions in binary form must reproduce the above copyright
+ *   notice, this list of conditions and the following disclaimer in the
+ *   documentation and/or other materials provided with the distribution.
+ * - The name of the author may not be used to endorse or promote products
+ *   derived from this software without specific prior written permission.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS OR
+ * IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES
+ * OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED.
+ * IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT,
+ * INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT
+ * NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF
+ * THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+/** @addtogroup libui
+ * @{
+ */
+/**
+ * @file Tab set
+ */
+
+#include <adt/list.h>
+#include <errno.h>
+#include <gfx/render.h>
+#include <io/pos_event.h>
+#include <stdlib.h>
+#include <ui/control.h>
+#include <ui/tab.h>
+#include <ui/tabset.h>
+#include <ui/window.h>
+#include "../private/tab.h"
+#include "../private/tabset.h"
+#include "../private/resource.h"
+
+static void ui_tab_set_ctl_destroy(void *);
+static errno_t ui_tab_set_ctl_paint(void *);
+static ui_evclaim_t ui_tab_set_ctl_kbd_event(void *, kbd_event_t *);
+static ui_evclaim_t ui_tab_set_ctl_pos_event(void *, pos_event_t *);
+
+/** Tab set control ops */
+ui_control_ops_t ui_tab_set_ops = {
+	.destroy = ui_tab_set_ctl_destroy,
+	.paint = ui_tab_set_ctl_paint,
+	.kbd_event = ui_tab_set_ctl_kbd_event,
+	.pos_event = ui_tab_set_ctl_pos_event
+};
+
+/** Create new tab set.
+ *
+ * @param res UI resource
+ * @param rtabset Place to store pointer to new tab set
+ * @return EOK on success, ENOMEM if out of memory
+ */
+errno_t ui_tab_set_create(ui_resource_t *res, ui_tab_set_t **rtabset)
+{
+	ui_tab_set_t *tabset;
+	errno_t rc;
+
+	tabset = calloc(1, sizeof(ui_tab_set_t));
+	if (tabset == NULL)
+		return ENOMEM;
+
+	rc = ui_control_new(&ui_tab_set_ops, (void *) tabset, &tabset->control);
+	if (rc != EOK) {
+		free(tabset);
+		return rc;
+	}
+
+	tabset->res = res;
+	list_initialize(&tabset->tabs);
+	*rtabset = tabset;
+	return EOK;
+}
+
+/** Destroy tab set
+ *
+ * @param tabset Tab set or @c NULL
+ */
+void ui_tab_set_destroy(ui_tab_set_t *tabset)
+{
+	ui_tab_t *tab;
+
+	if (tabset == NULL)
+		return;
+
+	/* Destroy tabs */
+	tab = ui_tab_first(tabset);
+	while (tab != NULL) {
+		ui_tab_destroy(tab);
+		tab = ui_tab_first(tabset);
+	}
+
+	ui_control_delete(tabset->control);
+	free(tabset);
+}
+
+/** Get base control from tab set.
+ *
+ * @param tabset Tab set
+ * @return Control
+ */
+ui_control_t *ui_tab_set_ctl(ui_tab_set_t *tabset)
+{
+	return tabset->control;
+}
+
+/** Set tab set rectangle.
+ *
+ * @param tabset Tab set
+ * @param rect New tab set rectangle
+ */
+void ui_tab_set_set_rect(ui_tab_set_t *tabset, gfx_rect_t *rect)
+{
+	tabset->rect = *rect;
+}
+
+/** Paint tab set.
+ *
+ * @param tabset Tab set
+ * @return EOK on success or an error code
+ */
+errno_t ui_tab_set_paint(ui_tab_set_t *tabset)
+{
+	ui_resource_t *res;
+	ui_tab_t *tab;
+	errno_t rc;
+
+	res = tabset->res;
+
+	if (tabset->selected != NULL) {
+		rc = ui_tab_paint_body_frame(tabset->selected);
+		if (rc != EOK)
+			goto error;
+	}
+
+	tab = ui_tab_first(tabset);
+	while (tab != NULL) {
+		rc = ui_tab_paint(tab);
+		if (rc != EOK)
+			return rc;
+
+		tab = ui_tab_next(tab);
+	}
+
+	rc = gfx_update(res->gc);
+	if (rc != EOK)
+		goto error;
+
+	return EOK;
+error:
+	return rc;
+}
+
+/** Select or deselect tab from tab set.
+ *
+ * Select @a tab. If @a tab is @c NULL or it is already selected,
+ * then select none.
+ *
+ * @param tabset Tab set
+ * @param tab Tab to select (or deselect if selected) or @c NULL
+ */
+void ui_tab_set_select(ui_tab_set_t *tabset, ui_tab_t *tab)
+{
+	tabset->selected = tab;
+	(void) ui_tab_set_paint(tabset);
+}
+
+/** Handle tab set keyboard event.
+ *
+ * @param tabset Tab set
+ * @param kbd_event Keyboard event
+ * @return @c ui_claimed iff the event is claimed
+ */
+ui_evclaim_t ui_tab_set_kbd_event(ui_tab_set_t *tabset, kbd_event_t *event)
+{
+	ui_tab_t *tab;
+	ui_evclaim_t claim;
+
+	tab = ui_tab_first(tabset);
+	while (tab != NULL) {
+		claim = ui_tab_kbd_event(tab, event);
+		if (claim == ui_claimed)
+			return ui_claimed;
+
+		tab = ui_tab_next(tab);
+	}
+
+	return ui_unclaimed;
+}
+
+/** Handle tab set position event.
+ *
+ * @param tabset Tab set
+ * @param pos_event Position event
+ * @return @c ui_claimed iff the event is claimed
+ */
+ui_evclaim_t ui_tab_set_pos_event(ui_tab_set_t *tabset, pos_event_t *event)
+{
+	ui_tab_t *tab;
+	ui_evclaim_t claim;
+
+	tab = ui_tab_first(tabset);
+	while (tab != NULL) {
+		claim = ui_tab_pos_event(tab, event);
+		if (claim == ui_claimed)
+			return ui_claimed;
+
+		tab = ui_tab_next(tab);
+	}
+
+	return ui_unclaimed;
+}
+
+/** Destroy tab set control.
+ *
+ * @param arg Argument (ui_tab_set_t *)
+ */
+static void ui_tab_set_ctl_destroy(void *arg)
+{
+	ui_tab_set_t *tabset = (ui_tab_set_t *) arg;
+
+	ui_tab_set_destroy(tabset);
+}
+
+/** Paint tab set control.
+ *
+ * @param arg Argument (ui_tab_set_t *)
+ * @return EOK on success or an error code
+ */
+static errno_t ui_tab_set_ctl_paint(void *arg)
+{
+	ui_tab_set_t *tabset = (ui_tab_set_t *) arg;
+
+	return ui_tab_set_paint(tabset);
+}
+
+/** Handle tab set control keyboard event.
+ *
+ * @param arg Argument (ui_tab_set_t *)
+ * @param pos_event Position event
+ * @return @c ui_claimed iff the event is claimed
+ */
+static ui_evclaim_t ui_tab_set_ctl_kbd_event(void *arg, kbd_event_t *event)
+{
+	ui_tab_set_t *tabset = (ui_tab_set_t *) arg;
+
+	return ui_tab_set_kbd_event(tabset, event);
+}
+
+/** Handle tab set control position event.
+ *
+ * @param arg Argument (ui_tab_set_t *)
+ * @param pos_event Position event
+ * @return @c ui_claimed iff the event is claimed
+ */
+static ui_evclaim_t ui_tab_set_ctl_pos_event(void *arg, pos_event_t *event)
+{
+	ui_tab_set_t *tabset = (ui_tab_set_t *) arg;
+
+	return ui_tab_set_pos_event(tabset, event);
+}
+
+/** @}
+ */
Index: uspace/lib/ui/src/testctl.c
===================================================================
--- uspace/lib/ui/src/testctl.c	(revision 1eaead40ef1e81dc0e203498597d60b2e71fcd27)
+++ uspace/lib/ui/src/testctl.c	(revision 1eaead40ef1e81dc0e203498597d60b2e71fcd27)
@@ -0,0 +1,159 @@
+/*
+ * Copyright (c) 2023 Jiri Svoboda
+ * All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions
+ * are met:
+ *
+ * - Redistributions of source code must retain the above copyright
+ *   notice, this list of conditions and the following disclaimer.
+ * - Redistributions in binary form must reproduce the above copyright
+ *   notice, this list of conditions and the following disclaimer in the
+ *   documentation and/or other materials provided with the distribution.
+ * - The name of the author may not be used to endorse or promote products
+ *   derived from this software without specific prior written permission.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS OR
+ * IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES
+ * OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED.
+ * IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT,
+ * INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT
+ * NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF
+ * THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+/** @addtogroup libui
+ * @{
+ */
+/**
+ * @file Test control
+ *
+ * Test control allows to read the arguments of and inject the responses
+ * to all control methods.
+ */
+
+#include <errno.h>
+#include <stdlib.h>
+#include <str.h>
+#include <ui/control.h>
+#include <ui/testctl.h>
+#include "../private/testctl.h"
+
+static void test_ctl_destroy(void *);
+static errno_t test_ctl_paint(void *);
+static ui_evclaim_t test_ctl_kbd_event(void *, kbd_event_t *);
+static ui_evclaim_t test_ctl_pos_event(void *, pos_event_t *);
+static void test_ctl_unfocus(void *, unsigned);
+
+ui_control_ops_t ui_test_ctl_ops = {
+	.destroy = test_ctl_destroy,
+	.paint = test_ctl_paint,
+	.kbd_event = test_ctl_kbd_event,
+	.pos_event = test_ctl_pos_event,
+	.unfocus = test_ctl_unfocus
+};
+
+static void test_ctl_destroy(void *arg)
+{
+	ui_test_ctl_t *testctl = (ui_test_ctl_t *)arg;
+	ui_tc_resp_t *resp = testctl->resp;
+
+	resp->destroy = true;
+	ui_test_ctl_destroy(testctl);
+}
+
+static errno_t test_ctl_paint(void *arg)
+{
+	ui_test_ctl_t *testctl = (ui_test_ctl_t *)arg;
+	ui_tc_resp_t *resp = testctl->resp;
+
+	resp->paint = true;
+	return resp->rc;
+}
+
+static ui_evclaim_t test_ctl_kbd_event(void *arg, kbd_event_t *event)
+{
+	ui_test_ctl_t *testctl = (ui_test_ctl_t *)arg;
+	ui_tc_resp_t *resp = testctl->resp;
+
+	resp->kbd = true;
+	resp->kevent = *event;
+
+	return resp->claim;
+}
+
+static ui_evclaim_t test_ctl_pos_event(void *arg, pos_event_t *event)
+{
+	ui_test_ctl_t *testctl = (ui_test_ctl_t *)arg;
+	ui_tc_resp_t *resp = testctl->resp;
+
+	resp->pos = true;
+	resp->pevent = *event;
+
+	return resp->claim;
+}
+
+static void test_ctl_unfocus(void *arg, unsigned nfocus)
+{
+	ui_test_ctl_t *testctl = (ui_test_ctl_t *)arg;
+	ui_tc_resp_t *resp = testctl->resp;
+
+	resp->unfocus = true;
+	resp->unfocus_nfocus = nfocus;
+}
+
+/** Create new test control.
+ *
+ * @param resp Response structure
+ * @param rtest Place to store pointer to new test control
+ * @return EOK on success, ENOMEM if out of memory
+ */
+errno_t ui_test_ctl_create(ui_tc_resp_t *resp, ui_test_ctl_t **rtest)
+{
+	ui_test_ctl_t *test;
+	errno_t rc;
+
+	test = calloc(1, sizeof(ui_test_ctl_t));
+	if (test == NULL)
+		return ENOMEM;
+
+	rc = ui_control_new(&ui_test_ctl_ops, (void *)test, &test->control);
+	if (rc != EOK) {
+		free(test);
+		return rc;
+	}
+
+	test->resp = resp;
+	*rtest = test;
+	return EOK;
+}
+
+/** Destroy test control.
+ *
+ * @param test Test control or @c NULL
+ */
+void ui_test_ctl_destroy(ui_test_ctl_t *test)
+{
+	if (test == NULL)
+		return;
+
+	ui_control_delete(test->control);
+	free(test);
+}
+
+/** Get base control from test control.
+ *
+ * @param test Test control
+ * @return Control
+ */
+ui_control_t *ui_test_ctl_ctl(ui_test_ctl_t *test)
+{
+	return test->control;
+}
+
+/** @}
+ */
Index: uspace/lib/ui/src/window.c
===================================================================
--- uspace/lib/ui/src/window.c	(revision b1f0a141d37cd3a919a708e87dbff0bd6527788e)
+++ uspace/lib/ui/src/window.c	(revision 1eaead40ef1e81dc0e203498597d60b2e71fcd27)
@@ -452,5 +452,4 @@
  * @param window Window
  * @param control Control
- * @return EOK on success, ENOMEM if out of memory
  */
 void ui_window_add(ui_window_t *window, ui_control_t *control)
