Index: uspace/lib/ui/include/types/ui/tab.h
===================================================================
--- uspace/lib/ui/include/types/ui/tab.h	(revision e994898da769c2fefc731de899af8f1370cd52f4)
+++ uspace/lib/ui/include/types/ui/tab.h	(revision e994898da769c2fefc731de899af8f1370cd52f4)
@@ -0,0 +1,45 @@
+/*
+ * 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
+ */
+
+#ifndef _UI_TYPES_TAB_H
+#define _UI_TYPES_TAB_H
+
+struct ui_menu;
+typedef struct ui_tab ui_tab_t;
+
+#endif
+
+/** @}
+ */
Index: uspace/lib/ui/include/types/ui/tabset.h
===================================================================
--- uspace/lib/ui/include/types/ui/tabset.h	(revision e994898da769c2fefc731de899af8f1370cd52f4)
+++ uspace/lib/ui/include/types/ui/tabset.h	(revision e994898da769c2fefc731de899af8f1370cd52f4)
@@ -0,0 +1,45 @@
+/*
+ * 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 Menu bar
+ */
+
+#ifndef _UI_TYPES_TABSET_H
+#define _UI_TYPES_TABSET_H
+
+struct ui_tab_set;
+typedef struct ui_tab_set ui_tab_set_t;
+
+#endif
+
+/** @}
+ */
Index: uspace/lib/ui/include/types/ui/testctl.h
===================================================================
--- uspace/lib/ui/include/types/ui/testctl.h	(revision e994898da769c2fefc731de899af8f1370cd52f4)
+++ uspace/lib/ui/include/types/ui/testctl.h	(revision e994898da769c2fefc731de899af8f1370cd52f4)
@@ -0,0 +1,74 @@
+/*
+ * 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 Label
+ */
+
+#ifndef _UI_TYPES_TESTCTL_H
+#define _UI_TYPES_TESTCTL_H
+
+struct ui_test_ctl;
+typedef struct ui_test_ctl ui_test_ctl_t;
+
+/** Test control response */
+typedef struct {
+	/** Claim to return */
+	ui_evclaim_t claim;
+	/** Result code to return */
+	errno_t rc;
+
+	/** @c true iff destroy was called */
+	bool destroy;
+
+	/** @c true iff paint was called */
+	bool paint;
+
+	/** @c true iff kbd_event was called */
+	bool kbd;
+	/** Keyboard event that was sent */
+	kbd_event_t kevent;
+
+	/** @c true iff pos_event was called */
+	bool pos;
+	/** Position event that was sent */
+	pos_event_t pevent;
+
+	/** @c true iff unfocus was called */
+	bool unfocus;
+	/** Number of remaining foci that was sent */
+	unsigned unfocus_nfocus;
+} ui_tc_resp_t;
+
+#endif
+
+/** @}
+ */
Index: uspace/lib/ui/include/ui/paint.h
===================================================================
--- uspace/lib/ui/include/ui/paint.h	(revision b1f0a141d37cd3a919a708e87dbff0bd6527788e)
+++ uspace/lib/ui/include/ui/paint.h	(revision e994898da769c2fefc731de899af8f1370cd52f4)
@@ -1,4 +1,4 @@
 /*
- * Copyright (c) 2022 Jiri Svoboda
+ * Copyright (c) 2023 Jiri Svoboda
  * All rights reserved.
  *
@@ -71,4 +71,6 @@
 extern errno_t ui_paint_unmaxicon(ui_resource_t *, gfx_coord2_t *, gfx_coord_t,
     gfx_coord_t, gfx_coord_t, gfx_coord_t);
+extern errno_t ui_paint_text_box_custom(ui_resource_t *, gfx_rect_t *,
+    ui_box_chars_t *, gfx_color_t *);
 extern errno_t ui_paint_text_box(ui_resource_t *, gfx_rect_t *,
     ui_box_style_t, gfx_color_t *);
Index: uspace/lib/ui/include/ui/tab.h
===================================================================
--- uspace/lib/ui/include/ui/tab.h	(revision e994898da769c2fefc731de899af8f1370cd52f4)
+++ uspace/lib/ui/include/ui/tab.h	(revision e994898da769c2fefc731de899af8f1370cd52f4)
@@ -0,0 +1,66 @@
+/*
+ * 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
+ */
+
+#ifndef _UI_TAB_H
+#define _UI_TAB_H
+
+#include <errno.h>
+#include <gfx/coord.h>
+#include <io/kbd_event.h>
+#include <io/pos_event.h>
+#include <stdbool.h>
+#include <types/common.h>
+#include <types/ui/tab.h>
+#include <types/ui/tabset.h>
+#include <types/ui/event.h>
+#include <uchar.h>
+
+extern errno_t ui_tab_create(ui_tab_set_t *, const char *, ui_tab_t **);
+extern void ui_tab_destroy(ui_tab_t *);
+extern ui_tab_t *ui_tab_first(ui_tab_set_t *);
+extern ui_tab_t *ui_tab_next(ui_tab_t *);
+extern ui_tab_t *ui_tab_last(ui_tab_set_t *);
+extern ui_tab_t *ui_tab_prev(ui_tab_t *);
+extern bool ui_tab_is_selected(ui_tab_t *);
+extern void ui_tab_add(ui_tab_t *, ui_control_t *);
+extern void ui_tab_remove(ui_tab_t *, ui_control_t *);
+extern errno_t ui_tab_paint(ui_tab_t *);
+extern ui_evclaim_t ui_tab_kbd_event(ui_tab_t *, kbd_event_t *);
+extern ui_evclaim_t ui_tab_pos_event(ui_tab_t *, pos_event_t *);
+
+#endif
+
+/** @}
+ */
Index: uspace/lib/ui/include/ui/tabset.h
===================================================================
--- uspace/lib/ui/include/ui/tabset.h	(revision e994898da769c2fefc731de899af8f1370cd52f4)
+++ uspace/lib/ui/include/ui/tabset.h	(revision e994898da769c2fefc731de899af8f1370cd52f4)
@@ -0,0 +1,59 @@
+/*
+ * 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
+ */
+
+#ifndef _UI_TABSET_H
+#define _UI_TABSET_H
+
+#include <errno.h>
+#include <gfx/coord.h>
+#include <io/kbd_event.h>
+#include <io/pos_event.h>
+#include <types/ui/tabset.h>
+#include <types/ui/control.h>
+#include <types/ui/event.h>
+#include <types/ui/resource.h>
+
+extern errno_t ui_tab_set_create(ui_resource_t *, ui_tab_set_t **);
+extern void ui_tab_set_destroy(ui_tab_set_t *);
+extern ui_control_t *ui_tab_set_ctl(ui_tab_set_t *);
+extern void ui_tab_set_set_rect(ui_tab_set_t *, gfx_rect_t *);
+extern errno_t ui_tab_set_paint(ui_tab_set_t *);
+extern ui_evclaim_t ui_tab_set_kbd_event(ui_tab_set_t *, kbd_event_t *);
+extern ui_evclaim_t ui_tab_set_pos_event(ui_tab_set_t *, pos_event_t *);
+
+#endif
+
+/** @}
+ */
Index: uspace/lib/ui/include/ui/testctl.h
===================================================================
--- uspace/lib/ui/include/ui/testctl.h	(revision e994898da769c2fefc731de899af8f1370cd52f4)
+++ uspace/lib/ui/include/ui/testctl.h	(revision e994898da769c2fefc731de899af8f1370cd52f4)
@@ -0,0 +1,50 @@
+/*
+ * 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
+ */
+
+#ifndef _UI_TESTCTL_H
+#define _UI_TESTCTL_H
+
+#include <errno.h>
+#include <types/ui/control.h>
+#include <types/ui/testctl.h>
+
+extern errno_t ui_test_ctl_create(ui_tc_resp_t *, ui_test_ctl_t **);
+extern void ui_test_ctl_destroy(ui_test_ctl_t *);
+extern ui_control_t *ui_test_ctl_ctl(ui_test_ctl_t *);
+
+#endif
+
+/** @}
+ */
Index: uspace/lib/ui/meson.build
===================================================================
--- uspace/lib/ui/meson.build	(revision b1f0a141d37cd3a919a708e87dbff0bd6527788e)
+++ uspace/lib/ui/meson.build	(revision e994898da769c2fefc731de899af8f1370cd52f4)
@@ -1,4 +1,4 @@
 #
-# Copyright (c) 2022 Jiri Svoboda
+# Copyright (c) 2023 Jiri Svoboda
 # All rights reserved.
 #
@@ -52,4 +52,7 @@
 	'src/scrollbar.c',
 	'src/slider.c',
+	'src/tab.c',
+	'src/tabset.c',
+	'src/testctl.c',
 	'src/ui.c',
 	'src/wdecor.c',
@@ -81,4 +84,7 @@
 	'test/scrollbar.c',
 	'test/slider.c',
+	'test/tab.c',
+	'test/tabset.c',
+	'test/testctl.c',
 	'test/ui.c',
 	'test/wdecor.c',
