nix/graphical/foot-tabs.patch
atagen 4921973b9a amaan can't into kernel
do 400 pushups per cache miss idiot
2026-04-27 16:09:39 +10:00

1902 lines
69 KiB
Diff
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

diff --git a/config.c b/config.c
index 481d4c4f..7684b570 100644
--- a/config.c
+++ b/config.c
@@ -148,6 +148,21 @@ static const char *const binding_action_map[] = {
[BIND_ACTION_THEME_SWITCH_LIGHT] = "color-theme-switch-light",
[BIND_ACTION_THEME_TOGGLE] = "color-theme-toggle",
+ /* Tab actions */
+ [BIND_ACTION_TAB_NEW] = "tab-new",
+ [BIND_ACTION_TAB_CLOSE] = "tab-close",
+ [BIND_ACTION_TAB_NEXT] = "tab-next",
+ [BIND_ACTION_TAB_PREV] = "tab-prev",
+ [BIND_ACTION_TAB_1] = "tab-1",
+ [BIND_ACTION_TAB_2] = "tab-2",
+ [BIND_ACTION_TAB_3] = "tab-3",
+ [BIND_ACTION_TAB_4] = "tab-4",
+ [BIND_ACTION_TAB_5] = "tab-5",
+ [BIND_ACTION_TAB_6] = "tab-6",
+ [BIND_ACTION_TAB_7] = "tab-7",
+ [BIND_ACTION_TAB_8] = "tab-8",
+ [BIND_ACTION_TAB_9] = "tab-9",
+
/* Mouse-specific actions */
[BIND_ACTION_SCROLLBACK_UP_MOUSE] = "scrollback-up-mouse",
[BIND_ACTION_SCROLLBACK_DOWN_MOUSE] = "scrollback-down-mouse",
@@ -1816,6 +1831,84 @@ parse_section_csd(struct context *ctx)
}
}
+static bool
+parse_section_tabs(struct context *ctx)
+{
+ struct config *conf = ctx->conf;
+ const char *key = ctx->key;
+
+ if (streq(key, "enabled"))
+ return value_to_bool(ctx, &conf->tabs.enabled);
+
+ else if (streq(key, "position")) {
+ _Static_assert(sizeof(conf->tabs.position) == sizeof(int),
+ "enum is not 32-bit");
+ return value_to_enum(
+ ctx,
+ (const char *[]){"top", "bottom", NULL},
+ (int *)&conf->tabs.position);
+ }
+
+ else if (streq(key, "style")) {
+ _Static_assert(sizeof(conf->tabs.style) == sizeof(int),
+ "enum is not 32-bit");
+ return value_to_enum(
+ ctx,
+ (const char *[]){"rounded", "square", "gradient", NULL},
+ (int *)&conf->tabs.style);
+ }
+
+ else if (streq(key, "layout")) {
+ _Static_assert(sizeof(conf->tabs.layout) == sizeof(int),
+ "enum is not 32-bit");
+ return value_to_enum(
+ ctx,
+ (const char *[]){"span", "floating", NULL},
+ (int *)&conf->tabs.layout);
+ }
+
+ else if (streq(key, "height"))
+ return value_to_uint16(ctx, 10, &conf->tabs.height);
+
+ else if (streq(key, "title-max-length"))
+ return value_to_uint16(ctx, 10, &conf->tabs.title_max_length);
+
+ else if (streq(key, "tab-width"))
+ return value_to_uint16(ctx, 10, &conf->tabs.tab_width);
+
+ else if (streq(key, "tab-padding"))
+ return value_to_uint16(ctx, 10, &conf->tabs.tab_padding);
+
+ else if (streq(key, "label-padding"))
+ return value_to_uint16(ctx, 10, &conf->tabs.label_padding);
+
+ else if (streq(key, "margin"))
+ return value_to_uint16(ctx, 10, &conf->tabs.margin);
+
+ else if (streq(key, "corner-radius"))
+ return value_to_uint16(ctx, 10, &conf->tabs.corner_radius);
+
+ else if (streq(key, "background"))
+ return value_to_color(ctx, &conf->tabs.colors.bg, false);
+
+ else if (streq(key, "foreground"))
+ return value_to_color(ctx, &conf->tabs.colors.fg, false);
+
+ else if (streq(key, "active-background"))
+ return value_to_color(ctx, &conf->tabs.colors.active_bg, false);
+
+ else if (streq(key, "active-foreground"))
+ return value_to_color(ctx, &conf->tabs.colors.active_fg, false);
+
+ else if (streq(key, "inherit-cwd"))
+ return value_to_bool(ctx, &conf->tabs.inherit_cwd);
+
+ else {
+ LOG_CONTEXTUAL_ERR("not a valid tabs option: %s", key);
+ return false;
+ }
+}
+
static void
free_binding_aux(struct binding_aux *aux)
{
@@ -3062,6 +3155,7 @@ enum section {
SECTION_ENVIRONMENT,
SECTION_TWEAK,
SECTION_TOUCH,
+ SECTION_TABS,
/* Deprecated */
SECTION_COLORS,
@@ -3098,6 +3192,7 @@ static const struct {
[SECTION_ENVIRONMENT] = {&parse_section_environment, "environment"},
[SECTION_TWEAK] = {&parse_section_tweak, "tweak"},
[SECTION_TOUCH] = {&parse_section_touch, "touch"},
+ [SECTION_TABS] = {&parse_section_tabs, "tabs"},
/* Deprecated */
[SECTION_COLORS] = {&parse_section_colors, "colors"},
@@ -3343,6 +3438,10 @@ add_default_key_bindings(struct config *conf)
{BIND_ACTION_FONT_SIZE_RESET, m(XKB_MOD_NAME_CTRL), {{XKB_KEY_0}}},
{BIND_ACTION_FONT_SIZE_RESET, m(XKB_MOD_NAME_CTRL), {{XKB_KEY_KP_0}}},
{BIND_ACTION_SPAWN_TERMINAL, m(XKB_MOD_NAME_CTRL "+" XKB_MOD_NAME_SHIFT), {{XKB_KEY_n}}},
+ {BIND_ACTION_TAB_NEW, m(XKB_MOD_NAME_CTRL "+" XKB_MOD_NAME_SHIFT), {{XKB_KEY_t}}},
+ {BIND_ACTION_TAB_CLOSE, m(XKB_MOD_NAME_CTRL "+" XKB_MOD_NAME_SHIFT), {{XKB_KEY_w}}},
+ {BIND_ACTION_TAB_NEXT, m(XKB_MOD_NAME_CTRL), {{XKB_KEY_Tab}}},
+ {BIND_ACTION_TAB_PREV, m(XKB_MOD_NAME_CTRL "+" XKB_MOD_NAME_SHIFT), {{XKB_KEY_Tab}}},
{BIND_ACTION_SHOW_URLS_LAUNCH, m(XKB_MOD_NAME_CTRL "+" XKB_MOD_NAME_SHIFT), {{XKB_KEY_o}}},
{BIND_ACTION_UNICODE_INPUT, m(XKB_MOD_NAME_CTRL "+" XKB_MOD_NAME_SHIFT), {{XKB_KEY_u}}},
{BIND_ACTION_PROMPT_PREV, m(XKB_MOD_NAME_CTRL "+" XKB_MOD_NAME_SHIFT), {{XKB_KEY_z}}},
@@ -3630,6 +3729,27 @@ config_load(struct config *conf, const char *conf_path,
.long_press_delay = 400,
},
+ .tabs = {
+ .enabled = false,
+ .inherit_cwd = false,
+ .position = CONF_TABS_POSITION_TOP,
+ .style = CONF_TABS_STYLE_ROUNDED,
+ .layout = CONF_TABS_LAYOUT_SPAN,
+ .height = 26,
+ .tab_width = 200,
+ .tab_padding = 8,
+ .label_padding = 8,
+ .margin = 4,
+ .corner_radius = 6,
+ .title_max_length = 100,
+ .colors = {
+ .bg = 0x1c1c1c,
+ .fg = 0xb0b0b0,
+ .active_bg = 0x3a3a3a,
+ .active_fg = 0xffffff,
+ },
+ },
+
.env_vars = tll_init(),
#if defined(UTMP_DEFAULT_HELPER_PATH)
.utmp_helper_path = ((strlen(UTMP_DEFAULT_HELPER_PATH) > 0 &&
diff --git a/config.h b/config.h
index f8e99df3..905f7079 100644
--- a/config.h
+++ b/config.h
@@ -466,6 +466,37 @@ struct config {
uint32_t long_press_delay;
} touch;
+ struct {
+ bool enabled;
+ enum {
+ CONF_TABS_POSITION_TOP,
+ CONF_TABS_POSITION_BOTTOM,
+ } position;
+ enum {
+ CONF_TABS_STYLE_ROUNDED,
+ CONF_TABS_STYLE_SQUARE,
+ CONF_TABS_STYLE_GRADIENT,
+ } style;
+ enum {
+ CONF_TABS_LAYOUT_SPAN,
+ CONF_TABS_LAYOUT_FLOATING,
+ } layout;
+ uint16_t height; /* pill height; bar = height + margin in floating mode */
+ uint16_t tab_width; /* max tab width in floating mode */
+ uint16_t tab_padding; /* gap between tabs in floating mode */
+ uint16_t label_padding; /* horizontal padding around the label inside each tab pill */
+ uint16_t margin; /* edge gap in floating mode (added to bar height) */
+ uint16_t corner_radius;
+ uint16_t title_max_length; /* max chars in composite window title before eliding with "..." */
+ bool inherit_cwd;
+ struct {
+ uint32_t bg;
+ uint32_t fg;
+ uint32_t active_bg;
+ uint32_t active_fg;
+ } colors;
+ } tabs;
+
user_notifications_t notifications;
};
diff --git a/cursor-shape.c b/cursor-shape.c
index c195a554..e68411c0 100644
--- a/cursor-shape.c
+++ b/cursor-shape.c
@@ -16,6 +16,7 @@ cursor_shape_to_string(enum cursor_shape shape)
[CURSOR_SHAPE_NONE] = {NULL},
[CURSOR_SHAPE_HIDDEN] = {"hidden", NULL},
[CURSOR_SHAPE_LEFT_PTR] = {"default", "left_ptr", NULL},
+ [CURSOR_SHAPE_POINTER] = {"pointer", "hand1", NULL},
[CURSOR_SHAPE_TEXT] = {"text", "xterm", NULL},
[CURSOR_SHAPE_TOP_LEFT_CORNER] = {"nw-resize", "top_left_corner", NULL},
[CURSOR_SHAPE_TOP_RIGHT_CORNER] = {"ne-resize", "top_right_corner", NULL},
@@ -37,6 +38,7 @@ cursor_shape_to_server_shape(enum cursor_shape shape)
{
static const enum wp_cursor_shape_device_v1_shape table[CURSOR_SHAPE_COUNT] = {
[CURSOR_SHAPE_LEFT_PTR] = WP_CURSOR_SHAPE_DEVICE_V1_SHAPE_DEFAULT,
+ [CURSOR_SHAPE_POINTER] = WP_CURSOR_SHAPE_DEVICE_V1_SHAPE_POINTER,
[CURSOR_SHAPE_TEXT] = WP_CURSOR_SHAPE_DEVICE_V1_SHAPE_TEXT,
[CURSOR_SHAPE_TOP_LEFT_CORNER] = WP_CURSOR_SHAPE_DEVICE_V1_SHAPE_NW_RESIZE,
[CURSOR_SHAPE_TOP_RIGHT_CORNER] = WP_CURSOR_SHAPE_DEVICE_V1_SHAPE_NE_RESIZE,
diff --git a/cursor-shape.h b/cursor-shape.h
index 13690588..51a411ed 100644
--- a/cursor-shape.h
+++ b/cursor-shape.h
@@ -8,6 +8,7 @@ enum cursor_shape {
CURSOR_SHAPE_HIDDEN,
CURSOR_SHAPE_LEFT_PTR,
+ CURSOR_SHAPE_POINTER,
CURSOR_SHAPE_TEXT,
CURSOR_SHAPE_TOP_LEFT_CORNER,
CURSOR_SHAPE_TOP_RIGHT_CORNER,
diff --git a/foot.ini b/foot.ini
index 81419d88..8b735fe1 100644
--- a/foot.ini
+++ b/foot.ini
@@ -175,6 +175,24 @@
# Same builtin defaults as [color], except for:
# dim-blend-towards=white
+[tabs]
+enabled=yes
+position=bottom
+style=rounded # rounded | square | gradient
+layout=floating
+height=26
+# tab-width=200 (max width per tab in floating mode)
+# tab-padding=8 (gap between tabs in floating mode)
+# label-padding=8 (horizontal padding around the label inside each tab pill)
+# margin=4 (edge gap; auto-added to bar height, does not squish pill)
+# corner-radius=6 (corner rounding in pixels)
+# title-max-length=100 (multi-tab window title elides at this many chars with "...")
+# background=1c1c1c
+# foreground=b0b0b0
+# active-background=3a3a3a
+# active-foreground=ffffff
+# inherit-cwd=no (new tabs open in the active tab's cwd; requires OSC 7 shell support)
+
[csd]
# preferred=server
# size=26
diff --git a/input.c b/input.c
index 6a829a70..92e9fc1a 100644
--- a/input.c
+++ b/input.c
@@ -457,6 +457,45 @@ execute_binding(struct seat *seat, struct terminal *term,
term_shutdown(term);
return true;
+ case BIND_ACTION_TAB_NEW:
+ term_tab_new(term, 0, NULL, NULL, term->shutdown.cb, term->shutdown.cb_data);
+ return true;
+
+ case BIND_ACTION_TAB_CLOSE:
+ term_tab_close(term);
+ return true;
+
+ case BIND_ACTION_TAB_NEXT: {
+ struct wl_window *win = term->window;
+ if (win->tab_count > 1)
+ term_tab_switch(win, (win->active_tab + 1) % win->tab_count);
+ return true;
+ }
+
+ case BIND_ACTION_TAB_PREV: {
+ struct wl_window *win = term->window;
+ if (win->tab_count > 1)
+ term_tab_switch(win,
+ win->active_tab == 0 ? win->tab_count - 1 : win->active_tab - 1);
+ return true;
+ }
+
+ case BIND_ACTION_TAB_1:
+ case BIND_ACTION_TAB_2:
+ case BIND_ACTION_TAB_3:
+ case BIND_ACTION_TAB_4:
+ case BIND_ACTION_TAB_5:
+ case BIND_ACTION_TAB_6:
+ case BIND_ACTION_TAB_7:
+ case BIND_ACTION_TAB_8:
+ case BIND_ACTION_TAB_9: {
+ size_t idx = (size_t)(action - BIND_ACTION_TAB_1);
+ struct wl_window *win = term->window;
+ if (idx < win->tab_count)
+ term_tab_switch(win, idx);
+ return true;
+ }
+
case BIND_ACTION_REGEX_LAUNCH:
case BIND_ACTION_REGEX_COPY:
if (binding->aux->type != BINDING_AUX_REGEX)
@@ -2510,6 +2549,7 @@ wl_pointer_enter(void *data, struct wl_pointer *wl_pointer,
case TERM_SURF_BORDER_RIGHT:
case TERM_SURF_BORDER_TOP:
case TERM_SURF_BORDER_BOTTOM:
+ case TERM_SURF_TAB_BAR:
break;
case TERM_SURF_BUTTON_MINIMIZE:
@@ -2582,7 +2622,7 @@ wl_pointer_leave(void *data, struct wl_pointer *wl_pointer,
if (surface != NULL) {
/* Sway 1.4 sends this event with a NULL surface when we destroy the window */
const struct wl_window UNUSED *win = wl_surface_get_user_data(surface);
- xassert(old_moused == win->term);
+ xassert(old_moused->window == win);
}
enum term_surface active_surface = old_moused->active_surface;
@@ -2609,6 +2649,7 @@ wl_pointer_leave(void *data, struct wl_pointer *wl_pointer,
case TERM_SURF_BORDER_RIGHT:
case TERM_SURF_BORDER_TOP:
case TERM_SURF_BORDER_BOTTOM:
+ case TERM_SURF_TAB_BAR:
break;
}
@@ -2693,6 +2734,7 @@ wl_pointer_motion(void *data, struct wl_pointer *wl_pointer,
case TERM_SURF_BORDER_RIGHT:
case TERM_SURF_BORDER_TOP:
case TERM_SURF_BORDER_BOTTOM:
+ case TERM_SURF_TAB_BAR:
break;
}
@@ -2743,6 +2785,7 @@ wl_pointer_motion(void *data, struct wl_pointer *wl_pointer,
case TERM_SURF_BORDER_RIGHT:
case TERM_SURF_BORDER_TOP:
case TERM_SURF_BORDER_BOTTOM:
+ case TERM_SURF_TAB_BAR:
break;
case TERM_SURF_GRID: {
@@ -3294,6 +3337,21 @@ wl_pointer_button(void *data, struct wl_pointer *wl_pointer,
break;
}
+ case TERM_SURF_TAB_BAR: {
+ if (state != WL_POINTER_BUTTON_STATE_PRESSED)
+ break;
+ if (button != BTN_LEFT)
+ break;
+
+ size_t idx = term_tab_bar_hit_test(term, seat->mouse.x, seat->mouse.y);
+ if (idx == SIZE_MAX || idx >= term->window->tab_count)
+ break;
+ if (idx == term->window->active_tab)
+ break;
+ term_tab_switch(term->window, idx);
+ break;
+ }
+
case TERM_SURF_NONE:
BUG("Invalid surface type");
break;
diff --git a/key-binding.h b/key-binding.h
index c4a04e99..acc11a7a 100644
--- a/key-binding.h
+++ b/key-binding.h
@@ -49,6 +49,21 @@ enum bind_action_normal {
BIND_ACTION_THEME_SWITCH_LIGHT,
BIND_ACTION_THEME_TOGGLE,
+ /* Tab actions */
+ BIND_ACTION_TAB_NEW,
+ BIND_ACTION_TAB_CLOSE,
+ BIND_ACTION_TAB_NEXT,
+ BIND_ACTION_TAB_PREV,
+ BIND_ACTION_TAB_1,
+ BIND_ACTION_TAB_2,
+ BIND_ACTION_TAB_3,
+ BIND_ACTION_TAB_4,
+ BIND_ACTION_TAB_5,
+ BIND_ACTION_TAB_6,
+ BIND_ACTION_TAB_7,
+ BIND_ACTION_TAB_8,
+ BIND_ACTION_TAB_9,
+
/* Mouse specific actions - i.e. they require a mouse coordinate */
BIND_ACTION_SCROLLBACK_UP_MOUSE,
BIND_ACTION_SCROLLBACK_DOWN_MOUSE,
@@ -61,7 +76,7 @@ enum bind_action_normal {
BIND_ACTION_SELECT_QUOTE,
BIND_ACTION_SELECT_ROW,
- BIND_ACTION_KEY_COUNT = BIND_ACTION_THEME_TOGGLE + 1,
+ BIND_ACTION_KEY_COUNT = BIND_ACTION_TAB_9 + 1,
BIND_ACTION_COUNT = BIND_ACTION_SELECT_ROW + 1,
};
diff --git a/osc.c b/osc.c
index 82793fb5..90704b70 100644
--- a/osc.c
+++ b/osc.c
@@ -15,6 +15,7 @@
#include "macros.h"
#include "notify.h"
#include "selection.h"
+#include "render.h"
#include "terminal.h"
#include "uri.h"
#include "util.h"
@@ -468,6 +469,7 @@ osc_set_pwd(struct terminal *term, char *string)
LOG_DBG("OSC7: pwd: %s", path);
free(term->cwd);
term->cwd = path;
+ render_refresh_tab_bar(term);
} else
free(path);
diff --git a/pgo/pgo.c b/pgo/pgo.c
index 4ff4111c..96ddcce7 100644
--- a/pgo/pgo.c
+++ b/pgo/pgo.c
@@ -71,6 +71,7 @@ void render_refresh_csd(struct terminal *term) {}
void render_refresh_title(struct terminal *term) {}
void render_refresh_app_id(struct terminal *term) {}
void render_refresh_icon(struct terminal *term) {}
+void render_refresh_tab_bar(struct terminal *term) {}
void render_overlay(struct terminal *term) {}
diff --git a/render.c b/render.c
index f74e9251..d7887d45 100644
--- a/render.c
+++ b/render.c
@@ -60,6 +60,7 @@ static struct {
} presentation_statistics = {0};
static void fdm_hook_refresh_pending_terminals(struct fdm *fdm, void *data);
+static void render_tab_bar(struct terminal *term);
struct renderer *
render_init(struct fdm *fdm, struct wayland *wayl)
@@ -4282,14 +4283,87 @@ render_urls(struct terminal *term)
}
}
+/* Build "tab1 | tab2 | ... | tabN" composite. Result is xmalloc'd; caller frees.
+ * If the codepoint count exceeds max_chars, the tail is replaced with "...". */
+static char *
+build_composite_title(const struct wl_window *win, size_t max_chars)
+{
+ /* Sum the bytes needed for the full untruncated composite */
+ size_t cap = 1;
+ for (size_t i = 0; i < win->tab_count; i++) {
+ const struct terminal *tab = win->tabs[i];
+ const char *t = tab->window_title != NULL ? tab->window_title : "foot";
+ cap += strlen(t) + (i > 0 ? 3 : 0);
+ }
+
+ char *full = xmalloc(cap);
+ size_t pos = 0;
+ for (size_t i = 0; i < win->tab_count; i++) {
+ const struct terminal *tab = win->tabs[i];
+ const char *t = tab->window_title != NULL ? tab->window_title : "foot";
+ if (i > 0) {
+ memcpy(full + pos, " | ", 3);
+ pos += 3;
+ }
+ size_t tlen = strlen(t);
+ memcpy(full + pos, t, tlen);
+ pos += tlen;
+ }
+ full[pos] = '\0';
+
+ /* Count codepoints */
+ size_t total = 0;
+ const unsigned char *p = (const unsigned char *)full;
+ while (*p != '\0') {
+ utf8proc_int32_t cp;
+ utf8proc_ssize_t consumed = utf8proc_iterate(p, -1, &cp);
+ if (consumed <= 0) break;
+ p += consumed;
+ total++;
+ }
+
+ if (total <= max_chars)
+ return full;
+
+ /* Walk the prefix to find the byte cut for (max_chars - 3) codepoints */
+ const size_t target = max_chars > 3 ? max_chars - 3 : 0;
+ p = (const unsigned char *)full;
+ size_t k = 0;
+ size_t cut = 0;
+ while (*p != '\0' && k < target) {
+ utf8proc_int32_t cp;
+ utf8proc_ssize_t consumed = utf8proc_iterate(p, -1, &cp);
+ if (consumed <= 0) break;
+ p += consumed;
+ cut = (size_t)((const char *)p - full);
+ k++;
+ }
+
+ char *result = xmalloc(cut + 4);
+ memcpy(result, full, cut);
+ memcpy(result + cut, "...", 3);
+ result[cut + 3] = '\0';
+ free(full);
+ return result;
+}
+
static void
render_update_title(struct terminal *term)
{
static const size_t max_len = 2048;
- const char *title = term->window_title != NULL ? term->window_title : "foot";
- char *copy = NULL;
+ struct wl_window *win = term->window;
+ char *composite = NULL;
+ const char *title;
+
+ if (win != NULL && win->tab_count > 1) {
+ composite = build_composite_title(win, term->conf->tabs.title_max_length);
+ title = composite;
+ } else {
+ title = term->window_title != NULL ? term->window_title : "foot";
+ }
+ char *copy = NULL;
if (strlen(title) > max_len) {
copy = xstrndup(title, max_len);
title = copy;
@@ -4297,6 +4371,7 @@ render_update_title(struct terminal *term)
xdg_toplevel_set_title(term->window->xdg_toplevel, title);
free(copy);
+ free(composite);
}
static void
@@ -4614,10 +4689,28 @@ render_resize(struct terminal *term, int width, int height, uint8_t opts)
/* Padding */
const int max_pad_x = (width - min_width) / 2;
const int max_pad_y = (height - min_height) / 2;
+ /* Total bar height = pill height + margin (margin only used in floating layout).
+ * Only reserve space for the bar when more than one tab is open. */
+ const bool tab_bar_visible =
+ term->conf->tabs.enabled && term->window->tab_count > 1;
+ const uint16_t tabs_bar_logical =
+ tab_bar_visible
+ ? (term->conf->tabs.height +
+ (term->conf->tabs.layout == CONF_TABS_LAYOUT_FLOATING
+ ? term->conf->tabs.margin : 0))
+ : 0;
+ const int conf_pad_top = term->conf->pad_top +
+ (tab_bar_visible &&
+ term->conf->tabs.position == CONF_TABS_POSITION_TOP
+ ? tabs_bar_logical : 0);
+ const int conf_pad_bottom = term->conf->pad_bottom +
+ (tab_bar_visible &&
+ term->conf->tabs.position == CONF_TABS_POSITION_BOTTOM
+ ? tabs_bar_logical : 0);
const int pad_left = min(max_pad_x, scale * term->conf->pad_left);
const int pad_right = min(max_pad_x, scale * term->conf->pad_right);
- const int pad_top = min(max_pad_y, scale * term->conf->pad_top);
- const int pad_bottom= min(max_pad_y, scale * term->conf->pad_bottom);
+ const int pad_top = min(max_pad_y, scale * conf_pad_top);
+ const int pad_bottom= min(max_pad_y, scale * conf_pad_bottom);
if (is_floating &&
(opts & RESIZE_BY_CELLS) &&
@@ -4709,8 +4802,8 @@ render_resize(struct terminal *term, int width, int height, uint8_t opts)
(center == CENTER_FULLSCREEN && term->window->is_fullscreen);
if (centered_padding && !term->window->is_resizing) {
- term->margins.left = total_x_pad / 2;
- term->margins.top = total_y_pad / 2;
+ term->margins.left = max(pad_left, min(total_x_pad - pad_right, total_x_pad / 2));
+ term->margins.top = max(pad_top, min(total_y_pad - pad_bottom, total_y_pad / 2));
} else {
term->margins.left = pad_left;
term->margins.top = pad_top;
@@ -5133,6 +5226,10 @@ fdm_hook_refresh_pending_terminals(struct fdm *fdm, void *data)
if (unlikely(term->shutdown.in_progress || !term->window->is_configured))
continue;
+ /* Skip inactive tabs - they process PTY data but don't render */
+ if (term->window->term != term)
+ continue;
+
bool grid = term->render.refresh.grid;
bool csd = term->render.refresh.csd;
bool search = term->is_searching && term->render.refresh.search;
@@ -5165,6 +5262,8 @@ fdm_hook_refresh_pending_terminals(struct fdm *fdm, void *data)
render_search_box(term);
if (urls)
render_urls(term);
+ if (term->conf->tabs.enabled)
+ render_tab_bar(term);
if (grid | csd | search | urls)
grid_render(term);
@@ -5180,6 +5279,15 @@ fdm_hook_refresh_pending_terminals(struct fdm *fdm, void *data)
term->render.pending.csd |= csd;
term->render.pending.search |= search;
term->render.pending.urls |= urls;
+
+ /* The tab bar is a desync subsurface — its commits apply
+ * independently of the parent surface, so we can repaint it
+ * now instead of deferring to the frame callback (which never
+ * calls render_tab_bar anyway). Without this, title changes
+ * while a frame is in flight leave the tab label stale until
+ * something else dirties the grid. */
+ if (grid && term->conf->tabs.enabled)
+ render_tab_bar(term);
}
}
@@ -5310,6 +5418,509 @@ render_refresh_urls(struct terminal *term)
term->render.refresh.urls = true;
}
+void
+render_refresh_tab_bar(struct terminal *term)
+{
+ if (term->conf->tabs.enabled)
+ term->render.refresh.grid = true; /* triggers full re-render which includes tab bar */
+}
+
+static void
+draw_rounded_corner_aa(pixman_image_t *pix, const pixman_color_t *color,
+ int dx, int dy, int r,
+ int cx_inside, int cy_inside)
+{
+ if (r <= 0)
+ return;
+
+ const int stride = (r + 3) & ~3; /* A8 stride aligned to 4 bytes */
+ uint8_t *data = xcalloc(stride * r, sizeof(uint8_t));
+
+ /* Circle center in local (0..r, 0..r) coords of the corner tile. */
+ const float cx = cx_inside > 0 ? (float)r : 0.f;
+ const float cy = cy_inside > 0 ? (float)r : 0.f;
+ const float r_f = (float)r;
+
+ for (int py = 0; py < r; py++) {
+ for (int px = 0; px < r; px++) {
+ const float ex = (float)px + 0.5f - cx;
+ const float ey = (float)py + 0.5f - cy;
+ const float dist = sqrtf(ex * ex + ey * ey);
+ float cov = r_f - dist + 0.5f;
+ if (cov <= 0.f)
+ cov = 0.f;
+ else if (cov >= 1.f)
+ cov = 1.f;
+ data[py * stride + px] = (uint8_t)(cov * 255.f + 0.5f);
+ }
+ }
+
+ pixman_image_t *mask = pixman_image_create_bits(
+ PIXMAN_a8, r, r, (uint32_t *)data, stride);
+ pixman_image_t *src = pixman_image_create_solid_fill(color);
+ pixman_image_composite32(PIXMAN_OP_OVER,
+ src, mask, pix, 0, 0, 0, 0, dx, dy, r, r);
+ pixman_image_unref(mask);
+ pixman_image_unref(src);
+ free(data);
+}
+
+static void
+draw_rounded_rect(pixman_image_t *pix, const pixman_color_t *color,
+ int x, int y, int w, int h, int r, unsigned corners)
+{
+ if (r <= 0 || w <= 0 || h <= 0) {
+ pixman_image_fill_rectangles(PIXMAN_OP_SRC, pix, color, 1,
+ &(pixman_rectangle16_t){x, y, w, h});
+ return;
+ }
+ r = min(r, min(w / 2, h / 2));
+
+ const bool tl = corners & 1;
+ const bool tr = corners & 2;
+ const bool bl = corners & 4;
+ const bool br = corners & 8;
+
+ /* Fill body regions. Corners handled separately if rounded. */
+ /* Top band */
+ pixman_image_fill_rectangles(PIXMAN_OP_SRC, pix, color, 1,
+ &(pixman_rectangle16_t){x + (tl ? r : 0), y,
+ w - (tl ? r : 0) - (tr ? r : 0), r});
+ /* Middle band (full width) */
+ pixman_image_fill_rectangles(PIXMAN_OP_SRC, pix, color, 1,
+ &(pixman_rectangle16_t){x, y + r, w, h - 2 * r});
+ /* Bottom band */
+ pixman_image_fill_rectangles(PIXMAN_OP_SRC, pix, color, 1,
+ &(pixman_rectangle16_t){x + (bl ? r : 0), y + h - r,
+ w - (bl ? r : 0) - (br ? r : 0), r});
+
+ /* Square corners: fill the r×r square. */
+ if (!tl)
+ pixman_image_fill_rectangles(PIXMAN_OP_SRC, pix, color, 1,
+ &(pixman_rectangle16_t){x, y, r, r});
+ if (!tr)
+ pixman_image_fill_rectangles(PIXMAN_OP_SRC, pix, color, 1,
+ &(pixman_rectangle16_t){x + w - r, y, r, r});
+ if (!bl)
+ pixman_image_fill_rectangles(PIXMAN_OP_SRC, pix, color, 1,
+ &(pixman_rectangle16_t){x, y + h - r, r, r});
+ if (!br)
+ pixman_image_fill_rectangles(PIXMAN_OP_SRC, pix, color, 1,
+ &(pixman_rectangle16_t){x + w - r, y + h - r, r, r});
+
+ /* Rounded corners: anti-aliased quarter-circles. */
+ if (tl) draw_rounded_corner_aa(pix, color, x, y, r, 1, 1);
+ if (tr) draw_rounded_corner_aa(pix, color, x + w - r, y, r, -1, 1);
+ if (bl) draw_rounded_corner_aa(pix, color, x, y + h - r, r, 1, -1);
+ if (br) draw_rounded_corner_aa(pix, color, x + w - r, y + h - r, r, -1, -1);
+}
+
+static int
+tab_label_build(char *buf, size_t bufsz,
+ const struct terminal *tab, size_t idx)
+{
+ const char *title = NULL;
+ if (tab->window_title_has_been_set && tab->window_title != NULL)
+ title = tab->window_title;
+ else if (tab->cwd != NULL) {
+ const char *slash = strrchr(tab->cwd, '/');
+ title = (slash != NULL && slash[1] != '\0') ? slash + 1 : tab->cwd;
+ }
+ int len = snprintf(buf, bufsz, "%zu: %s", idx + 1,
+ title != NULL ? title : "");
+ if (len < 0)
+ return 0;
+ if ((size_t)len >= bufsz)
+ len = (int)bufsz - 1;
+ return len;
+}
+
+static int
+tab_label_width(struct fcft_font *font, const char *buf, int len)
+{
+ if (font == NULL || len <= 0)
+ return 0;
+
+ int total = 0;
+ const unsigned char *p = (const unsigned char *)buf;
+ const unsigned char *end = p + len;
+ while (p < end) {
+ utf8proc_int32_t cp;
+ utf8proc_ssize_t consumed = utf8proc_iterate(p, end - p, &cp);
+ if (consumed <= 0 || cp < 0) {
+ p++;
+ continue;
+ }
+ p += consumed;
+
+ const struct fcft_glyph *g = fcft_rasterize_char_utf32(
+ font, (uint32_t)cp, FCFT_SUBPIXEL_NONE);
+ if (g != NULL)
+ total += g->advance.x;
+ }
+ return total;
+}
+
+static void
+render_tab_label(pixman_image_t *pix, struct fcft_font *font,
+ const pixman_color_t *fg, const struct terminal *tab,
+ size_t idx, int x, int y, int max_x, float scale)
+{
+ char label_buf[256];
+ int label_len = tab_label_build(label_buf, sizeof(label_buf), tab, idx);
+ if (label_len <= 0 || font == NULL)
+ return;
+
+ const int pad = (int)roundf(tab->conf->tabs.label_padding * scale);
+ int gx = x + pad;
+ const int clip_x = max_x - pad;
+
+ const unsigned char *p = (const unsigned char *)label_buf;
+ const unsigned char *end = p + label_len;
+ while (p < end) {
+ utf8proc_int32_t cp;
+ utf8proc_ssize_t consumed = utf8proc_iterate(p, end - p, &cp);
+ if (consumed <= 0 || cp < 0) {
+ p++;
+ continue;
+ }
+ p += consumed;
+
+ const struct fcft_glyph *glyph = fcft_rasterize_char_utf32(
+ font, (uint32_t)cp, FCFT_SUBPIXEL_NONE);
+ if (glyph == NULL)
+ continue;
+
+ if (gx + glyph->advance.x > clip_x)
+ break;
+
+ if (pixman_image_get_format(glyph->pix) == PIXMAN_a8r8g8b8) {
+ pixman_image_composite32(PIXMAN_OP_OVER,
+ glyph->pix, NULL, pix, 0, 0, 0, 0,
+ gx + glyph->x, y - glyph->y,
+ glyph->width, glyph->height);
+ } else {
+ pixman_image_t *src = pixman_image_create_solid_fill(fg);
+ pixman_image_composite32(PIXMAN_OP_OVER,
+ src, glyph->pix, pix, 0, 0, 0, 0,
+ gx + glyph->x, y - glyph->y,
+ glyph->width, glyph->height);
+ pixman_image_unref(src);
+ }
+ gx += glyph->advance.x;
+ }
+}
+
+/* Greyscale ramp indices for the gradient tab style.
+ * The 256-color palette greyscale ramp lives at indices 232..255. */
+#define GRADIENT_BAR_BG_IDX 234
+#define GRADIENT_PILL_ACTIVE_IDX 250
+#define GRADIENT_PILL_INACTIVE_IDX 240
+#define GRADIENT_FG_ACTIVE_IDX 232
+#define GRADIENT_FG_INACTIVE_IDX 250
+
+/* Hardcoded fade level masks (braille bitmasks); index = fade level (1..6).
+ * Mirrors the visual progression: ⠐ ⠡ ⡐ ⢔ ⣑ ⣪ */
+static const uint8_t gradient_fade_masks[7] = {
+ 0x00, 0x10, 0x21, 0x50, 0x94, 0xD1, 0xEA,
+};
+
+/* Braille bit (0..7) → (col, row) within the 2-col × 4-row dot grid */
+static const struct { uint8_t col, row; } gradient_dot_pos[8] = {
+ {0, 0}, {0, 1}, {0, 2}, {1, 0}, {1, 1}, {1, 2}, {0, 3}, {1, 3},
+};
+
+static void
+draw_gradient_pill(pixman_image_t *pix, const struct terminal *term,
+ uint8_t pill_idx, uint8_t bar_bg_idx, bool gamma_correct,
+ int x, int y, int w, int h, float scale)
+{
+ const int n_levels = (int)ALEN(gradient_fade_masks) - 1; /* 6 */
+ const int cell_w = max(2, (int)roundf(scale * 4));
+ const int fade_w = n_levels * cell_w;
+
+ const pixman_color_t pill = color_hex_to_pixman(
+ term->colors.table[pill_idx], gamma_correct);
+
+ if (w <= 2 * fade_w) {
+ /* Tab too narrow for full fades — draw it solid */
+ pixman_image_fill_rectangles(PIXMAN_OP_SRC, pix, &pill,
+ 1, &(pixman_rectangle16_t){x, y, w, h});
+ return;
+ }
+
+ /* Solid pill interior */
+ pixman_image_fill_rectangles(PIXMAN_OP_SRC, pix, &pill,
+ 1, &(pixman_rectangle16_t){x + fade_w, y, w - 2 * fade_w, h});
+
+ const int dot_size = max(1, (int)roundf(scale));
+ const float sub_w = (float)cell_w / 2.0f;
+ const float sub_h = (float)h / 4.0f;
+
+ const int inner = (int)pill_idx - 1;
+ const int outer = (int)bar_bg_idx;
+ const int range = inner - outer;
+ const int t_den = max(1, n_levels - 1);
+
+ for (int side = 0; side < 2; side++) {
+ const bool left = (side == 0);
+ for (int i = 1; i <= n_levels; i++) {
+ /* i == 1 is the outermost cell, i == n_levels the innermost */
+ const int cell_x = left
+ ? x + (i - 1) * cell_w
+ : x + w - i * cell_w;
+
+ const uint8_t band_idx = (uint8_t)(
+ outer + (range * (i - 1) + t_den / 2) / t_den);
+ const uint8_t dot_idx = band_idx > 233 ? band_idx - 2 : 232;
+
+ const pixman_color_t band = color_hex_to_pixman(
+ term->colors.table[band_idx], gamma_correct);
+ pixman_image_fill_rectangles(PIXMAN_OP_SRC, pix, &band,
+ 1, &(pixman_rectangle16_t){cell_x, y, cell_w, h});
+
+ const uint8_t mask = gradient_fade_masks[i];
+ const pixman_color_t dot = color_hex_to_pixman(
+ term->colors.table[dot_idx], gamma_correct);
+
+ pixman_rectangle16_t dot_rects[8];
+ int n_dots = 0;
+ for (int b = 0; b < 8; b++) {
+ if (!(mask & (1u << b)))
+ continue;
+ const int dx = (int)roundf((gradient_dot_pos[b].col + 0.5f) * sub_w)
+ - dot_size / 2;
+ const int dy = (int)roundf((gradient_dot_pos[b].row + 0.5f) * sub_h)
+ - dot_size / 2;
+ dot_rects[n_dots++] = (pixman_rectangle16_t){
+ cell_x + dx, y + dy, dot_size, dot_size,
+ };
+ }
+ if (n_dots > 0)
+ pixman_image_fill_rectangles(PIXMAN_OP_SRC, pix, &dot,
+ n_dots, dot_rects);
+ }
+ }
+}
+
+static void
+render_tab_bar(struct terminal *term)
+{
+ struct wl_window *win = term->window;
+ if (win->tab_bar.sub == NULL)
+ return;
+
+ const struct config *conf = term->conf;
+ const float scale = term->scale;
+ const bool floating = (conf->tabs.layout == CONF_TABS_LAYOUT_FLOATING);
+ const bool pos_top = (conf->tabs.position == CONF_TABS_POSITION_TOP);
+
+ /* pill height is always conf->tabs.height; total bar = pill + margin in floating */
+ const int margin_px = floating ? (int)roundf(scale * conf->tabs.margin) : 0;
+ const int tab_h_px = (int)roundf(scale * conf->tabs.height);
+ const int total_h = tab_h_px + margin_px;
+ const int width = term->width;
+
+ if (width <= 0 || total_h <= 0)
+ return;
+
+ /* Don't draw the tab bar unless more than one tab is open */
+ if (win->tab_count <= 1) {
+ win->tab_layout.count = 0;
+ wl_surface_attach(win->tab_bar.surface.surf, NULL, 0, 0);
+ wl_surface_commit(win->tab_bar.surface.surf);
+ return;
+ }
+
+ struct buffer_chain *chain = term->render.chains.tab_bar;
+ struct buffer *buf = shm_get_buffer(chain, width, total_h);
+ if (buf == NULL)
+ return;
+
+ const bool gamma_correct = wayl_do_linear_blending(win->term->wl, conf);
+ const bool rounded = (conf->tabs.style == CONF_TABS_STYLE_ROUNDED);
+ const bool gradient = (conf->tabs.style == CONF_TABS_STYLE_GRADIENT);
+ const int r = rounded ? (int)roundf(scale * conf->tabs.corner_radius) : 0;
+
+ /* Clear buffer: transparent for floating (shows terminal behind gaps), bg for span.
+ * Gradient style overrides the configured bar bg with a fixed greyscale ramp index. */
+ const pixman_color_t transparent = {0, 0, 0, 0};
+ const pixman_color_t bg_color = gradient
+ ? color_hex_to_pixman(term->colors.table[GRADIENT_BAR_BG_IDX], gamma_correct)
+ : color_hex_to_pixman(conf->tabs.colors.bg, gamma_correct);
+ pixman_image_fill_rectangles(PIXMAN_OP_SRC, buf->pix[0],
+ floating ? &transparent : &bg_color,
+ 1, &(pixman_rectangle16_t){0, 0, width, total_h});
+
+ const size_t tab_count = win->tab_count;
+ if (tab_count == 0)
+ goto commit;
+
+ struct fcft_font *font = term->fonts[0];
+
+ /* Per-tab layout arrays */
+ int tab_xs[TAB_MAX];
+ int tab_ws[TAB_MAX];
+ int tab_y, tab_h;
+
+ const size_t n = min(tab_count, (size_t)TAB_MAX);
+
+ if (floating) {
+ const int pad_px = (int)roundf(scale * conf->tabs.tab_padding);
+ const int max_tw = (int)roundf(scale * conf->tabs.tab_width);
+ const int label_pad = (int)roundf(scale * conf->tabs.label_padding);
+ const int min_tw = max(2 * label_pad, 1);
+
+ /* Measure each tab's natural width (label + padding), capped at max_tw */
+ int natural_w[TAB_MAX];
+ int total_w = 0;
+ for (size_t i = 0; i < n; i++) {
+ char buf_s[256];
+ int len = tab_label_build(buf_s, sizeof(buf_s), win->tabs[i], i);
+ int lw = tab_label_width(font, buf_s, len);
+ int w = lw + 2 * label_pad;
+ if (w > max_tw) w = max_tw;
+ if (w < min_tw) w = min_tw;
+ natural_w[i] = w;
+ total_w += w;
+ }
+ const int total_pads = pad_px * ((int)n - 1);
+ int bar_w = total_w + total_pads;
+
+ /* Shrink uniformly if we overflow the bar width */
+ if (bar_w > width && total_w > 0) {
+ const int avail = max(width - total_pads, (int)n);
+ int used = 0;
+ for (size_t i = 0; i < n; i++) {
+ int w = (int)((int64_t)natural_w[i] * avail / total_w);
+ if (w < 1) w = 1;
+ tab_ws[i] = w;
+ used += w;
+ }
+ /* Distribute rounding residue to the first few tabs */
+ int residue = avail - used;
+ for (size_t i = 0; i < n && residue > 0; i++, residue--)
+ tab_ws[i]++;
+ bar_w = avail + total_pads;
+ } else {
+ for (size_t i = 0; i < n; i++)
+ tab_ws[i] = natural_w[i];
+ }
+
+ int x_cur = (width - bar_w) / 2;
+ for (size_t i = 0; i < n; i++) {
+ tab_xs[i] = x_cur;
+ x_cur += tab_ws[i] + pad_px;
+ }
+
+ tab_y = pos_top ? margin_px : 0;
+ tab_h = tab_h_px;
+ } else {
+ const int base = width / (int)n;
+ int x_cur = 0;
+ for (size_t i = 0; i < n; i++) {
+ tab_xs[i] = x_cur;
+ tab_ws[i] = (i + 1 == n) ? (width - x_cur) : base;
+ x_cur += base;
+ }
+ tab_y = 0;
+ tab_h = tab_h_px;
+ }
+
+ /* Text baseline centered within the pill */
+ const int text_baseline = tab_y + (font
+ ? (tab_h - (int)(font->ascent + font->descent)) / 2 + (int)font->ascent
+ : tab_h / 2);
+
+ for (size_t i = 0; i < n; i++) {
+ struct terminal *tab = win->tabs[i];
+ const bool is_active = (i == win->active_tab);
+ const int x = tab_xs[i];
+ const int w = tab_ws[i];
+
+ const pixman_color_t fg_color = gradient
+ ? color_hex_to_pixman(
+ term->colors.table[is_active
+ ? GRADIENT_FG_ACTIVE_IDX : GRADIENT_FG_INACTIVE_IDX],
+ gamma_correct)
+ : color_hex_to_pixman(
+ is_active ? conf->tabs.colors.active_fg : conf->tabs.colors.fg,
+ gamma_correct);
+
+ if (gradient) {
+ const uint8_t pill_idx = is_active
+ ? GRADIENT_PILL_ACTIVE_IDX : GRADIENT_PILL_INACTIVE_IDX;
+ draw_gradient_pill(buf->pix[0], term, pill_idx, GRADIENT_BAR_BG_IDX,
+ gamma_correct, x, tab_y, w, tab_h, scale);
+ } else if (rounded) {
+ /* Floating: all 4 corners rounded. Span: only the open edge rounded. */
+ const pixman_color_t tab_bg = color_hex_to_pixman(
+ is_active ? conf->tabs.colors.active_bg : conf->tabs.colors.bg,
+ gamma_correct);
+ unsigned corners;
+ if (floating) {
+ corners = 0xf; /* all corners */
+ } else if (pos_top) {
+ corners = 0x3; /* top-left + top-right */
+ } else {
+ corners = 0xc; /* bottom-left + bottom-right */
+ }
+ draw_rounded_rect(buf->pix[0], &tab_bg, x, tab_y, w, tab_h, r, corners);
+ } else {
+ const pixman_color_t tab_bg = color_hex_to_pixman(
+ is_active ? conf->tabs.colors.active_bg : conf->tabs.colors.bg,
+ gamma_correct);
+ pixman_image_fill_rectangles(PIXMAN_OP_SRC, buf->pix[0], &tab_bg,
+ 1, &(pixman_rectangle16_t){x, tab_y, w, tab_h});
+ }
+
+ /* Span: separator between inactive tabs (skipped for gradient — fades
+ * already provide visual separation) */
+ if (!floating && !is_active && i + 1 < n && !gradient) {
+ const pixman_color_t sep = color_hex_to_pixman(
+ conf->tabs.colors.bg, gamma_correct);
+ pixman_image_fill_rectangles(PIXMAN_OP_SRC, buf->pix[0], &sep,
+ 1, &(pixman_rectangle16_t){x + w - 1, tab_y + 2, 1, tab_h - 4});
+ }
+
+ if (font != NULL)
+ render_tab_label(buf->pix[0], font, &fg_color, tab, i,
+ x, text_baseline, x + w, scale);
+ }
+
+ /* Publish layout for hit-testing */
+ for (size_t i = 0; i < n; i++) {
+ win->tab_layout.xs[i] = tab_xs[i];
+ win->tab_layout.ws[i] = tab_ws[i];
+ }
+ win->tab_layout.y = tab_y;
+ win->tab_layout.h = tab_h;
+ win->tab_layout.count = n;
+
+commit: ;
+ if (tab_count == 0)
+ win->tab_layout.count = 0;
+ const int y_pos = pos_top ? 0 : term->height - total_h;
+ wl_subsurface_set_position(win->tab_bar.sub, 0, (int)roundf(y_pos / scale));
+
+ wayl_surface_scale(win, &win->tab_bar.surface, buf, scale);
+ wl_surface_attach(win->tab_bar.surface.surf, buf->wl_buf, 0, 0);
+ wl_surface_damage_buffer(win->tab_bar.surface.surf, 0, 0, width, total_h);
+
+ if (!floating) {
+ struct wl_region *region = wl_compositor_create_region(term->wl->compositor);
+ if (region != NULL) {
+ wl_region_add(region, 0, 0, width, total_h);
+ wl_surface_set_opaque_region(win->tab_bar.surface.surf, region);
+ wl_region_destroy(region);
+ }
+ } else {
+ wl_surface_set_opaque_region(win->tab_bar.surface.surf, NULL);
+ }
+
+ wl_surface_commit(win->tab_bar.surface.surf);
+}
+
bool
render_xcursor_set(struct seat *seat, struct terminal *term,
enum cursor_shape shape)
diff --git a/render.h b/render.h
index e6674ab2..0277d2be 100644
--- a/render.h
+++ b/render.h
@@ -27,6 +27,7 @@ void render_refresh_csd(struct terminal *term);
void render_refresh_search(struct terminal *term);
void render_refresh_title(struct terminal *term);
void render_refresh_urls(struct terminal *term);
+void render_refresh_tab_bar(struct terminal *term);
bool render_xcursor_set(
struct seat *seat, struct terminal *term, enum cursor_shape shape);
bool render_xcursor_is_valid(const struct seat *seat, const char *cursor);
diff --git a/terminal.c b/terminal.c
index 8eafbcbe..5446b1ea 100644
--- a/terminal.c
+++ b/terminal.c
@@ -1371,6 +1371,7 @@ term_init(const struct config *conf, struct fdm *fdm, struct reaper *reaper,
.url = shm_chain_new(wayl, false, 1, desired_bit_depth, NULL, NULL),
.csd = shm_chain_new(wayl, false, 1, desired_bit_depth, NULL, NULL),
.overlay = shm_chain_new(wayl, false, 1, desired_bit_depth, NULL, NULL),
+ .tab_bar = shm_chain_new(wayl, false, 1, desired_bit_depth, NULL, NULL),
},
.scrollback_lines = conf->scrollback.lines,
.app_sync_updates.timer_fd = app_sync_updates_fd,
@@ -1504,6 +1505,394 @@ close_fds:
return NULL;
}
+struct terminal *
+term_tab_new(struct terminal *primary,
+ int argc, char *const *argv, const char *const *envp,
+ void (*shutdown_cb)(void *data, int exit_code), void *shutdown_data)
+{
+ struct wl_window *win = primary->window;
+
+ if (win->tab_count >= TAB_MAX) {
+ LOG_ERR("maximum number of tabs (%d) reached", TAB_MAX);
+ return NULL;
+ }
+
+ const struct config *conf = primary->conf;
+ struct fdm *fdm = primary->fdm;
+ struct reaper *reaper = primary->reaper;
+ struct wayland *wayl = primary->wl;
+
+ int ptmx = -1;
+ int flash_fd = -1;
+ int delay_lower_fd = -1;
+ int delay_upper_fd = -1;
+ int app_sync_updates_fd = -1;
+ int title_update_fd = -1;
+ int icon_update_fd = -1;
+ int app_id_update_fd = -1;
+
+ struct terminal *term = malloc(sizeof(*term));
+ if (unlikely(term == NULL)) {
+ LOG_ERRNO("malloc() failed");
+ return NULL;
+ }
+
+ ptmx = posix_openpt(PTY_OPEN_FLAGS);
+ if (ptmx < 0) {
+ LOG_ERRNO("failed to open PTY for new tab");
+ goto close_fds;
+ }
+ if ((flash_fd = timerfd_create(CLOCK_MONOTONIC, TFD_CLOEXEC | TFD_NONBLOCK)) < 0 ||
+ (delay_lower_fd = timerfd_create(CLOCK_MONOTONIC, TFD_CLOEXEC | TFD_NONBLOCK)) < 0 ||
+ (delay_upper_fd = timerfd_create(CLOCK_MONOTONIC, TFD_CLOEXEC | TFD_NONBLOCK)) < 0 ||
+ (app_sync_updates_fd = timerfd_create(CLOCK_MONOTONIC, TFD_CLOEXEC | TFD_NONBLOCK)) < 0 ||
+ (title_update_fd = timerfd_create(CLOCK_MONOTONIC, TFD_CLOEXEC | TFD_NONBLOCK)) < 0 ||
+ (icon_update_fd = timerfd_create(CLOCK_MONOTONIC, TFD_CLOEXEC | TFD_NONBLOCK)) < 0 ||
+ (app_id_update_fd = timerfd_create(CLOCK_MONOTONIC, TFD_CLOEXEC | TFD_NONBLOCK)) < 0)
+ {
+ LOG_ERRNO("failed to create timer FDs for new tab");
+ goto close_fds;
+ }
+
+ if (ioctl(ptmx, (unsigned int)TIOCSWINSZ,
+ &(struct winsize){
+ .ws_row = (unsigned short)primary->rows,
+ .ws_col = (unsigned short)primary->cols}) < 0)
+ {
+ LOG_ERRNO("failed to set TIOCSWINSZ for new tab");
+ goto close_fds;
+ }
+
+ key_binding_new_for_conf(wayl->key_binding_manager, wayl, conf);
+
+ int ptmx_flags;
+ if ((ptmx_flags = fcntl(ptmx, F_GETFL)) < 0 ||
+ fcntl(ptmx, F_SETFL, ptmx_flags | O_NONBLOCK) < 0)
+ {
+ LOG_ERRNO("failed to configure ptmx as non-blocking");
+ goto err;
+ }
+
+ const enum shm_bit_depth desired_bit_depth =
+ conf->tweak.surface_bit_depth == SHM_BITS_AUTO
+ ? wayl_do_linear_blending(wayl, conf) ? SHM_BITS_16 : SHM_BITS_8
+ : conf->tweak.surface_bit_depth;
+
+ const struct color_theme *theme = NULL;
+ switch (conf->initial_color_theme) {
+ case COLOR_THEME_DARK: theme = &conf->colors_dark; break;
+ case COLOR_THEME_LIGHT: theme = &conf->colors_light; break;
+ case COLOR_THEME_1: BUG("COLOR_THEME_1 should not be used"); break;
+ case COLOR_THEME_2: BUG("COLOR_THEME_2 should not be used"); break;
+ }
+
+ *term = (struct terminal){
+ .fdm = fdm,
+ .reaper = reaper,
+ .conf = conf,
+ .is_tab = true,
+ .slave = -1,
+ .ptmx = ptmx,
+ .ptmx_buffers = tll_init(),
+ .ptmx_paste_buffers = tll_init(),
+ .font_sizes = {
+ xmalloc(sizeof(term->font_sizes[0][0]) * conf->fonts[0].count),
+ xmalloc(sizeof(term->font_sizes[1][0]) * conf->fonts[1].count),
+ xmalloc(sizeof(term->font_sizes[2][0]) * conf->fonts[2].count),
+ xmalloc(sizeof(term->font_sizes[3][0]) * conf->fonts[3].count),
+ },
+ .font_dpi = 0.,
+ .font_dpi_before_unmap = -1.,
+ .font_subpixel = (theme->alpha == 0xffff
+ ? FCFT_SUBPIXEL_DEFAULT
+ : FCFT_SUBPIXEL_NONE),
+ .cursor_keys_mode = CURSOR_KEYS_NORMAL,
+ .keypad_keys_mode = KEYPAD_NUMERICAL,
+ .reverse_wrap = true,
+ .auto_margin = true,
+ .window_title_stack = tll_init(),
+ .scale = primary->scale,
+ .scale_before_unmap = -1,
+ .flash = {.fd = flash_fd},
+ .blink = {.fd = -1},
+ .vt = {.state = 0},
+ .colors = {
+ .fg = theme->fg,
+ .bg = theme->bg,
+ .alpha = theme->alpha,
+ .cursor_fg = (theme->use_custom.cursor ? 1u << 31 : 0) | theme->cursor.text,
+ .cursor_bg = (theme->use_custom.cursor ? 1u << 31 : 0) | theme->cursor.cursor,
+ .selection_fg = theme->selection_fg,
+ .selection_bg = theme->selection_bg,
+ .active_theme = conf->initial_color_theme,
+ },
+ .color_stack = {.stack = NULL, .size = 0, .idx = 0},
+ .origin = ORIGIN_ABSOLUTE,
+ .cursor_style = conf->cursor.style,
+ .cursor_blink = {
+ .decset = false,
+ .deccsusr = conf->cursor.blink.enabled,
+ .state = CURSOR_BLINK_ON,
+ .fd = -1,
+ },
+ .selection = {
+ .coords = {.start = {-1, -1}, .end = {-1, -1}},
+ .pivot = {.start = {-1, -1}, .end = {-1, -1}},
+ .auto_scroll = {.fd = -1},
+ },
+ .normal = {.scroll_damage = tll_init(), .sixel_images = tll_init()},
+ .alt = {.scroll_damage = tll_init(), .sixel_images = tll_init()},
+ .grid = &term->normal,
+ .composed = NULL,
+ .alt_scrolling = conf->mouse.alternate_scroll_mode,
+ .meta = {.esc_prefix = true, .eight_bit = true},
+ .num_lock_modifier = true,
+ .bell_action_enabled = true,
+ .tab_stops = tll_init(),
+ .wl = wayl,
+ .window = win,
+ .render = {
+ .chains = {
+ .grid = shm_chain_new(wayl, true, 1 + conf->render_worker_count,
+ desired_bit_depth, &render_buffer_release_callback, term),
+ .search = shm_chain_new(wayl, false, 1, desired_bit_depth, NULL, NULL),
+ .scrollback_indicator = shm_chain_new(wayl, false, 1, desired_bit_depth, NULL, NULL),
+ .render_timer = shm_chain_new(wayl, false, 1, desired_bit_depth, NULL, NULL),
+ .url = shm_chain_new(wayl, false, 1, desired_bit_depth, NULL, NULL),
+ .csd = shm_chain_new(wayl, false, 1, desired_bit_depth, NULL, NULL),
+ .overlay = shm_chain_new(wayl, false, 1, desired_bit_depth, NULL, NULL),
+ .tab_bar = shm_chain_new(wayl, false, 1, desired_bit_depth, NULL, NULL),
+ },
+ .scrollback_lines = conf->scrollback.lines,
+ .app_sync_updates.timer_fd = app_sync_updates_fd,
+ .title = {.timer_fd = title_update_fd},
+ .icon = {.timer_fd = icon_update_fd},
+ .app_id = {.timer_fd = app_id_update_fd},
+ .workers = {
+ .count = conf->render_worker_count,
+ .queue = tll_init(),
+ },
+ },
+ .delayed_render_timer = {
+ .is_armed = false,
+ .lower_fd = delay_lower_fd,
+ .upper_fd = delay_upper_fd,
+ },
+ .sixel = {
+ .scrolling = true,
+ .use_private_palette = true,
+ .palette_size = SIXEL_MAX_COLORS,
+ .max_width = SIXEL_MAX_WIDTH,
+ .max_height = SIXEL_MAX_HEIGHT,
+ },
+ .shutdown = {
+ .terminate_timeout_fd = -1,
+ .cb = shutdown_cb,
+ .cb_data = shutdown_data,
+ },
+ .foot_exe = xstrdup(primary->foot_exe),
+ .cwd = xstrdup(
+ conf->tabs.inherit_cwd
+ ? win->term->cwd
+ : (getenv("HOME") != NULL ? getenv("HOME") : "/")),
+ .grapheme_shaping = conf->tweak.grapheme_shaping,
+#if defined(FOOT_IME_ENABLED) && FOOT_IME_ENABLED
+ .ime_enabled = true,
+#endif
+ .active_notifications = tll_init(),
+ };
+
+ pixman_region32_init(&term->render.last_overlay_clip);
+ term_update_ascii_printer(term);
+ memcpy(term->colors.table, theme->table, sizeof(term->colors.table));
+
+ /* Inherit font sizes from primary so the new tab matches its zoom level */
+ for (size_t i = 0; i < 4; i++) {
+ const struct config_font_list *font_list = &conf->fonts[i];
+ for (size_t j = 0; j < font_list->count; j++)
+ term->font_sizes[i][j] = primary->font_sizes[i][j];
+ }
+
+ for (size_t i = 0; i < ALEN(term->notification_icons); i++)
+ term->notification_icons[i].tmp_file_fd = -1;
+
+ if (!fdm_add(fdm, flash_fd, EPOLLIN, &fdm_flash, term) ||
+ !fdm_add(fdm, delay_lower_fd, EPOLLIN, &fdm_delayed_render, term) ||
+ !fdm_add(fdm, delay_upper_fd, EPOLLIN, &fdm_delayed_render, term) ||
+ !fdm_add(fdm, app_sync_updates_fd, EPOLLIN, &fdm_app_sync_updates_timeout, term) ||
+ !fdm_add(fdm, title_update_fd, EPOLLIN, &fdm_title_update_timeout, term) ||
+ !fdm_add(fdm, icon_update_fd, EPOLLIN, &fdm_icon_update_timeout, term) ||
+ !fdm_add(fdm, app_id_update_fd, EPOLLIN, &fdm_app_id_update_timeout, term))
+ {
+ goto err;
+ }
+
+ add_utmp_record(conf, reaper, ptmx);
+
+ if ((term->slave = slave_spawn(
+ term->ptmx, argc, term->cwd, argv, envp, &conf->env_vars,
+ conf->term, conf->shell, conf->login_shell,
+ &conf->notifications)) == -1)
+ {
+ goto err;
+ }
+
+ reaper_add(term->reaper, term->slave, &fdm_client_terminated, term);
+
+ /* Load fonts (uses primary's scale) */
+ if (!term_font_dpi_changed(term, 0.))
+ goto err;
+
+ term->font_subpixel = get_font_subpixel(term);
+
+ /* Resize grid to match current window dimensions. cell_width and
+ * cell_height were already computed by term_set_fonts above, using the
+ * inherited font_sizes — inheriting primary's cell geometry would mask
+ * that, since primary may be mid-zoom or mid-reload. */
+ term->width = primary->width;
+ term->height = primary->height;
+
+ tll_push_back(wayl->terms, term);
+
+ /* Register in window's tab list */
+ win->tabs[win->tab_count++] = term;
+
+ /* Enable ptmx I/O now (window is already configured) */
+ fdm_add(term->fdm, term->ptmx, EPOLLIN, &fdm_ptmx, term);
+
+ if (!initialize_render_workers(term))
+ goto err;
+
+ /* Switch to the new tab */
+ term_tab_switch(win, win->tab_count - 1);
+
+ return term;
+
+err:
+ term->shutdown.in_progress = true;
+ term_destroy(term);
+ return NULL;
+
+close_fds:
+ if (ptmx >= 0) close(ptmx);
+ fdm_del(fdm, flash_fd);
+ fdm_del(fdm, delay_lower_fd);
+ fdm_del(fdm, delay_upper_fd);
+ fdm_del(fdm, app_sync_updates_fd);
+ fdm_del(fdm, title_update_fd);
+ fdm_del(fdm, icon_update_fd);
+ fdm_del(fdm, app_id_update_fd);
+ free(term);
+ return NULL;
+}
+
+void
+term_tab_switch(struct wl_window *win, size_t idx)
+{
+ if (idx >= win->tab_count)
+ return;
+
+ struct terminal *prev = win->term;
+ /* render_resize expects logical (unscaled) sizes; prev->width/height
+ * are physical pixels. Convert back to logical so render_resize's
+ * internal scale multiplication doesn't double-apply it. */
+ int cur_width = (int)roundf(prev->width / prev->scale);
+ int cur_height = (int)roundf(prev->height / prev->scale);
+
+ win->active_tab = idx;
+ win->term = win->tabs[idx];
+
+ struct terminal *term = win->term;
+
+ /* Inherit the surface kind so cursor updates work without a pointer re-enter */
+ term->active_surface = prev->active_surface;
+
+ /* Update keyboard and mouse focus for all seats looking at this window.
+ * Do this before term_kbd_focus_out(prev) so its guard (checking whether
+ * any seat still points to it) sees that no seat does. */
+ tll_foreach(term->wl->seats, it) {
+ struct seat *seat = &it->item;
+ if (seat->kbd_focus != NULL && seat->kbd_focus->window == win)
+ seat->kbd_focus = term;
+ if (seat->mouse_focus != NULL && seat->mouse_focus->window == win)
+ seat->mouse_focus = term;
+ }
+
+ /* prev loses keyboard focus now that no seat points to it */
+ term_kbd_focus_out(prev);
+
+ bool has_kbd_focus = false;
+ tll_foreach(term->wl->seats, it) {
+ if (it->item.kbd_focus == term) {
+ has_kbd_focus = true;
+ break;
+ }
+ }
+ if (has_kbd_focus)
+ term_visual_focus_in(term);
+ else
+ term_visual_focus_out(term);
+
+ /* Scale changes (fractional-scale-v1, preferred_buffer_scale, output
+ * enter/leave) only notify win->term — inactive tabs miss them. Catch
+ * the incoming tab up on scale, DPI, and font size before we resize. */
+ const float old_scale = term->scale;
+ term_update_scale(term);
+ term_font_dpi_changed(term, old_scale);
+ term_font_subpixel_changed(term);
+
+ /* Cancel the outgoing tab's in-flight frame_callback. Its listener data
+ * points at prev, so when it fires it services prev's empty pending bits
+ * and nothing gets drawn. Dropping it lets fdm_hook_refresh_pending_terminals
+ * take the immediate-render path for the new active tab. */
+ if (win->frame_callback != NULL) {
+ wl_callback_destroy(win->frame_callback);
+ win->frame_callback = NULL;
+ }
+
+ /* Use current window dimensions, not the inactive tab's stale ones.
+ * render_resize allocates the grid rows — term_kbd_focus_in must come
+ * after this because cursor_refresh dereferences cur_row. */
+ render_resize(term, cur_width, cur_height, RESIZE_FORCE);
+
+ if (has_kbd_focus)
+ term_kbd_focus_in(term);
+
+ render_refresh_csd(term);
+ render_refresh_tab_bar(term);
+ render_refresh_title(term);
+ term_xcursor_update(term);
+}
+
+void
+term_tab_close(struct terminal *term)
+{
+ term_shutdown(term);
+}
+
+size_t
+term_tab_bar_hit_test(const struct terminal *term, int x, int y)
+{
+ const struct wl_window *win = term->window;
+ if (win->tab_bar.sub == NULL)
+ return SIZE_MAX;
+
+ const size_t n = win->tab_layout.count;
+ if (n == 0)
+ return SIZE_MAX;
+
+ if (y < win->tab_layout.y || y >= win->tab_layout.y + win->tab_layout.h)
+ return SIZE_MAX;
+
+ for (size_t i = 0; i < n; i++) {
+ const int tx = win->tab_layout.xs[i];
+ const int tw = win->tab_layout.ws[i];
+ if (x >= tx && x < tx + tw)
+ return i;
+ }
+ return SIZE_MAX;
+}
+
void
term_window_configured(struct terminal *term)
{
@@ -1585,10 +1974,10 @@ static void
shutdown_maybe_done(struct terminal *term)
{
bool shutdown_done =
- term->window == NULL && term->shutdown.client_has_terminated;
+ term->shutdown.fdm_done && term->shutdown.client_has_terminated;
- LOG_DBG("window=%p, slave-has-been-reaped=%d --> %s",
- (void *)term->window, term->shutdown.client_has_terminated,
+ LOG_DBG("fdm_done=%d, slave-has-been-reaped=%d --> %s",
+ term->shutdown.fdm_done, term->shutdown.client_has_terminated,
(shutdown_done
? "shutdown done, calling term_destroy()"
: "no action"));
@@ -1632,26 +2021,35 @@ fdm_shutdown(struct fdm *fdm, int fd, int events, void *data)
/* Kill the event FD */
fdm_del(term->fdm, fd);
- wayl_win_destroy(term->window);
- term->window = NULL;
+ struct wl_window *win = term->window;
- struct wayland *wayl = term->wl;
+ if (win != NULL && win->tab_count <= 1) {
+ /*
+ * Last (or only) tab — destroy the whole window.
+ *
+ * Normally we'd get unmapped when we destroy the Wayland
+ * surface above. However, it appears that under certain
+ * conditions, those events are deferred (for example, when
+ * a screen locker is active), and thus we can get here
+ * without having been unmapped.
+ */
+ wayl_win_destroy(win);
+ term->window = NULL;
+ struct wayland *wayl = term->wl;
+ tll_foreach(wayl->seats, it) {
+ if (it->item.kbd_focus == term)
+ it->item.kbd_focus = NULL;
+ if (it->item.mouse_focus == term)
+ it->item.mouse_focus = NULL;
+ }
+ }
/*
- * Normally we'd get unmapped when we destroy the Wayland
- * above.
- *
- * However, it appears that under certain conditions, those events
- * are deferred (for example, when a screen locker is active), and
- * thus we can get here without having been unmapped.
+ * Multi-tab case: leave term->window intact so term_destroy() can
+ * use its existing tab-removal and tab-switch logic.
*/
- tll_foreach(wayl->seats, it) {
- if (it->item.kbd_focus == term)
- it->item.kbd_focus = NULL;
- if (it->item.mouse_focus == term)
- it->item.mouse_focus = NULL;
- }
+ term->shutdown.fdm_done = true;
shutdown_maybe_done(term);
return true;
}
@@ -1840,7 +2238,99 @@ term_destroy(struct terminal *term)
fdm_del(term->fdm, term->shutdown.terminate_timeout_fd);
if (term->window != NULL) {
- wayl_win_destroy(term->window);
+ struct wl_window *win = term->window;
+
+ /* Remove ourselves from the window's tab list */
+ bool was_active = (win->term == term);
+ size_t our_idx = win->tab_count; /* invalid sentinel */
+ for (size_t i = 0; i < win->tab_count; i++) {
+ if (win->tabs[i] == term) {
+ our_idx = i;
+ memmove(&win->tabs[i], &win->tabs[i + 1],
+ (win->tab_count - i - 1) * sizeof(win->tabs[0]));
+ win->tab_count--;
+ if (win->active_tab > 0 && win->active_tab >= win->tab_count)
+ win->active_tab = win->tab_count - 1;
+ else if (our_idx < win->active_tab)
+ win->active_tab--;
+ break;
+ }
+ }
+
+ if (win->tab_count == 0) {
+ /* Last tab - destroy the window normally */
+ wayl_win_destroy(win);
+ } else {
+ /* Other tabs remain - just purge our render chains */
+
+ /* Cancel any pending frame callback that still holds a pointer
+ * to this terminal, preventing a use-after-free when the
+ * compositor fires it after term is freed. */
+ if (win->frame_callback != NULL) {
+ wl_callback_destroy(win->frame_callback);
+ win->frame_callback = NULL;
+ }
+
+ render_wait_for_preapply_damage(term);
+ shm_purge(term->render.chains.search);
+ shm_purge(term->render.chains.scrollback_indicator);
+ shm_purge(term->render.chains.render_timer);
+ shm_purge(term->render.chains.grid);
+ shm_purge(term->render.chains.url);
+ shm_purge(term->render.chains.csd);
+ shm_purge(term->render.chains.tab_bar);
+ shm_purge(term->render.chains.overlay);
+
+ /* Switch to the new active tab if needed */
+ if (was_active) {
+ struct terminal *next = win->tabs[win->active_tab];
+ win->term = next;
+ next->active_surface = term->active_surface;
+
+ /* Update keyboard and mouse focus */
+ bool has_kbd_focus = false;
+ tll_foreach(next->wl->seats, it) {
+ struct seat *seat = &it->item;
+ if (seat->kbd_focus == term)
+ seat->kbd_focus = next;
+ if (seat->mouse_focus == term)
+ seat->mouse_focus = next;
+ if (seat->kbd_focus == next)
+ has_kbd_focus = true;
+ }
+
+ if (has_kbd_focus) {
+ term_kbd_focus_in(next);
+ term_visual_focus_in(next);
+ } else {
+ term_visual_focus_out(next);
+ }
+
+ render_resize(next,
+ (int)roundf(term->width / term->scale),
+ (int)roundf(term->height / term->scale),
+ RESIZE_FORCE);
+ render_refresh_csd(next);
+ render_refresh_tab_bar(next);
+ render_refresh_title(next);
+ term_xcursor_update(next);
+ } else {
+ /* If we just dropped to a single tab, the surviving
+ * active tab needs to resize to reclaim the space the
+ * tab bar was occupying. */
+ if (win->tab_count == 1) {
+ struct terminal *active = win->term;
+ render_resize(active,
+ (int)roundf(active->width / active->scale),
+ (int)roundf(active->height / active->scale),
+ RESIZE_FORCE);
+ }
+ /* Just update the tab bar to reflect removed tab */
+ render_refresh_tab_bar(win->term);
+ render_refresh_title(win->term);
+ }
+ }
+
term->window = NULL;
}
@@ -1917,6 +2407,7 @@ term_destroy(struct terminal *term)
shm_chain_free(term->render.chains.url);
shm_chain_free(term->render.chains.csd);
shm_chain_free(term->render.chains.overlay);
+ shm_chain_free(term->render.chains.tab_bar);
pixman_region32_fini(&term->render.last_overlay_clip);
tll_free(term->tab_stops);
@@ -3637,6 +4128,12 @@ term_xcursor_update_for_seat(struct terminal *term, struct seat *seat)
shape = CURSOR_SHAPE_LEFT_PTR;
break;
+ case TERM_SURF_TAB_BAR: {
+ size_t idx = term_tab_bar_hit_test(term, seat->mouse.x, seat->mouse.y);
+ shape = (idx != SIZE_MAX) ? CURSOR_SHAPE_POINTER : CURSOR_SHAPE_LEFT_PTR;
+ break;
+ }
+
case TERM_SURF_BORDER_LEFT:
case TERM_SURF_BORDER_RIGHT:
case TERM_SURF_BORDER_TOP:
@@ -3680,6 +4177,7 @@ term_set_window_title(struct terminal *term, const char *title)
free(term->window_title);
term->window_title = xstrdup(title);
render_refresh_title(term);
+ render_refresh_tab_bar(term);
term->window_title_has_been_set = true;
}
@@ -4448,6 +4946,8 @@ term_surface_kind(const struct terminal *term, const struct wl_surface *surface)
return TERM_SURF_BUTTON_MAXIMIZE;
else if (surface == term->window->csd.surface[CSD_SURF_CLOSE].surface.surf)
return TERM_SURF_BUTTON_CLOSE;
+ else if (surface == term->window->tab_bar.surface.surf)
+ return TERM_SURF_TAB_BAR;
else
return TERM_SURF_NONE;
}
diff --git a/terminal.h b/terminal.h
index 446d5f23..876db0d7 100644
--- a/terminal.h
+++ b/terminal.h
@@ -361,6 +361,7 @@ enum term_surface {
TERM_SURF_BUTTON_MINIMIZE,
TERM_SURF_BUTTON_MAXIMIZE,
TERM_SURF_BUTTON_CLOSE,
+ TERM_SURF_TAB_BAR,
};
enum overlay_style {
@@ -402,6 +403,7 @@ struct terminal {
struct fdm *fdm;
struct reaper *reaper;
const struct config *conf;
+ bool is_tab; /* Secondary tab terminal (shares window with primary) */
void (*ascii_printer)(struct terminal *term, char32_t c);
union {
@@ -645,6 +647,7 @@ struct terminal {
struct buffer_chain *url;
struct buffer_chain *csd;
struct buffer_chain *overlay;
+ struct buffer_chain *tab_bar;
} chains;
/* Scheduled for rendering, as soon-as-possible */
@@ -811,6 +814,7 @@ struct terminal {
struct {
bool in_progress;
bool client_has_terminated;
+ bool fdm_done;
int terminate_timeout_fd;
int exit_status;
int next_signal;
@@ -842,6 +846,15 @@ struct terminal *term_init(
int argc, char *const *argv, const char *const *envp,
void (*shutdown_cb)(void *data, int exit_code), void *shutdown_data);
+struct terminal *term_tab_new(
+ struct terminal *primary,
+ int argc, char *const *argv, const char *const *envp,
+ void (*shutdown_cb)(void *data, int exit_code), void *shutdown_data);
+
+void term_tab_switch(struct wl_window *win, size_t idx);
+void term_tab_close(struct terminal *term);
+size_t term_tab_bar_hit_test(const struct terminal *term, int x, int y);
+
bool term_shutdown(struct terminal *term);
int term_destroy(struct terminal *term);
diff --git a/wayland.c b/wayland.c
index f5737c1e..ed61d2e7 100644
--- a/wayland.c
+++ b/wayland.c
@@ -2163,6 +2163,19 @@ wayl_win_init(struct terminal *term, const char *token)
goto out;
}
+ if (conf->tabs.enabled) {
+ if (!wayl_win_subsurface_new(win, &win->tab_bar, true)) {
+ LOG_ERR("failed to create tab bar surface");
+ goto out;
+ }
+ wl_subsurface_set_desync(win->tab_bar.sub);
+ }
+
+ /* Initialize tab list with primary terminal */
+ win->tabs[0] = term;
+ win->tab_count = 1;
+ win->active_tab = 0;
+
switch (conf->tweak.render_timer) {
case RENDER_TIMER_OSD:
case RENDER_TIMER_BOTH:
@@ -2221,6 +2234,12 @@ wayl_win_destroy(struct wl_window *win)
wl_surface_commit(win->search.surface.surf);
}
+ /* Tab bar */
+ if (win->tab_bar.surface.surf != NULL) {
+ wl_surface_attach(win->tab_bar.surface.surf, NULL, 0, 0);
+ wl_surface_commit(win->tab_bar.surface.surf);
+ }
+
/* URLs */
tll_foreach(win->urls, it) {
wl_surface_attach(it->item.surf.surface.surf, NULL, 0, 0);
@@ -2257,6 +2276,7 @@ wayl_win_destroy(struct wl_window *win)
wayl_win_subsurface_destroy(&win->scrollback_indicator);
wayl_win_subsurface_destroy(&win->render_timer);
wayl_win_subsurface_destroy(&win->overlay);
+ wayl_win_subsurface_destroy(&win->tab_bar);
shm_purge(term->render.chains.search);
shm_purge(term->render.chains.scrollback_indicator);
@@ -2264,6 +2284,7 @@ wayl_win_destroy(struct wl_window *win)
shm_purge(term->render.chains.grid);
shm_purge(term->render.chains.url);
shm_purge(term->render.chains.csd);
+ shm_purge(term->render.chains.tab_bar);
tll_foreach(win->xdg_tokens, it) {
xdg_activation_token_v1_destroy(it->item->xdg_token);
diff --git a/wayland.h b/wayland.h
index 9cbd1023..9e1a3a6d 100644
--- a/wayland.h
+++ b/wayland.h
@@ -401,6 +401,22 @@ struct wl_window {
struct wayl_sub_surface scrollback_indicator;
struct wayl_sub_surface render_timer;
struct wayl_sub_surface overlay;
+ struct wayl_sub_surface tab_bar;
+
+ /* Tab management */
+#define TAB_MAX 32
+ struct terminal *tabs[TAB_MAX];
+ size_t tab_count;
+ size_t active_tab;
+
+ /* Tab bar geometry cache, filled by render_tab_bar, read by hit-testing */
+ struct {
+ int xs[TAB_MAX];
+ int ws[TAB_MAX];
+ int y;
+ int h;
+ size_t count;
+ } tab_layout;
struct wl_callback *frame_callback;