Draw passive Alt-hold tab-title overlay with FreeType text

This commit is contained in:
Andrew Hamilton 2026-08-14 12:39:51 +10:00
parent e52ac98afe
commit db6bbac84c
4 changed files with 368 additions and 19 deletions

View file

@ -87,9 +87,16 @@ Notes:
| Key | Action |
|----------------------------|---------------------------------------------------|
| `Alt` (hold) | Show the tab-title overlay (top-left) |
| `Alt+Up` | Previous tab (hold to keep cycling) |
| `Alt+Down` | Next tab (hold to keep cycling) |
Holding `Alt` shows a passive overlay listing every tab's title with the
active tab highlighted; the list updates live as you cycle with `Up`/`Down`
and disappears on `Alt` release. It is drawn by tabbed itself, so it works
on hosts with or without a title bar (the overlay text uses a system font
via FreeType; set `TABBED_FONT` to pick one).
The tab-cycling hotkey only emits a physical press for the first tab switch;
repeats are synthesized, so holding `Alt+Up/Down` keeps stepping through
tabs while any other key stops it.

View file

@ -27,6 +27,7 @@ deps = [
dependency('xkbcommon'),
dependency('egl'),
dependency('glesv2'),
dependency('freetype2'),
]
cc = meson.get_compiler('c')

View file