Index: uspace/lib/ui/private/tab.h
===================================================================
--- uspace/lib/ui/private/tab.h	(revision e994898da769c2fefc731de899af8f1370cd52f4)
+++ uspace/lib/ui/private/tab.h	(revision e994898da769c2fefc731de899af8f1370cd52f4)
@@ -0,0 +1,90 @@
+/*
+ * 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 structure
+ *
+ */
+
+#ifndef _UI_PRIVATE_TAB_H
+#define _UI_PRIVATE_TAB_H
+
+#include <adt/list.h>
+#include <gfx/coord.h>
+#include <stdbool.h>
+#include <types/ui/tab.h>
+#include <types/ui/resource.h>
+
+/** Actual structure of tab.
+ *
+ * This is private to libui.
+ */
+struct ui_tab {
+	/** Containing tab set */
+	struct ui_tab_set *tabset;
+	/** Link to @c tabset->tabs */
+	link_t ltabs;
+	/** Caption */
+	char *caption;
+	/** X offset of the handle */
+	gfx_coord_t xoff;
+	/** Tab content */
+	struct ui_control *content;
+};
+
+/** Tab geometry.
+ *
+ * Computed rectangles of tab elements.
+ */
+typedef struct {
+	/** Tab handle */
+	gfx_rect_t handle;
+	/** Tab handle area including pull-up area */
+	gfx_rect_t handle_area;
+	/** Tab body */
+	gfx_rect_t body;
+	/** Text position */
+	gfx_coord2_t text_pos;
+} ui_tab_geom_t;
+
+extern gfx_coord_t ui_tab_handle_width(ui_tab_t *);
+extern gfx_coord_t ui_tab_handle_height(ui_tab_t *);
+extern void ui_tab_get_geom(ui_tab_t *, ui_tab_geom_t *);
+extern errno_t ui_tab_paint_handle_frame(gfx_context_t *, gfx_rect_t *,
+    gfx_coord_t, gfx_color_t *, gfx_color_t *, bool, gfx_rect_t *);
+extern errno_t ui_tab_paint_body_frame(ui_tab_t *);
+extern errno_t ui_tab_paint_frame(ui_tab_t *);
+extern ui_resource_t *ui_tab_get_res(ui_tab_t *);
+
+#endif
+
+/** @}
+ */
Index: uspace/lib/ui/private/tabset.h
===================================================================
--- uspace/lib/ui/private/tabset.h	(revision e994898da769c2fefc731de899af8f1370cd52f4)
+++ uspace/lib/ui/private/tabset.h	(revision e994898da769c2fefc731de899af8f1370cd52f4)
@@ -0,0 +1,69 @@
+/*
+ * 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 structure
+ *
+ */
+
+#ifndef _UI_PRIVATE_TABSET_H
+#define _UI_PRIVATE_TABSET_H
+
+#include <adt/list.h>
+#include <gfx/coord.h>
+#include <stdbool.h>
+#include <types/common.h>
+#include <types/ui/tab.h>
+#include <types/ui/tabset.h>
+
+/** Actual structure of tab set.
+ *
+ * This is private to libui.
+ */
+struct ui_tab_set {
+	/** Base control object */
+	struct ui_control *control;
+	/** UI reource */
+	struct ui_resource *res;
+	/** Tab set rectangle */
+	gfx_rect_t rect;
+	/** Selected tab */
+	struct ui_tab *selected;
+	/** List of tabs (ui_tab_t) */
+	list_t tabs;
+};
+
+extern void ui_tab_set_select(ui_tab_set_t *, ui_tab_t *);
+
+#endif
+
+/** @}
+ */
Index: uspace/lib/ui/private/testctl.h
===================================================================
--- uspace/lib/ui/private/testctl.h	(revision e994898da769c2fefc731de899af8f1370cd52f4)
+++ uspace/lib/ui/private/testctl.h	(revision e994898da769c2fefc731de899af8f1370cd52f4)
@@ -0,0 +1,58 @@
+/*
+ * 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
+ *
+ */
+
+#ifndef _UI_PRIVATE_TESTCTL_H
+#define _UI_PRIVATE_TESTCTL_H
+
+#include <types/ui/testctl.h>
+
+/** Actual structure of test control.
+ *
+ * This is private to libui.
+ */
+struct ui_test_ctl {
+	/** Base control object */
+	struct ui_control *control;
+	/** Test response structure */
+	ui_tc_resp_t *resp;
+};
+
+extern ui_control_ops_t ui_test_ctl_ops;
+
+#endif
+
+/** @}
+ */
Index: uspace/lib/ui/src/paint.c
===================================================================
--- uspace/lib/ui/src/paint.c	(revision b1f0a141d37cd3a919a708e87dbff0bd6527788e)
+++ uspace/lib/ui/src/paint.c	(revision e994898da769c2fefc731de899af8f1370cd52f4)
@@ -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 e994898da769c2fefc731de899af8f1370cd52f4)
+++ uspace/lib/ui/src/tab.c	(revision e994898da769c2fefc731de899af8f1370cd52f4)
@@ -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 e994898da769c2fefc731de899af8f1370cd52f4)
+++ uspace/lib/ui/src/tabset.c	(revision e994898da769c2fefc731de899af8f1370cd52f4)
@@ -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 e994898da769c2fefc731de899af8f1370cd52f4)
+++ uspace/lib/ui/src/testctl.c	(revision e994898da769c2fefc731de899af8f1370cd52f4)
@@ -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 e994898da769c2fefc731de899af8f1370cd52f4)
@@ -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)
Index: uspace/lib/ui/test/control.c
===================================================================
--- uspace/lib/ui/test/control.c	(revision b1f0a141d37cd3a919a708e87dbff0bd6527788e)
+++ uspace/lib/ui/test/control.c	(revision e994898da769c2fefc731de899af8f1370cd52f4)
@@ -33,53 +33,12 @@
 #include <pcut/pcut.h>
 #include <ui/control.h>
+#include <ui/testctl.h>
 #include <stdbool.h>
 #include <types/ui/event.h>
+#include "../private/testctl.h"
 
 PCUT_INIT;
 
 PCUT_TEST_SUITE(control);
-
-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);
-
-static ui_control_ops_t 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
-};
-
-/** Test response */
-typedef struct {
-	/** Claim to return */
-	ui_evclaim_t claim;
-	/** Result code to return */
-	errno_t rc;
-
-	/** @c true iff destroy was called */
-	bool destroy;
-
-	/** @c true iff paint was called */
-	bool paint;
-
-	/** @c true iff kbd_event was called */
-	bool kbd;
-	/** Keyboard event that was sent */
-	kbd_event_t kevent;
-
-	/** @c true iff pos_event was called */
-	bool pos;
-	/** Position event that was sent */
-	pos_event_t pevent;
-
-	/** @c true iff unfocus was called */
-	bool unfocus;
-	/** Number of remaining foci that was sent */
-	unsigned unfocus_nfocus;
-} test_resp_t;
 
 /** Allocate and deallocate control */
@@ -89,5 +48,5 @@
 	errno_t rc;
 
-	rc = ui_control_new(&test_ctl_ops, NULL, &control);
+	rc = ui_control_new(&ui_test_ctl_ops, NULL, &control);
 	PCUT_ASSERT_ERRNO_VAL(EOK, rc);
 	PCUT_ASSERT_NOT_NULL(control);
@@ -105,16 +64,16 @@
 PCUT_TEST(destroy)
 {
-	ui_control_t *control = NULL;
-	test_resp_t resp;
+	ui_test_ctl_t *testctl = NULL;
+	ui_tc_resp_t resp;
 	errno_t rc;
 
-	rc = ui_control_new(&test_ctl_ops, &resp, &control);
+	rc = ui_test_ctl_create(&resp, &testctl);
 	PCUT_ASSERT_ERRNO_VAL(EOK, rc);
-	PCUT_ASSERT_NOT_NULL(control);
+	PCUT_ASSERT_NOT_NULL(testctl);
 
 	resp.rc = EOK;
 	resp.destroy = false;
 
-	ui_control_destroy(control);
+	ui_control_destroy(ui_test_ctl_ctl(testctl));
 	PCUT_ASSERT_TRUE(resp.destroy);
 }
@@ -123,16 +82,16 @@
 PCUT_TEST(paint)
 {
-	ui_control_t *control = NULL;
-	test_resp_t resp;
+	ui_test_ctl_t *testctl = NULL;
+	ui_tc_resp_t resp;
 	errno_t rc;
 
-	rc = ui_control_new(&test_ctl_ops, &resp, &control);
+	rc = ui_test_ctl_create(&resp, &testctl);
 	PCUT_ASSERT_ERRNO_VAL(EOK, rc);
-	PCUT_ASSERT_NOT_NULL(control);
+	PCUT_ASSERT_NOT_NULL(testctl);
 
 	resp.rc = EOK;
 	resp.paint = false;
 
-	rc = ui_control_paint(control);
+	rc = ui_control_paint(ui_test_ctl_ctl(testctl));
 	PCUT_ASSERT_ERRNO_VAL(resp.rc, rc);
 	PCUT_ASSERT_TRUE(resp.paint);
@@ -141,9 +100,9 @@
 	resp.paint = false;
 
-	rc = ui_control_paint(control);
+	rc = ui_control_paint(ui_test_ctl_ctl(testctl));
 	PCUT_ASSERT_ERRNO_VAL(resp.rc, rc);
 	PCUT_ASSERT_TRUE(resp.paint);
 
-	ui_control_delete(control);
+	ui_test_ctl_destroy(testctl);
 }
 
@@ -151,13 +110,13 @@
 PCUT_TEST(kbd_event)
 {
-	ui_control_t *control = NULL;
-	test_resp_t resp;
+	ui_test_ctl_t *testctl = NULL;
+	ui_tc_resp_t resp;
 	kbd_event_t event;
 	ui_evclaim_t claim;
 	errno_t rc;
 
-	rc = ui_control_new(&test_ctl_ops, &resp, &control);
+	rc = ui_test_ctl_create(&resp, &testctl);
 	PCUT_ASSERT_ERRNO_VAL(EOK, rc);
-	PCUT_ASSERT_NOT_NULL(control);
+	PCUT_ASSERT_NOT_NULL(testctl);
 
 	resp.claim = ui_claimed;
@@ -168,5 +127,5 @@
 	event.c = '@';
 
-	claim = ui_control_kbd_event(control, &event);
+	claim = ui_control_kbd_event(ui_test_ctl_ctl(testctl), &event);
 	PCUT_ASSERT_EQUALS(resp.claim, claim);
 	PCUT_ASSERT_TRUE(resp.kbd);
@@ -176,5 +135,5 @@
 	PCUT_ASSERT_INT_EQUALS(resp.kevent.c, event.c);
 
-	ui_control_delete(control);
+	ui_test_ctl_destroy(testctl);
 }
 
