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;