@ -45,6 +45,9 @@
#include <xkbcommon/xkbcommon.h>
#include <ft2build.h>
#include FT_FREETYPE_H
#include <wlr/backend.h>
#include <wlr/backend/headless.h>
#include <wlr/interfaces/wlr_keyboard.h>
@ -125,6 +128,14 @@ struct tabbed_server {
/* ===================== Host-side (nested) state ===================== */
/* One cached ASCII glyph in the overlay's font atlas texture. */
struct overlay_glyph {
int ax, ay; /* atlas pixel origin */
int pw, ph; /* glyph bitmap size */
int adv; /* advance width, pixels */
int top; /* y offset of the glyph top from the row top */
};
struct tabbed_host {
struct wl_display *display;
struct wl_registry *registry;
@ -154,8 +165,15 @@ struct tabbed_host {
int32_t axis_discrete[2];
/* GL program */
GLuint gl_program;
GLint gl_pos_loc, gl_uv_loc, gl_tex_loc;
GLint gl_pos_loc, gl_uv_loc, gl_tex_loc, gl_color_loc;
GLuint import_tex;
/* Alt-held tab-title overlay */
bool overlay_visible;
GLuint overlay_atlas_tex;
GLuint overlay_white_tex;
int overlay_atlas_w, overlay_atlas_h;
struct overlay_glyph overlay_glyphs[95]; /* chars 32..126 */
int overlay_font_h; /* ascent + descent, pixels */
/* Present gating: at most one buffer aimed at the host compositor at a
time, paced by the host surface's wl_callback. */
struct wl_callback *frame_cb;
@ -339,6 +357,8 @@ static void remove_socket_file(void) {
unlink(lock_path);
}
static void overlay_font_fini(void);
/* Grow server.apps to hold at least n entries. Returns 0 on success, -1 on
allocation failure. The buffer is freed in shutdown_cleanup(). */
static int apps_ensure_cap(int n) {
@ -367,6 +387,7 @@ static void shutdown_cleanup(void) {
if (server.xkb_state) xkb_state_unref(server.xkb_state);
if (server.xkb_keymap) xkb_keymap_unref(server.xkb_keymap);
if (server.xkb_ctx) xkb_context_unref(server.xkb_ctx);
overlay_font_fini();
bridge_cleanup();
remove_socket_file();
}
@ -414,8 +435,9 @@ static bool gl_setup(void) {
"precision mediump float;\n"
"varying vec2 v_uv;\n"
"uniform sampler2D u_tex;\n"
"uniform vec4 u_color;\n"
"void main() {\n"
" gl_FragColor = texture2D(u_tex, v_uv);\n"
" gl_FragColor = texture2D(u_tex, v_uv) * u_color;\n"
"}\n";
GLuint vsh = compile_shader(GL_VERTEX_SHADER, vs);
GLuint fsh = compile_shader(GL_FRAGMENT_SHADER, fs);
@ -439,6 +461,7 @@ static bool gl_setup(void) {
host.gl_pos_loc = glGetAttribLocation(host.gl_program, "a_pos");
host.gl_uv_loc = glGetAttribLocation(host.gl_program, "a_uv");
host.gl_tex_loc = glGetUniformLocation(host.gl_program, "u_tex");
host.gl_color_loc = glGetUniformLocation(host.gl_program, "u_color");
glGenTextures(1, &host.import_tex);
glBindTexture(GL_TEXTURE_2D, host.import_tex);
@ -447,39 +470,50 @@ static bool gl_setup(void) {
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
glBindTexture(GL_TEXTURE_2D, 0);
glGenTextures(1, &host.overlay_white_tex);
glBindTexture(GL_TEXTURE_2D, host.overlay_white_tex);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
const GLubyte white_px[4] = { 255, 255, 255, 255 };
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, 1, 1, 0, GL_RGBA,
GL_UNSIGNED_BYTE, white_px);
glBindTexture(GL_TEXTURE_2D, 0);
return true;
}
/* Blit a source rect of a texture into the window at the given destination
offset (window coords), clipped to the window. */
rect (dst_x, dst_y, dst_w, dst_h, window coords), clipped to the window.
The source is stretched uniformly to the destination (1:1 when the rects
match). The draw is tinted by `color` (RGBA), so solid rects are drawn
from a 1x1 white texture and text glyphs from the font atlas. */
static void draw_texture_rect(GLuint tex, int tex_w, int tex_h,
int src_x, int src_y, int src_w, int src_h,
int dst_x, int dst_y, int win_w, int win_h)
int dst_x, int dst_y, int dst_w, int dst_h,
int win_w, int win_h, const GLfloat color[4])
{
if (win_w <= 0 || win_h <= 0) return;
if (src_w <= 0 || src_h <= 0) return;
if (src_w <= 0 || src_h <= 0 || dst_w <= 0 || dst_h <= 0) return;
if (src_x >= tex_w || src_y >= tex_h) return;
if (src_w > tex_w - src_x) src_w = tex_w - src_x;
if (src_h > tex_h - src_y) src_h = tex_h - src_y;
int cx = dst_x < 0 ? -dst_x : 0;
int cy = dst_y < 0 ? -dst_y : 0;
if (cx >= src_w || cy >= src_h) return;
if (cx >= dst_w || cy >= dst_h) return;
int wx0 = dst_x + cx;
int wy0 = dst_y + cy;
int cw = src_w - cx;
int ch = src_h - cy;
int cw = dst_w - cx;
int ch = dst_h - cy;
int maxw = win_w - wx0;
int maxh = win_h - wy0;
if (cw > maxw) cw = maxw;
if (ch > maxh) ch = maxh;
if (cw <= 0 || ch <= 0) return;
src_x += cx;
src_y += cy;
float u0 = (float)src_x / (float)tex_w;
float u1 = (float)(src_x + cw) / (float)tex_w;
float v0 = (float)src_y / (float)tex_h;
float v1 = (float)(src_y + ch) / (float)tex_h;
float u0 = ((float)src_x + (float)src_w * cx / dst_w) / (float)tex_w;
float u1 = ((float)src_x + (float)src_w * (cx + cw) / dst_w) / (float)tex_w;
float v0 = ((float)src_y + (float)src_h * cy / dst_h) / (float)tex_h;
float v1 = ((float)src_y + (float)src_h * (cy + ch) / dst_h) / (float)tex_h;
float nx0 = -1.0f + 2.0f * (float)wx0 / (float)win_w;
float ny0 = 1.0f - 2.0f * (float)wy0 / (float)win_h;
@ -504,6 +538,7 @@ static void draw_texture_rect(GLuint tex, int tex_w, int tex_h,
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, tex);
glUniform1i(host.gl_tex_loc, 0);
glUniform4fv(host.gl_color_loc, 1, color);
glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, 0, verts);
glEnableVertexAttribArray(0);
@ -516,6 +551,262 @@ static void draw_texture_rect(GLuint tex, int tex_w, int tex_h,
glDisableVertexAttribArray(1);
}
/* ============ Alt-held tab-title overlay ============
While the host Alt modifier is held, a passive list of tab titles is
drawn into the top-left of the host window with the active tab
highlighted. Up/Down already cycle tabs while Alt is held, so the
highlight follows server.active automatically. Titles are read from the
wlr_xdg_toplevel at draw time (terminals retitle themselves), so no
per-client listeners are needed. */
static FT_Library overlay_ft_lib;
static FT_Face overlay_ft_face;
static bool overlay_font_ok;
/* Fixed rasterized font size; 30px is twice the original 15px constant. */
#define OVERLAY_FONT_PX 30
static const char *overlay_pick_font(void) {
static const char *const paths[] = {
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
"/usr/share/fonts/dejavu/DejaVuSans.ttf",
"/usr/share/fonts/TTF/DejaVuSans.ttf",
"/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf",
"/usr/share/fonts/truetype/liberation2/LiberationSans-Regular.ttf",
"/usr/share/fonts/liberation/LiberationSans-Regular.ttf",
"/usr/share/fonts/noto/NotoSans-Regular.ttf",
};
const char *env = getenv("TABBED_FONT");
if (env && access(env, R_OK) == 0)
return env;
for (size_t i = 0; i < sizeof(paths) / sizeof(paths[0]); i++)
if (access(paths[i], R_OK) == 0)
return paths[i];
return NULL;
}
/* Build the ASCII glyph atlas (chars 32..126) from the chosen system font.
Must be called with the host EGL context current. Non-fatal: on failure
the overlay still renders its boxes, just without text. */
static void overlay_font_init(void) {
int px = OVERLAY_FONT_PX;
static const char *overlay_font_path = NULL;
if (!overlay_ft_lib) {
const char *path = overlay_pick_font();
if (!path) {
tabbed_log(TABBED_LOG_INFO, "[overlay] no font found; "
"set TABBED_FONT to enable overlay text\n");
return;
}
overlay_font_path = path;
if (FT_Init_FreeType(&overlay_ft_lib) != 0) {
tabbed_log(TABBED_LOG_INFO, "[overlay] FreeType init failed\n");
return;
}
if (FT_New_Face(overlay_ft_lib, path, 0, &overlay_ft_face) != 0) {
tabbed_log(TABBED_LOG_INFO, "[overlay] cannot load font %s\n", path);
FT_Done_FreeType(overlay_ft_lib);
overlay_ft_lib = NULL;
return;
}
}
if (FT_Set_Pixel_Sizes(overlay_ft_face, 0, px) != 0) {
tabbed_log(TABBED_LOG_INFO, "[overlay] cannot set pixel size %d\n", px);
return;
}
int height = (int)(overlay_ft_face->size->metrics.height / 64.0f) + 1;
int asc = (int)(overlay_ft_face->size->metrics.ascender / 64.0f);
int adv_max = 0;
for (int c = 32; c <= 126; c++) {
if (FT_Load_Char(overlay_ft_face, c, FT_LOAD_DEFAULT) == 0) {
int adv = (int)(overlay_ft_face->glyph->advance.x / 64.0f);
if (adv > adv_max) adv_max = adv;
}
}
if (adv_max <= 0) adv_max = 8;
int cell_w = adv_max + 3;
int cell_h = height + 3;
int cols = 16;
int rows = (95 + cols - 1) / cols;
int atlas_w = cols * cell_w;
int atlas_h = rows * cell_h;
unsigned char *atlas = calloc((size_t)atlas_w * atlas_h * 2, 1);
if (!atlas) {
tabbed_log(TABBED_LOG_INFO, "[overlay] atlas alloc failed\n");
return;
}
int n_glyphs = 0;
for (int c = 32; c <= 126; c++) {
struct overlay_glyph *g = &host.overlay_glyphs[c - 32];
if (FT_Load_Char(overlay_ft_face, c, FT_LOAD_RENDER) != 0)
continue;
FT_Bitmap *bmp = &overlay_ft_face->glyph->bitmap;
/* A bitmap wider/taller than its cell would corrupt the glyphs it
spills into; skip such glyphs rather than damage the atlas. */
if ((int)bmp->width > cell_w - 2 || (int)bmp->rows > cell_h - 2)
continue;
int cell = c - 32;
int cx = (cell % cols) * cell_w;
int cy = (cell / cols) * cell_h;
g->ax = cx + 1;
g->ay = cy + 1;
g->pw = bmp->width;
g->ph = bmp->rows;
g->adv = (int)(overlay_ft_face->glyph->advance.x / 64.0f);
g->top = asc - overlay_ft_face->glyph->bitmap_top;
/* Cells are GL_LUMINANCE_ALPHA, so each 8-bit alpha is duplicated. */
for (int r = 0; r < (int)bmp->rows; r++) {
const unsigned char *src = bmp->buffer + (size_t)r * bmp->pitch;
unsigned char *dst = atlas
+ ((size_t)(g->ay + r) * atlas_w + g->ax) * 2;
for (int x = 0; x < (int)bmp->width; x++) {
dst[x * 2 + 0] = src[x];
dst[x * 2 + 1] = src[x];
}
}
n_glyphs++;
}
if (n_glyphs == 0) {
free(atlas);
return;
}
if (!host.overlay_atlas_tex)
glGenTextures(1, &host.overlay_atlas_tex);
glBindTexture(GL_TEXTURE_2D, host.overlay_atlas_tex);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
glTexImage2D(GL_TEXTURE_2D, 0, GL_LUMINANCE_ALPHA, atlas_w, atlas_h, 0,
GL_LUMINANCE_ALPHA, GL_UNSIGNED_BYTE, atlas);
glBindTexture(GL_TEXTURE_2D, 0);
free(atlas);
host.overlay_atlas_w = atlas_w;
host.overlay_atlas_h = atlas_h;
host.overlay_font_h = height;
overlay_font_ok = true;
tabbed_log(TABBED_LOG_INFO, "[overlay] font %s glyphs=%d atlas=%dx%d\n",
overlay_font_path, n_glyphs, atlas_w, atlas_h);
}
static void overlay_font_fini(void) {
if (overlay_ft_face) {
FT_Done_Face(overlay_ft_face);
overlay_ft_face = NULL;
}
if (overlay_ft_lib) {
FT_Done_FreeType(overlay_ft_lib);
overlay_ft_lib = NULL;
}
overlay_font_ok = false;
}
static void draw_rect(int x, int y, int w, int h,
float r, float g, float b, float a)
{
if (w <= 0 || h <= 0) return;
GLfloat color[4] = { r, g, b, a };
draw_texture_rect(host.overlay_white_tex, 1, 1,
0, 0, 1, 1, x, y, w, h, host.width, host.height, color);
}
/* Draw `s` at (x, y) (window coords, y is the row top) tinted by `color`.
Non-ASCII characters are skipped with a narrow advance. */
static void draw_text(const char *s, int x, int y,
float r, float g, float b, float a)
{
if (!overlay_font_ok) return;
GLfloat color[4] = { r, g, b, a };
int pen = x;
for (const unsigned char *p = (const unsigned char *)s; *p; p++) {
int c = *p;
if (c < 32 || c > 126) {
pen += 4;
continue;
}
struct overlay_glyph *glyph = &host.overlay_glyphs[c - 32];
draw_texture_rect(host.overlay_atlas_tex, host.overlay_atlas_w,
host.overlay_atlas_h,
glyph->ax, glyph->ay, glyph->pw, glyph->ph,
pen, y + glyph->top, glyph->pw, glyph->ph,
host.width, host.height, color);
pen += glyph->adv;
}
}
static int overlay_text_width(const char *s) {
if (!overlay_font_ok) return 0;
int w = 0;
for (const unsigned char *p = (const unsigned char *)s; *p; p++) {
int c = *p;
if (c < 32 || c > 126) {
w += 4;
continue;
}
w += host.overlay_glyphs[c - 32].adv;
}
return w;
}
static void draw_tab_overlay(void) {
if (!host.overlay_visible || server.num_apps < 1) return;
int margin = 8, boxpad = 6;
int row_h = host.overlay_font_h > 0 ? host.overlay_font_h + 8 : 18;
int max_title_w = 0, n_rows = 0;
for (int i = 0; i < server.num_apps; i++) {
struct tabbed_app *app = server.apps[i];
if (!app->toplevel) continue;
const char *title = app->toplevel->title;
char fallback[16];
if (!title || !title[0]) {
snprintf(fallback, sizeof(fallback), "tab %d", i);
title = fallback;
}
int w = overlay_text_width(title);
if (w > max_title_w) max_title_w = w;
n_rows++;
}
if (n_rows == 0) return;
int box_w = max_title_w + 2 * boxpad;
int box_h = n_rows * row_h + 2 * boxpad;
if (box_w > host.width - 2 * margin) box_w = host.width - 2 * margin;
if (box_h > host.height - 2 * margin) box_h = host.height - 2 * margin;
int box_x = margin, box_y = margin;
draw_rect(box_x, box_y, box_w, box_h, 0.07f, 0.07f, 0.09f, 1.0f);
int row = 0;
for (int i = 0; i < server.num_apps; i++) {
struct tabbed_app *app = server.apps[i];
if (!app->toplevel) continue;
const char *title = app->toplevel->title;
char fallback[16];
if (!title || !title[0]) {
snprintf(fallback, sizeof(fallback), "tab %d", i);
title = fallback;
}
int ry = box_y + boxpad + row * row_h;
bool active = app->toplevel == server.active;
if (active) {
int hw = overlay_text_width(title) + 4;
draw_rect(box_x + boxpad - 2, ry - 1, hw, row_h,
0.24f, 0.36f, 0.60f, 0.9f);
}
draw_text(title, box_x + boxpad, ry + (row_h - host.overlay_font_h) / 2,
active ? 1.0f : 0.72f,
active ? 1.0f : 0.72f,
active ? 1.0f : 0.72f,
active ? 1.0f : 0.9f);
row++;
}
}
/* ===================== Server-side wlroots setup ===================== */
static void swapchain_resize(int width, int height) {
@ -2105,6 +2396,10 @@ static void host_kbd_key(void *data, struct wl_keyboard *kbd, uint32_t serial,
? xkb_state_mod_index_is_active(server.xkb_state,
server.xkb_alt_mod, XKB_STATE_MODS_EFFECTIVE)
: (host.host_mods_depressed & (1u << 3)) != 0;
if (alt != host.overlay_visible) {
host.overlay_visible = alt;
needs_redraw = true;
}
char symbuf[32];
xkb_keysym_get_name(sym, symbuf, sizeof(symbuf));
tabbed_log(TABBED_LOG_DEBUG,
@ -2171,6 +2466,10 @@ static void host_kbd_modifiers(void *data, struct wl_keyboard *kbd, uint32_t ser
? xkb_state_mod_index_is_active(server.xkb_state,
server.xkb_alt_mod, XKB_STATE_MODS_EFFECTIVE)
: (mods_depressed & (1u << 3)) != 0;
if (alt != host.overlay_visible) {
host.overlay_visible = alt;
needs_redraw = true;
}
if (host.repeat_key && !alt)
kbd_repeat_cancel();
wlr_keyboard_notify_modifiers(&server.keyboard, mods_depressed,
@ -2517,6 +2816,7 @@ static bool host_init(void) {
host.egl_ok = false;
return true;
}
overlay_font_init();
host.egl_ok = true;
tabbed_log(TABBED_LOG_INFO, "tabbed-wlroots: EGL (GPU) rendering enabled\n");
@ -2658,10 +2958,13 @@ static void blit_scene_buffer(struct wlr_buffer *buf) {
bool imported = buf && import_scene_buffer(buf, host.import_tex);
if (imported) {
static const GLfloat white[4] = { 1, 1, 1, 1 };
glEnable(GL_BLEND);
glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);
draw_texture_rect(host.import_tex, buf->width, buf->height,
0, 0, buf->width, buf->height, 0, 0, w, h);
0, 0, buf->width, buf->height, 0, 0,
buf->width, buf->height, w, h, white);
draw_tab_overlay();
glDisable(GL_BLEND);
}
if (dump_host_req) {

View file

@ -221,6 +221,22 @@ class Session:
time.sleep(0.1)
pytest.fail("both apps never logged a [claim] line")
def pixel_at(self, path, x, y):
"""Pixel (x, y) of an 8-bit P6 ppm as an (R, G, B) tuple.
The dump writes the back buffer top-down (window coords), so row 0
of the file is the top of the window and column 0 is the left edge.
"""
with open(path, "rb") as f:
data = f.read()
lines = data.split(b"\n", 3)
if len(lines) < 3:
raise AssertionError(f"bad PPM {path}")
w, h = map(int, lines[1].split())
off = (len(lines[0]) + 1 + len(lines[1]) + 1 + len(lines[2]) + 1)
i = off + 3 * (y * w + x)
return tuple(data[i:i + 3])
def centre_of(self, path):
"""Centre pixel of an 8-bit P6 ppm as an (R, G, B) tuple."""
with open(path, "rb") as f:
@ -229,10 +245,7 @@ class Session:
if len(lines) < 3:
raise AssertionError(f"bad PPM {path}")
w, h = map(int, lines[1].split())
off = (len(lines[0]) + 1 + len(lines[1]) + 1 + len(lines[2]) + 1)
cx, cy = w // 2, h // 2
i = off + 3 * (cy * w + cx)
return tuple(data[i:i + 3])
return self.pixel_at(path, w // 2, h // 2)
def close(self):
if self.proc.poll() is None:
@ -452,6 +465,31 @@ def test_hold_alt_down_repeats(session):
assert session.active_count() == settled, "repeats continued after release"
def test_alt_holds_tab_overlay(session):
"""Holding Alt shows the passive tab-title overlay (top-left box over
the content); releasing Alt hides it again. The overlay is drawn into
the host frame in the same blit that produces the SIGUSR1 dump, so the
sampled top-left pixel darkens while Alt is held and reverts after."""
session.wait_any_active()
base = session.dump_host()
p_base = session.pixel_at(base, 9, 9)
session.mods(ALT)
over = session.dump_host()
p_over = session.pixel_at(over, 9, 9)
session.mods(0)
after = session.dump_host()
p_after = session.pixel_at(after, 9, 9)
assert p_after == p_base, (
f"overlay did not hide on Alt release ({p_base} vs {p_after})")
assert p_over != p_base, "overlay did not darken the top-left corner"
assert sum(p_over) < sum(p_base), (
f"overlay pixel not darker than content ({p_over} vs {p_base})")
assert session.proc.poll() is None
def test_non_ctrl_key_ignored(session):
start = session.wait_any_active()
assert start in (0, 1)