@@ -182,13 +141,13 @@
 PCUT_TEST(pos_event)
 {
-	ui_control_t *control = NULL;
-	test_resp_t resp;
+	ui_test_ctl_t *testctl = NULL;
+	ui_tc_resp_t resp;
 	pos_event_t event;
 	ui_evclaim_t claim;
 	errno_t rc;
 
-	rc = ui_control_new(&test_ctl_ops, &resp, &control);
+	rc = ui_test_ctl_create(&resp, &testctl);
 	PCUT_ASSERT_ERRNO_VAL(EOK, rc);
-	PCUT_ASSERT_NOT_NULL(control);
+	PCUT_ASSERT_NOT_NULL(testctl);
 
 	resp.claim = ui_claimed;
@@ -200,5 +159,5 @@
 	event.vpos = 4;
 
-	claim = ui_control_pos_event(control, &event);
+	claim = ui_control_pos_event(ui_test_ctl_ctl(testctl), &event);
 	PCUT_ASSERT_EQUALS(resp.claim, claim);
 	PCUT_ASSERT_TRUE(resp.pos);
@@ -209,5 +168,5 @@
 	PCUT_ASSERT_INT_EQUALS(resp.pevent.vpos, event.vpos);
 
-	ui_control_delete(control);
+	ui_test_ctl_destroy(testctl);
 }
 
@@ -215,62 +174,19 @@
 PCUT_TEST(unfocus)
 {
-	ui_control_t *control = NULL;
-	test_resp_t resp;
+	ui_test_ctl_t *testctl = NULL;
+	ui_tc_resp_t resp;
 	errno_t rc;
 
-	rc = ui_control_new(&test_ctl_ops, &resp, &control);
+	rc = ui_test_ctl_create(&resp, &testctl);
 	PCUT_ASSERT_ERRNO_VAL(EOK, rc);
-	PCUT_ASSERT_NOT_NULL(control);
+	PCUT_ASSERT_NOT_NULL(testctl);
 
 	resp.unfocus = false;
 
-	ui_control_unfocus(control, 42);
+	ui_control_unfocus(ui_test_ctl_ctl(testctl), 42);
 	PCUT_ASSERT_TRUE(resp.unfocus);
 	PCUT_ASSERT_INT_EQUALS(42, resp.unfocus_nfocus);
 
-	ui_control_delete(control);
-}
-
-static void test_ctl_destroy(void *arg)
-{
-	test_resp_t *resp = (test_resp_t *) arg;
-
-	resp->destroy = true;
-}
-
-static errno_t test_ctl_paint(void *arg)
-{
-	test_resp_t *resp = (test_resp_t *) arg;
-
-	resp->paint = true;
-	return resp->rc;
-}
-
-static ui_evclaim_t test_ctl_kbd_event(void *arg, kbd_event_t *event)
-{
-	test_resp_t *resp = (test_resp_t *) arg;
-
-	resp->kbd = true;
-	resp->kevent = *event;
-
-	return resp->claim;
-}
-
-static ui_evclaim_t test_ctl_pos_event(void *arg, pos_event_t *event)
-{
-	test_resp_t *resp = (test_resp_t *) arg;
-
-	resp->pos = true;
-	resp->pevent = *event;
-
-	return resp->claim;
-}
-
-static void test_ctl_unfocus(void *arg, unsigned nfocus)
-{
-	test_resp_t *resp = (test_resp_t *) arg;
-
-	resp->unfocus = true;
-	resp->unfocus_nfocus = nfocus;
+	ui_test_ctl_destroy(testctl);
 }
 
Index: uspace/lib/ui/test/main.c
===================================================================
--- uspace/lib/ui/test/main.c	(revision b1f0a141d37cd3a919a708e87dbff0bd6527788e)
+++ uspace/lib/ui/test/main.c	(revision e994898da769c2fefc731de899af8f1370cd52f4)
@@ -1,4 +1,4 @@
 /*
- * Copyright (c) 2022 Jiri Svoboda
+ * Copyright (c) 2023 Jiri Svoboda
  * All rights reserved.
  *
@@ -52,4 +52,7 @@
 PCUT_IMPORT(scrollbar);
 PCUT_IMPORT(slider);
+PCUT_IMPORT(tab);
+PCUT_IMPORT(tabset);
+PCUT_IMPORT(testctl);
 PCUT_IMPORT(ui);
 PCUT_IMPORT(wdecor);
Index: uspace/lib/ui/test/paint.c
===================================================================
--- uspace/lib/ui/test/paint.c	(revision b1f0a141d37cd3a919a708e87dbff0bd6527788e)
+++ uspace/lib/ui/test/paint.c	(revision e994898da769c2fefc731de899af8f1370cd52f4)
@@ -1,4 +1,4 @@
 /*
- * Copyright (c) 2022 Jiri Svoboda
+ * Copyright (c) 2023 Jiri Svoboda
  * All rights reserved.
  *
@@ -75,4 +75,13 @@
 } testgc_bitmap_t;
 
+/** Test box characters */
+static ui_box_chars_t test_box_chars = {
+	{
+		{ "A", "B", "C" },
+		{ "D", " ", "E" },
+		{ "F", "G", "H" }
+	}
+};
+
 /** Paint bevel */
 PCUT_TEST(bevel)
@@ -466,4 +475,40 @@
 	/* Paint text box */
 	rc = ui_paint_text_box(resource, &rect, ui_box_single, color);
+	PCUT_ASSERT_ERRNO_VAL(EOK, rc);
+
+	gfx_color_delete(color);
+	ui_resource_destroy(resource);
+	rc = gfx_context_delete(gc);
+	PCUT_ASSERT_ERRNO_VAL(EOK, rc);
+}
+
+/** Paint custom text box */
+PCUT_TEST(text_box_custom)
+{
+	errno_t rc;
+	gfx_context_t *gc = NULL;
+	ui_resource_t *resource = NULL;
+	gfx_color_t *color = NULL;
+	test_gc_t tgc;
+	gfx_rect_t rect;
+
+	memset(&tgc, 0, sizeof(tgc));
+	rc = gfx_context_new(&ops, &tgc, &gc);
+	PCUT_ASSERT_ERRNO_VAL(EOK, rc);
+
+	rc = ui_resource_create(gc, false, &resource);
+	PCUT_ASSERT_ERRNO_VAL(EOK, rc);
+	PCUT_ASSERT_NOT_NULL(resource);
+
+	rc = gfx_color_new_rgb_i16(1, 2, 3, &color);
+	PCUT_ASSERT_ERRNO_VAL(EOK, rc);
+
+	rect.p0.x = 10;
+	rect.p0.y = 20;
+	rect.p1.x = 30;
+	rect.p1.y = 40;
+
+	/* Paint text box */
+	rc = ui_paint_text_box_custom(resource, &rect, &test_box_chars, color);
 	PCUT_ASSERT_ERRNO_VAL(EOK, rc);
 
Index: uspace/lib/ui/test/tab.c
===================================================================
--- uspace/lib/ui/test/tab.c	(revision e994898da769c2fefc731de899af8f1370cd52f4)
+++ uspace/lib/ui/test/tab.c	(revision e994898da769c2fefc731de899af8f1370cd52f4)
@@ -0,0 +1,844 @@
+/*
+ * 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.
+ */
+
+#include <gfx/context.h>
+#include <gfx/coord.h>
+#include <mem.h>
+#include <pcut/pcut.h>
+#include <stdbool.h>
+#include <str.h>
+#include <ui/control.h>
+#include <ui/tab.h>
+#include <ui/tabset.h>
+#include <ui/testctl.h>
+#include <ui/ui.h>
+#include <ui/window.h>
+#include "../private/resource.h"
+#include "../private/tab.h"
+#include "../private/tabset.h"
+
+PCUT_INIT;
+
+PCUT_TEST_SUITE(tab);
+
+/** Create and destroy tab */
+PCUT_TEST(create_destroy)
+{
+	ui_t *ui = NULL;
+	ui_window_t *window = NULL;
+	ui_wnd_params_t params;
+	ui_resource_t *res;
+	ui_tab_set_t *tabset = NULL;
+	ui_tab_t *tab = NULL;
+	errno_t rc;
+
+	rc = ui_create_disp(NULL, &ui);
+	PCUT_ASSERT_ERRNO_VAL(EOK, rc);
+
+	ui_wnd_params_init(&params);
+	params.caption = "Hello";
+
+	rc = ui_window_create(ui, &params, &window);
+	PCUT_ASSERT_ERRNO_VAL(EOK, rc);
+	PCUT_ASSERT_NOT_NULL(window);
+
+	res = ui_window_get_res(window);
+
+	rc = ui_tab_set_create(res, &tabset);
+	PCUT_ASSERT_ERRNO_VAL(EOK, rc);
+
+	rc = ui_tab_create(tabset, "Test", &tab);
+	PCUT_ASSERT_ERRNO_VAL(EOK, rc);
+	PCUT_ASSERT_NOT_NULL(tab);
+
+	/*
+	 * Normally we don't need to destroy a tab explicitly, it will
+	 * be destroyed along with tab bar, but here we'll test destroying
+	 * it explicitly.
+	 */
+	ui_tab_destroy(tab);
+	ui_tab_set_destroy(tabset);
+
+	ui_window_destroy(window);
+	ui_destroy(ui);
+}
+
+/** Destroy tab implicitly by destroying the tab set */
+PCUT_TEST(implicit_destroy)
+{
+	ui_t *ui = NULL;
+	ui_window_t *window = NULL;
+	ui_wnd_params_t params;
+	ui_resource_t *res;
+	ui_tab_set_t *tabset = NULL;
+	ui_tab_t *tab = NULL;
+	errno_t rc;
+
+	rc = ui_create_disp(NULL, &ui);
+	PCUT_ASSERT_ERRNO_VAL(EOK, rc);
+
+	ui_wnd_params_init(&params);
+	params.caption = "Hello";
+
+	rc = ui_window_create(ui, &params, &window);
+	PCUT_ASSERT_ERRNO_VAL(EOK, rc);
+	PCUT_ASSERT_NOT_NULL(window);
+
+	res = ui_window_get_res(window);
+
+	rc = ui_tab_set_create(res, &tabset);
+	PCUT_ASSERT_ERRNO_VAL(EOK, rc);
+
+	rc = ui_tab_create(tabset, "Test", &tab);
+	PCUT_ASSERT_ERRNO_VAL(EOK, rc);
+	PCUT_ASSERT_NOT_NULL(tab);
+
+	/* Let the tab be destroyed as part of destroying tab set */
+	ui_tab_set_destroy(tabset);
+
+	ui_window_destroy(window);
+	ui_destroy(ui);
+}
+
+/** ui_tab_destroy() can take NULL argument (no-op) */
+PCUT_TEST(destroy_null)
+{
+	ui_tab_destroy(NULL);
+}
+
+/** ui_tab_first() / ui_tab_next() iterate over tabs */
+PCUT_TEST(first_next)
+{
+	ui_t *ui = NULL;
+	ui_window_t *window = NULL;
+	ui_wnd_params_t params;
+	ui_resource_t *res;
+	ui_tab_set_t *tabset = NULL;
+	ui_tab_t *tab1 = NULL;
+	ui_tab_t *tab2 = NULL;
+	ui_tab_t *t;
+	errno_t rc;
+
+	rc = ui_create_disp(NULL, &ui);
+	PCUT_ASSERT_ERRNO_VAL(EOK, rc);
+
+	ui_wnd_params_init(&params);
+	params.caption = "Hello";
+
+	rc = ui_window_create(ui, &params, &window);
+	PCUT_ASSERT_ERRNO_VAL(EOK, rc);
+	PCUT_ASSERT_NOT_NULL(window);
+
+	res = ui_window_get_res(window);
+
+	rc = ui_tab_set_create(res, &tabset);
+	PCUT_ASSERT_ERRNO_VAL(EOK, rc);
+	PCUT_ASSERT_NOT_NULL(tabset);
+
+	rc = ui_tab_create(tabset, "Test 1", &tab1);
+	PCUT_ASSERT_ERRNO_VAL(EOK, rc);
+	PCUT_ASSERT_NOT_NULL(tab1);
+
+	rc = ui_tab_create(tabset, "Test 2", &tab2);
+	PCUT_ASSERT_ERRNO_VAL(EOK, rc);
+	PCUT_ASSERT_NOT_NULL(tab2);
+
+	t = ui_tab_first(tabset);
+	PCUT_ASSERT_EQUALS(tab1, t);
+
+	t = ui_tab_next(t);
+	PCUT_ASSERT_EQUALS(tab2, t);
+
+	t = ui_tab_next(t);
+	PCUT_ASSERT_NULL(t);
+
+	ui_tab_set_destroy(tabset);
+	ui_window_destroy(window);
+	ui_destroy(ui);
+}
+
+/** ui_tab_last() / ui_tab_prev() iterate over tabs in reverse */
+PCUT_TEST(last_prev)
+{
+	ui_t *ui = NULL;
+	ui_window_t *window = NULL;
+	ui_wnd_params_t params;
+	ui_resource_t *res;
+	ui_tab_set_t *tabset = NULL;
+	ui_tab_t *tab1 = NULL;
+	ui_tab_t *tab2 = NULL;
+	ui_tab_t *t;
+	errno_t rc;
+
+	rc = ui_create_disp(NULL, &ui);
+	PCUT_ASSERT_ERRNO_VAL(EOK, rc);
+
+	ui_wnd_params_init(&params);
+	params.caption = "Hello";
+
+	rc = ui_window_create(ui, &params, &window);
+	PCUT_ASSERT_ERRNO_VAL(EOK, rc);
+	PCUT_ASSERT_NOT_NULL(window);
+
+	res = ui_window_get_res(window);
+
+	rc = ui_tab_set_create(res, &tabset);
+	PCUT_ASSERT_ERRNO_VAL(EOK, rc);
+	PCUT_ASSERT_NOT_NULL(tabset);
+
+	rc = ui_tab_create(tabset, "Test 1", &tab1);
+	PCUT_ASSERT_ERRNO_VAL(EOK, rc);
+	PCUT_ASSERT_NOT_NULL(tab1);
+
+	rc = ui_tab_create(tabset, "Test 2", &tab2);
+	PCUT_ASSERT_ERRNO_VAL(EOK, rc);
+	PCUT_ASSERT_NOT_NULL(tab2);
+
+	t = ui_tab_last(tabset);
+	PCUT_ASSERT_EQUALS(tab2, t);
+
+	t = ui_tab_prev(t);
+	PCUT_ASSERT_EQUALS(tab1, t);
+
+	t = ui_tab_prev(t);
+	PCUT_ASSERT_NULL(t);
+
+	ui_tab_set_destroy(tabset);
+	ui_window_destroy(window);
+	ui_destroy(ui);
+}
+
+/** ui_tab_is_selected() correctly returns tab state */
+PCUT_TEST(is_selected)
+{
+	ui_t *ui = NULL;
+	ui_window_t *window = NULL;
+	ui_wnd_params_t params;
+	ui_resource_t *res;
+	ui_tab_set_t *tabset = NULL;
+	ui_tab_t *tab1 = NULL;
+	ui_tab_t *tab2 = NULL;
+	errno_t rc;
+
+	rc = ui_create_disp(NULL, &ui);
+	PCUT_ASSERT_ERRNO_VAL(EOK, rc);
+
+	ui_wnd_params_init(&params);
+	params.caption = "Hello";
+
+	rc = ui_window_create(ui, &params, &window);
+	PCUT_ASSERT_ERRNO_VAL(EOK, rc);
+	PCUT_ASSERT_NOT_NULL(window);
+
+	res = ui_window_get_res(window);
+
+	rc = ui_tab_set_create(res, &tabset);
+	PCUT_ASSERT_ERRNO_VAL(EOK, rc);
+	PCUT_ASSERT_NOT_NULL(tabset);
+
+	rc = ui_tab_create(tabset, "Test 1", &tab1);
+	PCUT_ASSERT_ERRNO_VAL(EOK, rc);
+
+	/* The first added tab should be automatically selected */
+	PCUT_ASSERT_TRUE(ui_tab_is_selected(tab1));
+
+	rc = ui_tab_create(tabset, "Test 2", &tab2);
+	PCUT_ASSERT_ERRNO_VAL(EOK, rc);
+
+	/* After adding a second tab the first should still be selected */
+	PCUT_ASSERT_TRUE(ui_tab_is_selected(tab1));
+	PCUT_ASSERT_FALSE(ui_tab_is_selected(tab2));
+
+	/* Select second tab */
+	ui_tab_set_select(tabset, tab2);
+
+	/* Now the second tab should be selected */
+	PCUT_ASSERT_FALSE(ui_tab_is_selected(tab1));
+	PCUT_ASSERT_TRUE(ui_tab_is_selected(tab2));
+
+	ui_tab_set_destroy(tabset);
+	ui_window_destroy(window);
+	ui_destroy(ui);
+}
+
+/** ui_tab_add() adds control to tab */
+PCUT_TEST(add)
+{
+	ui_t *ui = NULL;
+	ui_window_t *window = NULL;
+	ui_wnd_params_t params;
+	ui_resource_t *res;
+	ui_tab_set_t *tabset = NULL;
+	ui_tab_t *tab = NULL;
+	ui_test_ctl_t *testctl = NULL;
+	ui_tc_resp_t resp;
+	errno_t rc;
+
+	rc = ui_create_disp(NULL, &ui);
+	PCUT_ASSERT_ERRNO_VAL(EOK, rc);
+
+	ui_wnd_params_init(&params);
+	params.caption = "Hello";
+
+	rc = ui_window_create(ui, &params, &window);
+	PCUT_ASSERT_ERRNO_VAL(EOK, rc);
+	PCUT_ASSERT_NOT_NULL(window);
+
+	res = ui_window_get_res(window);
+
+	rc = ui_tab_set_create(res, &tabset);
+	PCUT_ASSERT_ERRNO_VAL(EOK, rc);
+	PCUT_ASSERT_NOT_NULL(tabset);
+
+	rc = ui_tab_create(tabset, "Test", &tab);
+	PCUT_ASSERT_ERRNO_VAL(EOK, rc);
+	PCUT_ASSERT_NOT_NULL(tab);
+
+	rc = ui_test_ctl_create(&resp, &testctl);
+	PCUT_ASSERT_ERRNO_VAL(EOK, rc);
+
+	/* Add test control to tab */
+	ui_tab_add(tab, ui_test_ctl_ctl(testctl));
+
+	resp.destroy = false;
+
+	ui_tab_set_destroy(tabset);
+
+	/* Destroying the tab should have destroyed the control as well */
+	PCUT_ASSERT_TRUE(resp.destroy);
+
+	ui_window_destroy(window);
+	ui_destroy(ui);
+}
+
+/** ui_tab_remove() removes control to tab */
+PCUT_TEST(remove)
+{
+	ui_t *ui = NULL;
+	ui_window_t *window = NULL;
+	ui_wnd_params_t params;
+	ui_resource_t *res;
+	ui_tab_set_t *tabset = NULL;
+	ui_tab_t *tab = NULL;
+	ui_test_ctl_t *testctl = NULL;
+	ui_tc_resp_t resp;
+	errno_t rc;
+
+	rc = ui_create_disp(NULL, &ui);
+	PCUT_ASSERT_ERRNO_VAL(EOK, rc);
+
+	ui_wnd_params_init(&params);
+	params.caption = "Hello";
+
+	rc = ui_window_create(ui, &params, &window);
+	PCUT_ASSERT_ERRNO_VAL(EOK, rc);
+	PCUT_ASSERT_NOT_NULL(window);
+
+	res = ui_window_get_res(window);
+
+	rc = ui_tab_set_create(res, &tabset);
+	PCUT_ASSERT_ERRNO_VAL(EOK, rc);
+	PCUT_ASSERT_NOT_NULL(tabset);
+
+	rc = ui_tab_create(tabset, "Test", &tab);
+	PCUT_ASSERT_ERRNO_VAL(EOK, rc);
+	PCUT_ASSERT_NOT_NULL(tab);
+
+	rc = ui_test_ctl_create(&resp, &testctl);
+	PCUT_ASSERT_ERRNO_VAL(EOK, rc);
+
+	/* Add test control to tab */
+	ui_tab_add(tab, ui_test_ctl_ctl(testctl));
+
+	/* Rmove control from tab */
+	ui_tab_remove(tab, ui_test_ctl_ctl(testctl));
+
+	resp.destroy = false;
+
+	ui_tab_set_destroy(tabset);
+
+	/* Destroying the tab should NOT have destroyed the control */
+	PCUT_ASSERT_FALSE(resp.destroy);
+
+	ui_test_ctl_destroy(testctl);
+	ui_window_destroy(window);
+	ui_destroy(ui);
+}
+
+/** Paint tab */
+PCUT_TEST(paint)
+{
+	ui_t *ui = NULL;
+	ui_window_t *window = NULL;
+	ui_wnd_params_t params;
+	ui_resource_t *res;
+	ui_tab_set_t *tabset = NULL;
+	ui_tab_t *tab = NULL;
+	errno_t rc;
+
+	rc = ui_create_disp(NULL, &ui);
+	PCUT_ASSERT_ERRNO_VAL(EOK, rc);
+
+	ui_wnd_params_init(&params);
+	params.caption = "Hello";
+
+	rc = ui_window_create(ui, &params, &window);
+	PCUT_ASSERT_ERRNO_VAL(EOK, rc);
+	PCUT_ASSERT_NOT_NULL(window);
+
+	res = ui_window_get_res(window);
+
+	rc = ui_tab_set_create(res, &tabset);
+	PCUT_ASSERT_ERRNO_VAL(EOK, rc);
+	PCUT_ASSERT_NOT_NULL(tabset);
+
+	rc = ui_tab_create(tabset, "Test", &tab);
+	PCUT_ASSERT_ERRNO_VAL(EOK, rc);
+	PCUT_ASSERT_NOT_NULL(tab);
+
+	rc = ui_tab_paint(tab);
+	PCUT_ASSERT_ERRNO_VAL(EOK, rc);
+
+	ui_tab_set_destroy(tabset);
+	ui_window_destroy(window);
+	ui_destroy(ui);
+}
+
+/** ui_tab_kbd_event() delivers keyboard event */
+PCUT_TEST(kbd_event)
+{
+	ui_t *ui = NULL;
+	ui_window_t *window = NULL;
+	ui_wnd_params_t params;
+	ui_resource_t *res;
+	ui_tab_set_t *tabset = NULL;
+	ui_tab_t *tab = NULL;
+	ui_evclaim_t claimed;
+	kbd_event_t event;
+	ui_test_ctl_t *testctl = NULL;
+	ui_tc_resp_t resp;
+	errno_t rc;
+
+	rc = ui_create_disp(NULL, &ui);
+	PCUT_ASSERT_ERRNO_VAL(EOK, rc);
+
+	ui_wnd_params_init(&params);
+	params.caption = "Hello";
+
+	rc = ui_window_create(ui, &params, &window);
+	PCUT_ASSERT_ERRNO_VAL(EOK, rc);
+	PCUT_ASSERT_NOT_NULL(window);
+
+	res = ui_window_get_res(window);
+
+	rc = ui_tab_set_create(res, &tabset);
+	PCUT_ASSERT_ERRNO_VAL(EOK, rc);
+	PCUT_ASSERT_NOT_NULL(tabset);
+
+	/* Without anytabs, event should be unclaimed */
+	event.type = KEY_PRESS;
+	event.key = KC_ENTER;
+	event.mods = 0;
+	claimed = ui_tab_set_kbd_event(tabset, &event);
+	PCUT_ASSERT_ERRNO_VAL(EOK, rc);
+	PCUT_ASSERT_EQUALS(ui_unclaimed, claimed);
+
+	rc = ui_tab_create(tabset, "Test", &tab);
+	PCUT_ASSERT_ERRNO_VAL(EOK, rc);
+	PCUT_ASSERT_NOT_NULL(tab);
+
+	rc = ui_test_ctl_create(&resp, &testctl);
+	PCUT_ASSERT_ERRNO_VAL(EOK, rc);
+
+	/* Set up response */
+	ui_tab_add(tab, ui_test_ctl_ctl(testctl));
+	resp.claim = ui_claimed;
+	resp.kbd = false;
+
+	/* Send keyboard event */
+	event.type = KEY_PRESS;
+	event.key = KC_F10;
+	event.mods = 0;
+	claimed = ui_tab_kbd_event(tab, &event);
+	PCUT_ASSERT_ERRNO_VAL(EOK, rc);
+	PCUT_ASSERT_EQUALS(ui_claimed, claimed);
+
+	/* Make sure event was delivered */
+	PCUT_ASSERT_TRUE(resp.kbd);
+	PCUT_ASSERT_EQUALS(event.type, resp.kevent.type);
+	PCUT_ASSERT_EQUALS(event.key, resp.kevent.key);
+	PCUT_ASSERT_EQUALS(event.mods, resp.kevent.mods);
+
+	ui_tab_set_destroy(tabset);
+	ui_window_destroy(window);
+	ui_destroy(ui);
+}
+
+/** ui_tab_pos_event() delivers position event */
+PCUT_TEST(pos_event)
+{
+	ui_t *ui = NULL;
+	ui_window_t *window = NULL;
+	ui_wnd_params_t params;
+	ui_resource_t *res;
+	ui_tab_set_t *tabset = NULL;
+	gfx_rect_t rect;
+	ui_tab_t *tab = NULL;
+	ui_evclaim_t claimed;
+	pos_event_t event;
+	ui_test_ctl_t *testctl = NULL;
+	ui_tc_resp_t resp;
+	errno_t rc;
+
+	rc = ui_create_disp(NULL, &ui);
+	PCUT_ASSERT_ERRNO_VAL(EOK, rc);
+
+	ui_wnd_params_init(&params);
+	params.caption = "Hello";
+
+	rc = ui_window_create(ui, &params, &window);
+	PCUT_ASSERT_ERRNO_VAL(EOK, rc);
+	PCUT_ASSERT_NOT_NULL(window);
+
+	res = ui_window_get_res(window);
+
+	rc = ui_tab_set_create(res, &tabset);
+	PCUT_ASSERT_ERRNO_VAL(EOK, rc);
+	PCUT_ASSERT_NOT_NULL(tabset);
+
+	rect.p0.x = 0;
+	rect.p0.y = 0;
+	rect.p1.x = 100;
+	rect.p1.y = 200;
+	ui_tab_set_set_rect(tabset, &rect);
+
+	rc = ui_tab_create(tabset, "Test", &tab);
+	PCUT_ASSERT_ERRNO_VAL(EOK, rc);
+	PCUT_ASSERT_NOT_NULL(tab);
+
+	rc = ui_test_ctl_create(&resp, &testctl);
+	PCUT_ASSERT_ERRNO_VAL(EOK, rc);
+
+	/* Set up response */
+	ui_tab_add(tab, ui_test_ctl_ctl(testctl));
+	resp.claim = ui_claimed;
+	resp.pos = false;
+
+	/* Send position event */
+	event.type = POS_PRESS;
+	event.hpos = 10;
+	event.vpos = 40;
+	claimed = ui_tab_pos_event(tab, &event);
+	PCUT_ASSERT_ERRNO_VAL(EOK, rc);
+	PCUT_ASSERT_EQUALS(ui_claimed, claimed);
+
+	/* Make sure event was delivered */
+	PCUT_ASSERT_TRUE(resp.pos);
+	PCUT_ASSERT_EQUALS(event.type, resp.pevent.type);
+	PCUT_ASSERT_EQUALS(event.hpos, resp.pevent.hpos);
+	PCUT_ASSERT_EQUALS(event.vpos, resp.pevent.vpos);
+
+	ui_tab_set_destroy(tabset);
+	ui_window_destroy(window);
+	ui_destroy(ui);
+}
+
+/** ui_tab_handle_width() and ui_tab_handle_height() return dimensions */
+PCUT_TEST(handle_width_height)
+{
+	ui_t *ui = NULL;
+	ui_window_t *window = NULL;
+	ui_wnd_params_t params;
+	ui_resource_t *res;
+	ui_tab_set_t *tabset = NULL;
+	ui_tab_t *tab = NULL;
+	gfx_coord_t w, h;
+	errno_t rc;
+
+	rc = ui_create_disp(NULL, &ui);
+	PCUT_ASSERT_ERRNO_VAL(EOK, rc);
+
+	ui_wnd_params_init(&params);
+	params.caption = "Hello";
+
+	rc = ui_window_create(ui, &params, &window);
+	PCUT_ASSERT_ERRNO_VAL(EOK, rc);
+	PCUT_ASSERT_NOT_NULL(window);
+
+	res = ui_window_get_res(window);
+
+	rc = ui_tab_set_create(res, &tabset);
+	PCUT_ASSERT_ERRNO_VAL(EOK, rc);
+	PCUT_ASSERT_NOT_NULL(tabset);
+
+	rc = ui_tab_create(tabset, "Test", &tab);
+	PCUT_ASSERT_ERRNO_VAL(EOK, rc);
+	PCUT_ASSERT_NOT_NULL(tab);
+
+	w = ui_tab_handle_width(tab);
+	h = ui_tab_handle_height(tab);
+
+	PCUT_ASSERT_INT_EQUALS(50, w);
+	PCUT_ASSERT_INT_EQUALS(25, h);
+
+	ui_tab_set_destroy(tabset);
+	ui_window_destroy(window);
+	ui_destroy(ui);
+}
+
+/** Computing tab geometry */
+PCUT_TEST(get_geom)
+{
+	ui_t *ui = NULL;
+	ui_window_t *window = NULL;
+	ui_wnd_params_t params;
+	ui_resource_t *res;
+	ui_tab_set_t *tabset = NULL;
+	ui_tab_t *tab = NULL;
+	ui_tab_geom_t geom;
+	gfx_rect_t rect;
+	errno_t rc;
+
+	rc = ui_create_disp(NULL, &ui);
+	PCUT_ASSERT_ERRNO_VAL(EOK, rc);
+
+	ui_wnd_params_init(&params);
+	params.caption = "Hello";
+
+	rc = ui_window_create(ui, &params, &window);
+	PCUT_ASSERT_ERRNO_VAL(EOK, rc);
+	PCUT_ASSERT_NOT_NULL(window);
+
+	res = ui_window_get_res(window);
+
+	rc = ui_tab_set_create(res, &tabset);
+	PCUT_ASSERT_ERRNO_VAL(EOK, rc);
+	PCUT_ASSERT_NOT_NULL(tabset);
+
+	rect.p0.x = 1000;
+	rect.p0.y = 2000;
+	rect.p1.x = 1100;
+	rect.p1.y = 2200;
+	ui_tab_set_set_rect(tabset, &rect);
+
+	rc = ui_tab_create(tabset, "Test", &tab);
+	PCUT_ASSERT_ERRNO_VAL(EOK, rc);
+	PCUT_ASSERT_NOT_NULL(tab);
+
+	ui_tab_get_geom(tab, &geom);
+
+	PCUT_ASSERT_INT_EQUALS(1006, geom.handle.p0.x);
+	PCUT_ASSERT_INT_EQUALS(2000, geom.handle.p0.y);
+	PCUT_ASSERT_INT_EQUALS(1056, geom.handle.p1.x);
+	PCUT_ASSERT_INT_EQUALS(2027, geom.handle.p1.y);
+
+	PCUT_ASSERT_INT_EQUALS(1006, geom.handle_area.p0.x);
+	PCUT_ASSERT_INT_EQUALS(2000, geom.handle_area.p0.y);
+	PCUT_ASSERT_INT_EQUALS(1056, geom.handle_area.p1.x);
+	PCUT_ASSERT_INT_EQUALS(2027, geom.handle_area.p1.y);
+
+	PCUT_ASSERT_INT_EQUALS(1000, geom.body.p0.x);
+	PCUT_ASSERT_INT_EQUALS(2025, geom.body.p0.y);
+	PCUT_ASSERT_INT_EQUALS(1100, geom.body.p1.x);
+	PCUT_ASSERT_INT_EQUALS(2200, geom.body.p1.y);
+
+	PCUT_ASSERT_INT_EQUALS(1014, geom.text_pos.x);
+	PCUT_ASSERT_INT_EQUALS(2007, geom.text_pos.y);
+
+	ui_tab_set_destroy(tabset);
+	ui_window_destroy(window);
+	ui_destroy(ui);
+}
+
+/** ui_tab_patint_handle_frame() */
+PCUT_TEST(paint_handle_frame)
+{
+	ui_t *ui = NULL;
+	ui_window_t *window = NULL;
+	ui_wnd_params_t params;
+	ui_resource_t *res;
+	gfx_context_t *gc;
+	gfx_rect_t rect;
+	gfx_rect_t irect;
+	gfx_coord_t chamfer;
+	gfx_color_t *hi_color;
+	gfx_color_t *sh_color;
+	errno_t rc;
+
+	rc = ui_create_disp(NULL, &ui);
+	PCUT_ASSERT_ERRNO_VAL(EOK, rc);
+
+	ui_wnd_params_init(&params);
+	params.caption = "Hello";
+
+	rc = ui_window_create(ui, &params, &window);
+	PCUT_ASSERT_ERRNO_VAL(EOK, rc);
+	PCUT_ASSERT_NOT_NULL(window);
+
+	res = ui_window_get_res(window);
+	gc = ui_window_get_gc(window);
+
+	rect.p0.x = 10;
+	rect.p0.y = 20;
+	rect.p1.x = 100;
+	rect.p1.y = 200;
+
+	chamfer = 4;
+
+	hi_color = res->wnd_highlight_color;
+	sh_color = res->wnd_shadow_color;
+
+	rc = ui_tab_paint_handle_frame(gc, &rect, chamfer, hi_color, sh_color,
+	    true, &irect);
+	PCUT_ASSERT_ERRNO_VAL(EOK, rc);
+
+	rc = ui_tab_paint_handle_frame(gc, &rect, chamfer, hi_color, sh_color,
+	    false, &irect);
+	PCUT_ASSERT_ERRNO_VAL(EOK, rc);
+
+	ui_window_destroy(window);
+	ui_destroy(ui);
+}
+
+/** ui_tab_paint_body_frame() */
+PCUT_TEST(paint_body_frame)
+{
+	ui_t *ui = NULL;
+	ui_window_t *window = NULL;
+	ui_wnd_params_t params;
+	ui_resource_t *res;
+	ui_tab_set_t *tabset = NULL;
+	ui_tab_t *tab = NULL;
+	errno_t rc;
+
+	rc = ui_create_disp(NULL, &ui);
+	PCUT_ASSERT_ERRNO_VAL(EOK, rc);
+
+	ui_wnd_params_init(&params);
+	params.caption = "Hello";
+
+	rc = ui_window_create(ui, &params, &window);
+	PCUT_ASSERT_ERRNO_VAL(EOK, rc);
+	PCUT_ASSERT_NOT_NULL(window);
+
+	res = ui_window_get_res(window);
+
+	rc = ui_tab_set_create(res, &tabset);
+	PCUT_ASSERT_ERRNO_VAL(EOK, rc);
+	PCUT_ASSERT_NOT_NULL(tabset);
+
+	rc = ui_tab_create(tabset, "Test", &tab);
+	PCUT_ASSERT_ERRNO_VAL(EOK, rc);
+	PCUT_ASSERT_NOT_NULL(tab);
+
+	rc = ui_tab_paint_body_frame(tab);
+	PCUT_ASSERT_ERRNO_VAL(EOK, rc);
+
+	ui_tab_set_destroy(tabset);
+	ui_window_destroy(window);
+	ui_destroy(ui);
+}
+
+/** ui_tab_paint_frame() */
+PCUT_TEST(paint_frame)
+{
+	ui_t *ui = NULL;
+	ui_window_t *window = NULL;
+	ui_wnd_params_t params;
+	ui_resource_t *res;
+	ui_tab_set_t *tabset = NULL;
+	ui_tab_t *tab = NULL;
+	errno_t rc;
+
+	rc = ui_create_disp(NULL, &ui);
+	PCUT_ASSERT_ERRNO_VAL(EOK, rc);
+
+	ui_wnd_params_init(&params);
+	params.caption = "Hello";
+
+	rc = ui_window_create(ui, &params, &window);
+	PCUT_ASSERT_ERRNO_VAL(EOK, rc);
+	PCUT_ASSERT_NOT_NULL(window);
+
+	res = ui_window_get_res(window);
+
+	rc = ui_tab_set_create(res, &tabset);
+	PCUT_ASSERT_ERRNO_VAL(EOK, rc);
+	PCUT_ASSERT_NOT_NULL(tabset);
+
+	rc = ui_tab_create(tabset, "Test", &tab);
+	PCUT_ASSERT_ERRNO_VAL(EOK, rc);
+	PCUT_ASSERT_NOT_NULL(tab);
+
+	rc = ui_tab_paint_frame(tab);
+	PCUT_ASSERT_ERRNO_VAL(EOK, rc);
+
+	ui_tab_set_destroy(tabset);
+	ui_window_destroy(window);
+	ui_destroy(ui);
+}
+
+/** ui_tab_get_res() returns the resource */
+PCUT_TEST(get_res)
+{
+	ui_t *ui = NULL;
+	ui_window_t *window = NULL;
+	ui_wnd_params_t params;
+	ui_resource_t *res;
+	ui_tab_set_t *tabset = NULL;
+	ui_tab_t *tab = NULL;
+	errno_t rc;
+
+	rc = ui_create_disp(NULL, &ui);
+	PCUT_ASSERT_ERRNO_VAL(EOK, rc);
+
+	ui_wnd_params_init(&params);
+	params.caption = "Hello";
+
+	rc = ui_window_create(ui, &params, &window);
+	PCUT_ASSERT_ERRNO_VAL(EOK, rc);
+	PCUT_ASSERT_NOT_NULL(window);
+
+	res = ui_window_get_res(window);
+
+	rc = ui_tab_set_create(res, &tabset);
+	PCUT_ASSERT_ERRNO_VAL(EOK, rc);
+	PCUT_ASSERT_NOT_NULL(tabset);
+
+	rc = ui_tab_create(tabset, "Test", &tab);
+	PCUT_ASSERT_ERRNO_VAL(EOK, rc);
+	PCUT_ASSERT_NOT_NULL(tab);
+
+	PCUT_ASSERT_EQUALS(res, ui_tab_get_res(tab));
+
+	ui_tab_set_destroy(tabset);
+	ui_window_destroy(window);
+	ui_destroy(ui);
+}
+
+PCUT_EXPORT(tab);
Index: uspace/lib/ui/test/tabset.c
===================================================================
--- uspace/lib/ui/test/tabset.c	(revision e994898da769c2fefc731de899af8f1370cd52f4)
+++ uspace/lib/ui/test/tabset.c	(revision e994898da769c2fefc731de899af8f1370cd52f4)
@@ -0,0 +1,346 @@
+/*
+ * 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.
+ */
+
+#include <gfx/coord.h>
+#include <mem.h>
+#include <pcut/pcut.h>
+#include <stdbool.h>
+#include <ui/control.h>
+#include <ui/tab.h>
+#include <ui/tabset.h>
+#include <ui/testctl.h>
+#include <ui/ui.h>
+#include <ui/window.h>
+#include "../private/tabset.h"
+
+PCUT_INIT;
+
+PCUT_TEST_SUITE(tabset);
+
+/** Create and destroy tab set */
+PCUT_TEST(create_destroy)
+{
+	ui_tab_set_t *tabset = NULL;
+	errno_t rc;
+
+	rc = ui_tab_set_create(NULL, &tabset);
+	PCUT_ASSERT_ERRNO_VAL(EOK, rc);
+	PCUT_ASSERT_NOT_NULL(tabset);
+
+	ui_tab_set_destroy(tabset);
+}
+
+/** ui_tab_set_destroy() can take NULL argument (no-op) */
+PCUT_TEST(destroy_null)
+{
+	ui_tab_set_destroy(NULL);
+}
+
+/** ui_tab_set_ctl() returns control that has a working virtual destructor */
+PCUT_TEST(ctl)
+{
+	ui_tab_set_t *tabset = NULL;
+	ui_control_t *control;
+	errno_t rc;
+
+	rc = ui_tab_set_create(NULL, &tabset);
+	PCUT_ASSERT_ERRNO_VAL(EOK, rc);
+	PCUT_ASSERT_NOT_NULL(tabset);
+
+	control = ui_tab_set_ctl(tabset);
+	PCUT_ASSERT_NOT_NULL(control);
+
+	ui_control_destroy(control);
+}
+
+/** Set tab set rectangle sets internal field */
+PCUT_TEST(set_rect)
+{
+	errno_t rc;
+	ui_t *ui = NULL;
+	ui_window_t *window = NULL;
+	ui_wnd_params_t params;
+	ui_resource_t *res;
+	ui_tab_set_t *tabset = NULL;
+	gfx_rect_t rect;
+
+	rc = ui_create_disp(NULL, &ui);
+	PCUT_ASSERT_ERRNO_VAL(EOK, rc);
+
+	ui_wnd_params_init(&params);
+	params.caption = "Hello";
+
+	rc = ui_window_create(ui, &params, &window);
+	PCUT_ASSERT_ERRNO_VAL(EOK, rc);
+	PCUT_ASSERT_NOT_NULL(window);
+
+	res = ui_window_get_res(window);
+
+	rc = ui_tab_set_create(res, &tabset);
+	PCUT_ASSERT_ERRNO_VAL(EOK, rc);
+	PCUT_ASSERT_NOT_NULL(tabset);
+
+	rect.p0.x = 1;
+	rect.p0.y = 2;
+	rect.p1.x = 3;
+	rect.p1.y = 4;
+
+	ui_tab_set_set_rect(tabset, &rect);
+	PCUT_ASSERT_INT_EQUALS(rect.p0.x, tabset->rect.p0.x);
+	PCUT_ASSERT_INT_EQUALS(rect.p0.y, tabset->rect.p0.y);
+	PCUT_ASSERT_INT_EQUALS(rect.p1.x, tabset->rect.p1.x);
+	PCUT_ASSERT_INT_EQUALS(rect.p1.y, tabset->rect.p1.y);
+
+	ui_tab_set_destroy(tabset);
+	ui_window_destroy(window);
+	ui_destroy(ui);
+}
+
+/** Paint tab set */
+PCUT_TEST(paint)
+{
+	ui_t *ui = NULL;
+	ui_window_t *window = NULL;
+	ui_wnd_params_t params;
+	ui_resource_t *res;
+	ui_tab_set_t *tabset = NULL;
+	errno_t rc;
+
+	rc = ui_create_disp(NULL, &ui);
+	PCUT_ASSERT_ERRNO_VAL(EOK, rc);
+
+	ui_wnd_params_init(&params);
+	params.caption = "Hello";
+
+	rc = ui_window_create(ui, &params, &window);
+	PCUT_ASSERT_ERRNO_VAL(EOK, rc);
+	PCUT_ASSERT_NOT_NULL(window);
+
+	res = ui_window_get_res(window);
+
+	rc = ui_tab_set_create(res, &tabset);
+	PCUT_ASSERT_ERRNO_VAL(EOK, rc);
+	PCUT_ASSERT_NOT_NULL(tabset);
+
+	rc = ui_tab_set_paint(tabset);
+	PCUT_ASSERT_ERRNO_VAL(EOK, rc);
+
+	ui_tab_set_destroy(tabset);
+	ui_window_destroy(window);
+	ui_destroy(ui);
+}
+
+/** Deliver tab set keyboard event */
+PCUT_TEST(kbd_event)
+{
+	ui_t *ui = NULL;
+	ui_window_t *window = NULL;
+	ui_wnd_params_t params;
+	ui_resource_t *res;
+	ui_tab_set_t *tabset = NULL;
+	ui_tab_t *tab = NULL;
+	ui_evclaim_t claimed;
+	kbd_event_t event;
+	ui_test_ctl_t *testctl = NULL;
+	ui_tc_resp_t resp;
+	errno_t rc;
+
+	rc = ui_create_disp(NULL, &ui);
+	PCUT_ASSERT_ERRNO_VAL(EOK, rc);
+
+	ui_wnd_params_init(&params);
+	params.caption = "Hello";
+
+	rc = ui_window_create(ui, &params, &window);
+	PCUT_ASSERT_ERRNO_VAL(EOK, rc);
+	PCUT_ASSERT_NOT_NULL(window);
+
+	res = ui_window_get_res(window);
+
+	rc = ui_tab_set_create(res, &tabset);
+	PCUT_ASSERT_ERRNO_VAL(EOK, rc);
+	PCUT_ASSERT_NOT_NULL(tabset);
+
+	/* Without anytabs, event should be unclaimed */
+	event.type = KEY_PRESS;
+	event.key = KC_ENTER;
+	event.mods = 0;
+	claimed = ui_tab_set_kbd_event(tabset, &event);
+	PCUT_ASSERT_ERRNO_VAL(EOK, rc);
+	PCUT_ASSERT_EQUALS(ui_unclaimed, claimed);
+
+	rc = ui_tab_create(tabset, "Test", &tab);
+	PCUT_ASSERT_ERRNO_VAL(EOK, rc);
+	PCUT_ASSERT_NOT_NULL(tab);
+
+	rc = ui_test_ctl_create(&resp, &testctl);
+	PCUT_ASSERT_ERRNO_VAL(EOK, rc);
+
+	/* Set up response */
+	ui_tab_add(tab, ui_test_ctl_ctl(testctl));
+	resp.claim = ui_claimed;
+	resp.kbd = false;
+
+	/* Send keyboard event */
+	event.type = KEY_PRESS;
+	event.key = KC_F10;
+	event.mods = 0;
+	claimed = ui_tab_set_kbd_event(tabset, &event);
+	PCUT_ASSERT_ERRNO_VAL(EOK, rc);
+	PCUT_ASSERT_EQUALS(ui_claimed, claimed);
+
+	/* Make sure event was delivered */
+	PCUT_ASSERT_TRUE(resp.kbd);
+	PCUT_ASSERT_EQUALS(event.type, resp.kevent.type);
+	PCUT_ASSERT_EQUALS(event.key, resp.kevent.key);
+	PCUT_ASSERT_EQUALS(event.mods, resp.kevent.mods);
+
+	ui_tab_set_destroy(tabset);
+	ui_window_destroy(window);
+	ui_destroy(ui);
+}
+
+/** Press event on tab handle selects tab */
+PCUT_TEST(pos_event_select)
+{
+	ui_t *ui = NULL;
+	ui_window_t *window = NULL;
+	ui_wnd_params_t params;
+	ui_resource_t *res;
+	ui_tab_set_t *tabset = NULL;
+	ui_tab_t *tab1 = NULL;
+	ui_tab_t *tab2 = NULL;
+	ui_evclaim_t claimed;
+	pos_event_t event;
+	errno_t rc;
+
+	rc = ui_create_disp(NULL, &ui);
+	PCUT_ASSERT_ERRNO_VAL(EOK, rc);
+
+	ui_wnd_params_init(&params);
+	params.caption = "Hello";
+
+	rc = ui_window_create(ui, &params, &window);
+	PCUT_ASSERT_ERRNO_VAL(EOK, rc);
+	PCUT_ASSERT_NOT_NULL(window);
+
+	res = ui_window_get_res(window);
+
+	rc = ui_tab_set_create(res, &tabset);
+	PCUT_ASSERT_ERRNO_VAL(EOK, rc);
+	PCUT_ASSERT_NOT_NULL(tabset);
+
+	/* Without any tabs, event should be unclaimed */
+	event.type = POS_PRESS;
+	event.hpos = 80;
+	event.vpos = 4;
+	event.btn_num = 1;
+	claimed = ui_tab_set_pos_event(tabset, &event);
+	PCUT_ASSERT_ERRNO_VAL(EOK, rc);
+	PCUT_ASSERT_EQUALS(ui_unclaimed, claimed);
+
+	rc = ui_tab_create(tabset, "Test 1", &tab1);
+	PCUT_ASSERT_ERRNO_VAL(EOK, rc);
+
+	/* The first added tab should be automatically selected */
+	PCUT_ASSERT_EQUALS(tab1, tabset->selected);
+
+	rc = ui_tab_create(tabset, "Test 2", &tab2);
+	PCUT_ASSERT_ERRNO_VAL(EOK, rc);
+
+	/* After adding a second tab the first should still be selected */
+	PCUT_ASSERT_EQUALS(tab1, tabset->selected);
+
+	event.type = POS_PRESS;
+	event.hpos = 80;
+	event.vpos = 4;
+	event.btn_num = 1;
+	claimed = ui_tab_set_pos_event(tabset, &event);
+	PCUT_ASSERT_ERRNO_VAL(EOK, rc);
+	PCUT_ASSERT_EQUALS(ui_claimed, claimed);
+
+	/* Clicking the second tab handle should select tab2 */
+	PCUT_ASSERT_EQUALS(tab2, tabset->selected);
+
+	ui_tab_set_destroy(tabset);
+	ui_window_destroy(window);
+	ui_destroy(ui);
+}
+
+/** ui_tab_set_select() selects tab */
+PCUT_TEST(select)
+{
+	ui_t *ui = NULL;
+	ui_window_t *window = NULL;
+	ui_wnd_params_t params;
+	ui_resource_t *res;
+	ui_tab_set_t *tabset = NULL;
+	ui_tab_t *tab1 = NULL;
+	ui_tab_t *tab2 = NULL;
+	errno_t rc;
+
+	rc = ui_create_disp(NULL, &ui);
+	PCUT_ASSERT_ERRNO_VAL(EOK, rc);
+
+	ui_wnd_params_init(&params);
+	params.caption = "Hello";
+
+	rc = ui_window_create(ui, &params, &window);
+	PCUT_ASSERT_ERRNO_VAL(EOK, rc);
+	PCUT_ASSERT_NOT_NULL(window);
+
+	res = ui_window_get_res(window);
+
+	rc = ui_tab_set_create(res, &tabset);
+	PCUT_ASSERT_ERRNO_VAL(EOK, rc);
+	PCUT_ASSERT_NOT_NULL(tabset);
+
+	rc = ui_tab_create(tabset, "Test 1", &tab1);
+	PCUT_ASSERT_ERRNO_VAL(EOK, rc);
+
+	/* The first added tab should be automatically selected */
+	PCUT_ASSERT_EQUALS(tab1, tabset->selected);
+
+	rc = ui_tab_create(tabset, "Test 2", &tab2);
+	PCUT_ASSERT_ERRNO_VAL(EOK, rc);
+
+	/* After adding a second tab the first should still be selected */
+	PCUT_ASSERT_EQUALS(tab1, tabset->selected);
+
+	/* Select second tab */
+	ui_tab_set_select(tabset, tab2);
+
+	/* Now the second tab should be selected */
+	PCUT_ASSERT_EQUALS(tab2, tabset->selected);
+
+	ui_tab_set_destroy(tabset);
+	ui_window_destroy(window);
+	ui_destroy(ui);
+}
+
+PCUT_EXPORT(tabset);
Index: uspace/lib/ui/test/testctl.c
===================================================================
--- uspace/lib/ui/test/testctl.c	(revision e994898da769c2fefc731de899af8f1370cd52f4)
+++ uspace/lib/ui/test/testctl.c	(revision e994898da769c2fefc731de899af8f1370cd52f4)
@@ -0,0 +1,75 @@
+/*
+ * 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.
+ */
+
+#include <pcut/pcut.h>
+#include <ui/control.h>
+#include <ui/testctl.h>
+#include "../private/testctl.h"
+
+PCUT_INIT;
+
+PCUT_TEST_SUITE(testctl);
+
+/** Create and destroy test control */
+PCUT_TEST(create_destroy)
+{
+	ui_test_ctl_t *testctl = NULL;
+	ui_tc_resp_t resp;
+	errno_t rc;
+
+	rc = ui_test_ctl_create(&resp, &testctl);
+	PCUT_ASSERT_ERRNO_VAL(EOK, rc);
+	PCUT_ASSERT_NOT_NULL(testctl);
+
+	ui_test_ctl_destroy(testctl);
+}
+
+/** ui_test_ctl_destroy() can take NULL argument (no-op) */
+PCUT_TEST(destroy_null)
+{
+	ui_test_ctl_destroy(NULL);
+}
+
+/** ui_test_ctl_ctl() returns control that has a working virtual destructor */
+PCUT_TEST(ctl)
+{
+	ui_control_t *control;
+	ui_test_ctl_t *testctl;
+	errno_t rc;
+	ui_tc_resp_t resp;
+
+	rc = ui_test_ctl_create(&resp, &testctl);
+	PCUT_ASSERT_ERRNO_VAL(EOK, rc);
+
+	control = ui_test_ctl_ctl(testctl);
+	PCUT_ASSERT_NOT_NULL(control);
+
+	ui_control_destroy(control);
+}
+
+PCUT_EXPORT(testctl);
Index: uspace/lib/ui/test/wdecor.c
===================================================================
--- uspace/lib/ui/test/wdecor.c	(revision b1f0a141d37cd3a919a708e87dbff0bd6527788e)
+++ uspace/lib/ui/test/wdecor.c	(revision e994898da769c2fefc731de899af8f1370cd52f4)
@@ -137,9 +137,20 @@
 PCUT_TEST(set_rect)
 {
+	gfx_context_t *gc = NULL;
+	test_gc_t tgc;
+	ui_resource_t *resource = NULL;
 	ui_wdecor_t *wdecor;
 	gfx_rect_t rect;
 	errno_t rc;
 
-	rc = ui_wdecor_create(NULL, "Hello", ui_wds_none, &wdecor);
+	memset(&tgc, 0, sizeof(tgc));
+	rc = gfx_context_new(&ops, &tgc, &gc);
+	PCUT_ASSERT_ERRNO_VAL(EOK, rc);
+
+	rc = ui_resource_create(gc, false, &resource);
+	PCUT_ASSERT_ERRNO_VAL(EOK, rc);
+	PCUT_ASSERT_NOT_NULL(resource);
+
+	rc = ui_wdecor_create(resource, "Hello", ui_wds_none, &wdecor);
 	PCUT_ASSERT_ERRNO_VAL(EOK, rc);
 
@@ -156,4 +167,8 @@
 
 	ui_wdecor_destroy(wdecor);
+	ui_resource_destroy(resource);
+
+	rc = gfx_context_delete(gc);
+	PCUT_ASSERT_ERRNO_VAL(EOK, rc);
 }
 
@@ -445,4 +460,7 @@
 PCUT_TEST(close_btn_clicked)
 {
+	gfx_context_t *gc = NULL;
+	test_gc_t tgc;
+	ui_resource_t *resource = NULL;
 	ui_wdecor_t *wdecor;
 	gfx_rect_t rect;
@@ -450,5 +468,13 @@
 	errno_t rc;
 
-	rc = ui_wdecor_create(NULL, "Hello", ui_wds_none, &wdecor);
+	memset(&tgc, 0, sizeof(tgc));
+	rc = gfx_context_new(&ops, &tgc, &gc);
+	PCUT_ASSERT_ERRNO_VAL(EOK, rc);
+
+	rc = ui_resource_create(gc, false, &resource);
+	PCUT_ASSERT_ERRNO_VAL(EOK, rc);
+	PCUT_ASSERT_NOT_NULL(resource);
+
+	rc = ui_wdecor_create(resource, "Hello", ui_wds_decorated, &wdecor);
 	PCUT_ASSERT_ERRNO_VAL(EOK, rc);
 
@@ -468,4 +494,8 @@
 
 	ui_wdecor_destroy(wdecor);
+	ui_resource_destroy(resource);
+
+	rc = gfx_context_delete(gc);
+	PCUT_ASSERT_ERRNO_VAL(EOK, rc);
 }
 
@@ -788,4 +818,7 @@
 PCUT_TEST(get_rsztype)
 {
+	gfx_context_t *gc = NULL;
+	test_gc_t tgc;
+	ui_resource_t *resource = NULL;
 	ui_wdecor_t *wdecor;
 	gfx_rect_t rect;
@@ -794,5 +827,13 @@
 	errno_t rc;
 
-	rc = ui_wdecor_create(NULL, "Hello", ui_wds_resizable, &wdecor);
+	memset(&tgc, 0, sizeof(tgc));
+	rc = gfx_context_new(&ops, &tgc, &gc);
+	PCUT_ASSERT_ERRNO_VAL(EOK, rc);
+
+	rc = ui_resource_create(gc, false, &resource);
+	PCUT_ASSERT_ERRNO_VAL(EOK, rc);
+	PCUT_ASSERT_NOT_NULL(resource);
+
+	rc = ui_wdecor_create(resource, "Hello", ui_wds_resizable, &wdecor);
 	PCUT_ASSERT_ERRNO_VAL(EOK, rc);
 
@@ -874,5 +915,5 @@
 	/* Non-resizable window */
 
-	rc = ui_wdecor_create(NULL, "Hello", ui_wds_none, &wdecor);
+	rc = ui_wdecor_create(resource, "Hello", ui_wds_none, &wdecor);
 	PCUT_ASSERT_ERRNO_VAL(EOK, rc);
 
@@ -890,4 +931,8 @@
 
 	ui_wdecor_destroy(wdecor);
+	ui_resource_destroy(resource);
+
+	rc = gfx_context_delete(gc);
+	PCUT_ASSERT_ERRNO_VAL(EOK, rc);
 }
 
@@ -918,4 +963,7 @@
 PCUT_TEST(frame_pos_event)
 {
+	gfx_context_t *gc = NULL;
+	test_gc_t tgc;
+	ui_resource_t *resource = NULL;
 	ui_wdecor_t *wdecor;
 	gfx_rect_t rect;
@@ -924,5 +972,13 @@
 	errno_t rc;
 
-	rc = ui_wdecor_create(NULL, "Hello", ui_wds_resizable, &wdecor);
+	memset(&tgc, 0, sizeof(tgc));
+	rc = gfx_context_new(&ops, &tgc, &gc);
+	PCUT_ASSERT_ERRNO_VAL(EOK, rc);
+
+	rc = ui_resource_create(gc, false, &resource);
+	PCUT_ASSERT_ERRNO_VAL(EOK, rc);
+	PCUT_ASSERT_NOT_NULL(resource);
+
+	rc = ui_wdecor_create(resource, "Hello", ui_wds_resizable, &wdecor);
 	PCUT_ASSERT_ERRNO_VAL(EOK, rc);
 
@@ -960,4 +1016,8 @@
 
 	ui_wdecor_destroy(wdecor);
+	ui_resource_destroy(resource);
+
+	rc = gfx_context_delete(gc);
+	PCUT_ASSERT_ERRNO_VAL(EOK, rc);
 }
 
